diff --git a/.infisicalignore b/.infisicalignore index b80ecaad5..a88bdccbd 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -14,3 +14,11 @@ docs/self-hosting/guides/automated-bootstrapping.mdx:jwt:74 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx:generic-api-key:72 k8-operator/config/samples/crd/pushsecret/source-secret-with-templating.yaml:private-key:11 k8-operator/config/samples/crd/pushsecret/push-secret-with-template.yaml:private-key:52 +backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts:generic-api-key:125 +frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx:generic-api-key:67 +frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx:generic-api-key:14 +frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx:generic-api-key:11 +frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx:generic-api-key:23 +frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:28 +frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65 +frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26 diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index d3aed3543..e3261db70 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -38,6 +38,7 @@ import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; +import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -206,6 +207,7 @@ declare module "fastify" { certificateTemplate: TCertificateTemplateServiceFactory; sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; + sshHost: TSshHostServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 82582bfed..dc0e5ee67 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -232,6 +232,9 @@ import { TProjectSplitBackfillIds, TProjectSplitBackfillIdsInsert, TProjectSplitBackfillIdsUpdate, + TProjectSshConfigs, + TProjectSshConfigsInsert, + TProjectSshConfigsUpdate, TProjectsUpdate, TProjectTemplates, TProjectTemplatesInsert, @@ -380,6 +383,15 @@ import { TSshCertificateTemplates, TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate, + TSshHostLoginUserMappings, + TSshHostLoginUserMappingsInsert, + TSshHostLoginUserMappingsUpdate, + TSshHostLoginUsers, + TSshHostLoginUsersInsert, + TSshHostLoginUsersUpdate, + TSshHosts, + TSshHostsInsert, + TSshHostsUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -425,6 +437,7 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshHost]: KnexOriginal.CompositeTableType; [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< TSshCertificateAuthorities, TSshCertificateAuthoritiesInsert, @@ -450,6 +463,16 @@ declare module "knex/types/tables" { TSshCertificateBodiesInsert, TSshCertificateBodiesUpdate >; + [TableName.SshHostLoginUser]: KnexOriginal.CompositeTableType< + TSshHostLoginUsers, + TSshHostLoginUsersInsert, + TSshHostLoginUsersUpdate + >; + [TableName.SshHostLoginUserMapping]: KnexOriginal.CompositeTableType< + TSshHostLoginUserMappings, + TSshHostLoginUserMappingsInsert, + TSshHostLoginUserMappingsUpdate + >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, TCertificateAuthoritiesInsert, @@ -554,6 +577,11 @@ declare module "knex/types/tables" { [TableName.SuperAdmin]: KnexOriginal.CompositeTableType; [TableName.ApiKey]: KnexOriginal.CompositeTableType; [TableName.Project]: KnexOriginal.CompositeTableType; + [TableName.ProjectSshConfig]: KnexOriginal.CompositeTableType< + TProjectSshConfigs, + TProjectSshConfigsInsert, + TProjectSshConfigsUpdate + >; [TableName.ProjectMembership]: KnexOriginal.CompositeTableType< TProjectMemberships, TProjectMembershipsInsert, diff --git a/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts b/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts new file mode 100644 index 000000000..dc05eb9e5 --- /dev/null +++ b/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SshCertificateAuthority, "keySource"))) { + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.string("keySource"); + }); + + // Backfilling the keySource to internal + await knex(TableName.SshCertificateAuthority).update({ keySource: "internal" }); + + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.string("keySource").notNullable().alter(); + }); + } + + if (await knex.schema.hasColumn(TableName.SshCertificate, "sshCaId")) { + await knex.schema.alterTable(TableName.SshCertificate, (t) => { + t.uuid("sshCaId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SshCertificateAuthority, "keySource")) { + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.dropColumn("keySource"); + }); + } +} diff --git a/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts b/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts new file mode 100644 index 000000000..560fca9b1 --- /dev/null +++ b/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts @@ -0,0 +1,93 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SshHost))) { + await knex.schema.createTable(TableName.SshHost, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("hostname").notNullable(); + t.string("userCertTtl").notNullable(); + t.string("hostCertTtl").notNullable(); + t.uuid("userSshCaId").notNullable(); + t.foreign("userSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.uuid("hostSshCaId").notNullable(); + t.foreign("hostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.unique(["projectId", "hostname"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHost); + } + + if (!(await knex.schema.hasTable(TableName.SshHostLoginUser))) { + await knex.schema.createTable(TableName.SshHostLoginUser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostId").notNullable(); + t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("CASCADE"); + t.string("loginUser").notNullable(); // e.g. ubuntu, root, ec2-user, ... + t.unique(["sshHostId", "loginUser"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostLoginUser); + } + + if (!(await knex.schema.hasTable(TableName.SshHostLoginUserMapping))) { + await knex.schema.createTable(TableName.SshHostLoginUserMapping, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostLoginUserId").notNullable(); + t.foreign("sshHostLoginUserId").references("id").inTable(TableName.SshHostLoginUser).onDelete("CASCADE"); + t.uuid("userId").nullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.unique(["sshHostLoginUserId", "userId"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostLoginUserMapping); + } + + if (!(await knex.schema.hasTable(TableName.ProjectSshConfig))) { + // new table to store configuration for projects of type SSH (i.e. Infisical SSH) + await knex.schema.createTable(TableName.ProjectSshConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("defaultUserSshCaId"); + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.uuid("defaultHostSshCaId"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + }); + await createOnUpdateTrigger(knex, TableName.ProjectSshConfig); + } + + const hasColumn = await knex.schema.hasColumn(TableName.SshCertificate, "sshHostId"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.SshCertificate, (t) => { + t.uuid("sshHostId").nullable(); + t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectSshConfig); + await dropOnUpdateTrigger(knex, TableName.ProjectSshConfig); + + await knex.schema.dropTableIfExists(TableName.SshHostLoginUserMapping); + await dropOnUpdateTrigger(knex, TableName.SshHostLoginUserMapping); + + await knex.schema.dropTableIfExists(TableName.SshHostLoginUser); + await dropOnUpdateTrigger(knex, TableName.SshHostLoginUser); + + const hasColumn = await knex.schema.hasColumn(TableName.SshCertificate, "sshHostId"); + if (hasColumn) { + await knex.schema.alterTable(TableName.SshCertificate, (t) => { + t.dropColumn("sshHostId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.SshHost); + await dropOnUpdateTrigger(knex, TableName.SshHost); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5b78cf86f..8543417cf 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -75,6 +75,7 @@ export * from "./project-memberships"; export * from "./project-roles"; export * from "./project-slack-configs"; export * from "./project-split-backfill-ids"; +export * from "./project-ssh-configs"; export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; @@ -125,6 +126,9 @@ export * from "./ssh-certificate-authority-secrets"; export * from "./ssh-certificate-bodies"; export * from "./ssh-certificate-templates"; export * from "./ssh-certificates"; +export * from "./ssh-host-login-user-mappings"; +export * from "./ssh-host-login-users"; +export * from "./ssh-hosts"; export * from "./super-admin"; export * from "./totp-configs"; export * from "./trusted-ips"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e2a0f153f..95561c14a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,9 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshHost = "ssh_hosts", + SshHostLoginUser = "ssh_host_login_users", + SshHostLoginUserMapping = "ssh_host_login_user_mappings", SshCertificateAuthority = "ssh_certificate_authorities", SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", SshCertificateTemplate = "ssh_certificate_templates", @@ -38,6 +41,7 @@ export enum TableName { SuperAdmin = "super_admin", RateLimit = "rate_limit", ApiKey = "api_keys", + ProjectSshConfig = "project_ssh_configs", Project = "projects", ProjectBot = "project_bots", Environment = "project_environments", diff --git a/backend/src/db/schemas/project-ssh-configs.ts b/backend/src/db/schemas/project-ssh-configs.ts new file mode 100644 index 000000000..d0be89ee3 --- /dev/null +++ b/backend/src/db/schemas/project-ssh-configs.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectSshConfigsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + defaultUserSshCaId: z.string().uuid().nullable().optional(), + defaultHostSshCaId: z.string().uuid().nullable().optional() +}); + +export type TProjectSshConfigs = z.infer; +export type TProjectSshConfigsInsert = Omit, TImmutableDBKeys>; +export type TProjectSshConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts index 81e789288..75603406f 100644 --- a/backend/src/db/schemas/ssh-certificate-authorities.ts +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -14,7 +14,8 @@ export const SshCertificateAuthoritiesSchema = z.object({ projectId: z.string(), status: z.string(), friendlyName: z.string(), - keyAlgorithm: z.string() + keyAlgorithm: z.string(), + keySource: z.string() }); export type TSshCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts index 6fe5bc261..1bfd1fe6e 100644 --- a/backend/src/db/schemas/ssh-certificates.ts +++ b/backend/src/db/schemas/ssh-certificates.ts @@ -11,14 +11,15 @@ export const SshCertificatesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - sshCaId: z.string().uuid(), + sshCaId: z.string().uuid().nullable().optional(), sshCertificateTemplateId: z.string().uuid().nullable().optional(), serialNumber: z.string(), certType: z.string(), principals: z.string().array(), keyId: z.string(), notBefore: z.date(), - notAfter: z.date() + notAfter: z.date(), + sshHostId: z.string().uuid().nullable().optional() }); export type TSshCertificates = z.infer; diff --git a/backend/src/db/schemas/ssh-host-login-user-mappings.ts b/backend/src/db/schemas/ssh-host-login-user-mappings.ts new file mode 100644 index 000000000..6edb0d5a3 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-login-user-mappings.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshHostLoginUserMappingsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostLoginUserId: z.string().uuid(), + userId: z.string().uuid().nullable().optional() +}); + +export type TSshHostLoginUserMappings = z.infer; +export type TSshHostLoginUserMappingsInsert = Omit, TImmutableDBKeys>; +export type TSshHostLoginUserMappingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts new file mode 100644 index 000000000..62454d3c9 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-login-users.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshHostLoginUsersSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostId: z.string().uuid(), + loginUser: z.string() +}); + +export type TSshHostLoginUsers = z.infer; +export type TSshHostLoginUsersInsert = Omit, TImmutableDBKeys>; +export type TSshHostLoginUsersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-hosts.ts b/backend/src/db/schemas/ssh-hosts.ts new file mode 100644 index 000000000..7577e065b --- /dev/null +++ b/backend/src/db/schemas/ssh-hosts.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshHostsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + hostname: z.string(), + userCertTtl: z.string(), + hostCertTtl: z.string(), + userSshCaId: z.string().uuid(), + hostSshCaId: z.string().uuid() +}); + +export type TSshHosts = z.infer; +export type TSshHostsInsert = Omit, TImmutableDBKeys>; +export type TSshHostsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index e793c687d..2bf85e9c4 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -32,6 +32,7 @@ import { registerSnapshotRouter } from "./snapshot-router"; import { registerSshCaRouter } from "./ssh-certificate-authority-router"; import { registerSshCertRouter } from "./ssh-certificate-router"; import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; +import { registerSshHostRouter } from "./ssh-host-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -82,6 +83,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await sshRouter.register(registerSshCaRouter, { prefix: "/ca" }); await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" }); await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" }); }, { prefix: "/ssh" } ); diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index ab80888d7..783cb9b72 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -1,14 +1,15 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { normalizeSshPrivateKey } from "@app/ee/services/ssh/ssh-certificate-authority-fns"; import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; -import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCaKeySource, SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; export const registerSshCaRouter = async (server: FastifyZodProvider) => { server.route({ @@ -20,14 +21,34 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Create SSH CA", - body: z.object({ - projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), - friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), - keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) - .default(CertKeyAlgorithm.RSA_2048) - .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) - }), + body: z + .object({ + projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), + friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + keyAlgorithm: z + .nativeEnum(SshCertKeyAlgorithm) + .default(SshCertKeyAlgorithm.ED25519) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm), + publicKey: z.string().trim().optional().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.publicKey), + privateKey: z + .string() + .trim() + .optional() + .transform((val) => (val ? normalizeSshPrivateKey(val) : undefined)) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.privateKey), + keySource: z + .nativeEnum(SshCaKeySource) + .default(SshCaKeySource.INTERNAL) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keySource) + }) + .refine((data) => data.keySource === SshCaKeySource.INTERNAL || (!!data.publicKey && !!data.privateKey), { + message: "publicKey and privateKey are required when keySource is external", + path: ["publicKey"] + }) + .refine((data) => data.keySource === SshCaKeySource.EXTERNAL || !!data.keyAlgorithm, { + message: "keyAlgorithm is required when keySource is internal", + path: ["keyAlgorithm"] + }), response: { 200: z.object({ ca: sanitizedSshCa.extend({ diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts index 249a96b50..eb0fc158a 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerSshCertRouter = async (server: FastifyZodProvider) => { @@ -108,8 +108,8 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { .min(1) .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId), keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) - .default(CertKeyAlgorithm.RSA_2048) + .nativeEnum(SshCertKeyAlgorithm) + .default(SshCertKeyAlgorithm.ED25519) .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), certType: z .nativeEnum(SshCertType) @@ -133,7 +133,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey), publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey), keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) + .nativeEnum(SshCertKeyAlgorithm) .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) }) } diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index a85e6b0ca..a7dc55661 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -92,8 +92,8 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates), allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds) }) - .refine((data) => ms(data.maxTTL) > ms(data.ttl), { - message: "Max TLL must be greater than TTL", + .refine((data) => ms(data.maxTTL) >= ms(data.ttl), { + message: "Max TLL must be greater than or equal to TTL", path: ["maxTTL"] }), response: { diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts new file mode 100644 index 000000000..1dab5dd2f --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -0,0 +1,444 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; +import { SSH_HOSTS } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { publicSshCaLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerSshHostRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.array( + sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + ) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const hosts = await server.services.sshHost.listSshHosts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return hosts; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const host = await server.services.sshHost.getSshHost({ + sshHostId: req.params.sshHostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.GET_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname + } + } + }); + + return host; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Add an SSH Host", + body: z.object({ + projectId: z.string().describe(SSH_HOSTS.CREATE.projectId), + hostname: z + .string() + .min(1) + .refine((v) => isValidHostname(v), { + message: "Hostname must be a valid hostname" + }) + .describe(SSH_HOSTS.CREATE.hostname), + userCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("8h") + .describe(SSH_HOSTS.CREATE.userCertTtl), + hostCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("1y") + .describe(SSH_HOSTS.CREATE.hostCertTtl), + loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOSTS.CREATE.loginMappings), + userSshCaId: z.string().describe(SSH_HOSTS.CREATE.userSshCaId).optional(), + hostSshCaId: z.string().describe(SSH_HOSTS.CREATE.hostSshCaId).optional() + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const host = await server.services.sshHost.createSshHost({ + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.CREATE_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname, + userCertTtl: host.userCertTtl, + hostCertTtl: host.hostCertTtl, + loginMappings: host.loginMappings, + userSshCaId: host.userSshCaId, + hostSshCaId: host.hostSshCaId + } + } + }); + + return host; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshHostId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH Host", + params: z.object({ + sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId) + }), + body: z.object({ + hostname: z + .string() + .min(1) + .refine((v) => isValidHostname(v), { + message: "Hostname must be a valid hostname" + }) + .optional() + .describe(SSH_HOSTS.UPDATE.hostname), + userCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_HOSTS.UPDATE.userCertTtl), + hostCertTtl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_HOSTS.UPDATE.hostCertTtl), + loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOSTS.UPDATE.loginMappings) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + handler: async (req) => { + const host = await server.services.sshHost.updateSshHost({ + sshHostId: req.params.sshHostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.UPDATE_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname, + userCertTtl: host.userCertTtl, + hostCertTtl: host.hostCertTtl, + loginMappings: host.loginMappings, + userSshCaId: host.userSshCaId, + hostSshCaId: host.hostSshCaId + } + } + }); + + return host; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const host = await server.services.sshHost.deleteSshHost({ + sshHostId: req.params.sshHostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: host.projectId, + event: { + type: EventType.DELETE_SSH_HOST, + metadata: { + sshHostId: host.id, + hostname: host.hostname + } + } + }); + + return host; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostId/issue-user-cert", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + description: "Issue SSH certificate for user", + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId) + }), + body: z.object({ + loginUser: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.loginUser) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.signedKey), + privateKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.privateKey), + publicKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.publicKey), + keyAlgorithm: z.nativeEnum(SshCertKeyAlgorithm).describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.keyAlgorithm) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } = + await server.services.sshHost.issueSshHostUserCert({ + sshHostId: req.params.sshHostId, + loginUser: req.body.loginUser, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_HOST_USER_CERT, + metadata: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + loginUser: req.body.loginUser, + principals, + ttl: host.userCertTtl + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueSshHostUserCert, + distinctId: getTelemetryDistinctId(req), + properties: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + principals, + ...req.auditLogInfo + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm + }; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostId/issue-host-cert", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue SSH certificate for host", + params: z.object({ + sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId) + }), + body: z.object({ + publicKey: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.publicKey) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.serialNumber), + signedKey: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.signedKey) + }) + } + }, + handler: async (req) => { + const { host, principals, serialNumber, signedPublicKey } = await server.services.sshHost.issueSshHostHostCert({ + sshHostId: req.params.sshHostId, + publicKey: req.body.publicKey, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_HOST_HOST_CERT, + metadata: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + principals, + serialNumber, + ttl: host.hostCertTtl + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueSshHostHostCert, + distinctId: getTelemetryDistinctId(req), + properties: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + principals, + ...req.auditLogInfo + } + }); + + return { + serialNumber, + signedKey: signedPublicKey + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostId/user-ca-public-key", + config: { + rateLimit: publicSshCaLimit + }, + schema: { + description: "Get public key of the user SSH CA linked to the host", + params: z.object({ + sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId) + }), + response: { + 200: z.string().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.publicKey) + } + }, + handler: async (req) => { + const publicKey = await server.services.sshHost.getSshHostUserCaPk(req.params.sshHostId); + return publicKey; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostId/host-ca-public-key", + config: { + rateLimit: publicSshCaLimit + }, + schema: { + description: "Get public key of the host SSH CA linked to the host", + params: z.object({ + sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId) + }), + response: { + 200: z.string().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.publicKey) + } + }, + handler: async (req) => { + const publicKey = await server.services.sshHost.getSshHostHostCaPk(req.params.sshHostId); + return publicKey; + } + }); +}; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 2ab46b6ad..996a90555 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -10,6 +10,7 @@ import { TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; @@ -189,6 +190,12 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + CREATE_SSH_HOST = "create-ssh-host", + UPDATE_SSH_HOST = "update-ssh-host", + DELETE_SSH_HOST = "delete-ssh-host", + GET_SSH_HOST = "get-ssh-host", + ISSUE_SSH_HOST_USER_CERT = "issue-ssh-host-user-cert", + ISSUE_SSH_HOST_HOST_CERT = "issue-ssh-host-host-cert", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -1377,7 +1384,7 @@ interface IssueSshCreds { type: EventType.ISSUE_SSH_CREDS; metadata: { certificateTemplateId: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; certType: SshCertType; principals: string[]; ttl: string; @@ -1473,6 +1480,80 @@ interface DeleteSshCertificateTemplate { }; } +interface CreateSshHost { + type: EventType.CREATE_SSH_HOST; + metadata: { + sshHostId: string; + hostname: string; + userCertTtl: string; + hostCertTtl: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; + userSshCaId: string; + hostSshCaId: string; + }; +} + +interface UpdateSshHost { + type: EventType.UPDATE_SSH_HOST; + metadata: { + sshHostId: string; + hostname?: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; + userSshCaId?: string; + hostSshCaId?: string; + }; +} + +interface DeleteSshHost { + type: EventType.DELETE_SSH_HOST; + metadata: { + sshHostId: string; + hostname: string; + }; +} + +interface GetSshHost { + type: EventType.GET_SSH_HOST; + metadata: { + sshHostId: string; + hostname: string; + }; +} + +interface IssueSshHostUserCert { + type: EventType.ISSUE_SSH_HOST_USER_CERT; + metadata: { + sshHostId: string; + hostname: string; + loginUser: string; + principals: string[]; + ttl: string; + }; +} + +interface IssueSshHostHostCert { + type: EventType.ISSUE_SSH_HOST_HOST_CERT; + metadata: { + sshHostId: string; + hostname: string; + serialNumber: string; + principals: string[]; + ttl: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -2493,6 +2574,12 @@ export type Event = | UpdateSshCertificateTemplate | GetSshCertificateTemplate | DeleteSshCertificateTemplate + | CreateSshHost + | UpdateSshHost + | DeleteSshHost + | GetSshHost + | IssueSshHostUserCert + | IssueSshHostHostCert | CreateCa | GetCa | UpdateCa diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 4d3fff12a..153401900 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -67,6 +67,14 @@ export enum ProjectPermissionGroupActions { GrantPrivileges = "grant-privileges" } +export enum ProjectPermissionSshHostActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueHostCert = "issue-host-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -121,6 +129,7 @@ export enum ProjectPermissionSub { SshCertificateAuthorities = "ssh-certificate-authorities", SshCertificates = "ssh-certificates", SshCertificateTemplates = "ssh-certificate-templates", + SshHosts = "ssh-hosts", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -160,6 +169,10 @@ export type IdentityManagementSubjectFields = { identityId: string; }; +export type SshHostSubjectFields = { + hostname: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -215,6 +228,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] + | [ + ProjectPermissionSshHostActions, + ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields) + ] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] @@ -313,6 +330,21 @@ const IdentityManagementConditionSchema = z }) .partial(); +const SshHostConditionSchema = z + .object({ + hostname: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -561,6 +593,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshHosts).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSshHostActions).describe( + "Describe what action an entity can take." + ), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + conditions: SshHostConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), @@ -613,6 +655,17 @@ const buildAdminPermissionRules = () => { ); }); + can( + [ + ProjectPermissionSshHostActions.Edit, + ProjectPermissionSshHostActions.Read, + ProjectPermissionSshHostActions.Create, + ProjectPermissionSshHostActions.Delete, + ProjectPermissionSshHostActions.IssueHostCert + ], + ProjectPermissionSub.SshHosts + ); + can( [ ProjectPermissionMemberActions.Create, @@ -873,6 +926,8 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); + can([ProjectPermissionSshHostActions.Read], ProjectPermissionSub.SshHosts); + can( [ ProjectPermissionCmekActions.Create, diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-types.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-types.ts new file mode 100644 index 000000000..14e2755ee --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-types.ts @@ -0,0 +1,7 @@ +export enum SshCertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1", + ED25519 = "ED25519" +} diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts new file mode 100644 index 000000000..4baeca503 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -0,0 +1,193 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostDALFactory = ReturnType; + +export const sshHostDALFactory = (db: TDbClient) => { + const sshHostOrm = ormify(db, TableName.SshHost); + + const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => { + try { + const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first(); + + if (!user) { + throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") }); + } + + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) + .whereIn(`${TableName.SshHost}.projectId`, projectIds) + .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ) + .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + + const grouped = groupBy(rows, (r) => r.sshHostId); + return Object.values(grouped).map((hostRows) => { + const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; + + const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ + loginUser, + allowedPrincipals: { + usernames: [user.username] + } + })); + + return { + id: sshHostId, + hostname, + projectId, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId, + hostSshCaId + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostsWithPrincipalsAcrossProjects` }); + } + }; + + const findSshHostsWithLoginMappings = async (projectId: string, tx?: Knex) => { + try { + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHost}.projectId`, projectId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ) + .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + + const hostsGrouped = groupBy(rows, (r) => r.sshHostId); + return Object.values(hostsGrouped).map((hostRows) => { + const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; + + const loginMappingGrouped = groupBy( + hostRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + + return { + id: sshHostId, + hostname, + projectId, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId, + hostSshCaId + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostsWithLoginMappings` }); + } + }; + + const findSshHostByIdWithLoginMappings = async (sshHostId: string, tx?: Knex) => { + try { + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHost}.id`, sshHostId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ); + + if (rows.length === 0) return null; + + const { sshHostId: id, projectId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; + + const loginMappingGrouped = groupBy( + rows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + + return { + id, + projectId, + hostname, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId, + hostSshCaId + }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostByIdWithLoginMappings` }); + } + }; + + return { + ...sshHostOrm, + findSshHostsWithLoginMappings, + findUserAccessibleSshHosts, + findSshHostByIdWithLoginMappings + }; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts new file mode 100644 index 000000000..0d9e8013b --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostLoginUserMappingDALFactory = ReturnType; + +export const sshHostLoginUserMappingDALFactory = (db: TDbClient) => { + const sshHostLoginUserMappingOrm = ormify(db, TableName.SshHostLoginUserMapping); + return sshHostLoginUserMappingOrm; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-schema.ts b/backend/src/ee/services/ssh-host/ssh-host-schema.ts new file mode 100644 index 000000000..4eeb90881 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { SshHostsSchema } from "@app/db/schemas"; + +export const sanitizedSshHost = SshHostsSchema.pick({ + id: true, + projectId: true, + hostname: true, + userCertTtl: true, + hostCertTtl: true, + userSshCaId: true, + hostSshCaId: true +}); + +export const loginMappingSchema = z.object({ + loginUser: z.string().trim(), + allowedPrincipals: z.object({ + usernames: z.array(z.string().trim()).transform((usernames) => Array.from(new Set(usernames))) + }) +}); diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts new file mode 100644 index 000000000..69807431a --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -0,0 +1,694 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; +import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { + convertActorToPrincipals, + createSshCert, + createSshKeyPair, + getSshPublicKey +} from "../ssh/ssh-certificate-authority-fns"; +import { SshCertType } from "../ssh/ssh-certificate-authority-types"; +import { + TCreateSshHostDTO, + TDeleteSshHostDTO, + TGetSshHostDTO, + TIssueSshHostHostCertDTO, + TIssueSshHostUserCertDTO, + TListSshHostsDTO, + TUpdateSshHostDTO +} from "./ssh-host-types"; + +type TSshHostServiceFactoryDep = { + userDAL: Pick; + projectDAL: Pick; + projectSshConfigDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateBodyDAL: Pick; + sshHostDAL: Pick< + TSshHostDALFactory, + | "transaction" + | "create" + | "findById" + | "updateById" + | "deleteById" + | "findOne" + | "findSshHostByIdWithLoginMappings" + | "findUserAccessibleSshHosts" + >; + sshHostLoginUserDAL: TSshHostLoginUserDALFactory; + sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; + permissionService: Pick; + kmsService: Pick; +}; + +export type TSshHostServiceFactory = ReturnType; + +export const sshHostServiceFactory = ({ + userDAL, + projectDAL, + projectSshConfigDAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + sshHostDAL, + sshHostLoginUserMappingDAL, + sshHostLoginUserDAL, + permissionService, + kmsService +}: TSshHostServiceFactoryDep) => { + /** + * Return list of all SSH hosts that a user can issue user SSH certificates for + * (i.e. is able to access / connect to) across all SSH projects in the organization + */ + const listSshHosts = async ({ actorId, actorAuthMethod, actor, actorOrgId }: TListSshHostsDTO) => { + if (actor !== ActorType.USER) { + // (dangtony98): only support user for now + throw new BadRequestError({ message: `Actor type ${actor} not supported` }); + } + + const sshProjects = await projectDAL.find({ + orgId: actorOrgId, + type: ProjectType.SSH + }); + + const allowedHosts = []; + + for await (const project of sshProjects) { + try { + await permissionService.getProjectPermission({ + actor, + actorId, + projectId: project.id, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + const projectHosts = await sshHostDAL.findUserAccessibleSshHosts([project.id], actorId); + + allowedHosts.push(...projectHosts); + } catch { + // intentionally ignore projects where user lacks access + } + } + + return allowedHosts; + }; + + const createSshHost = async ({ + projectId, + hostname, + userCertTtl, + hostCertTtl, + loginMappings, + userSshCaId: requestedUserSshCaId, + hostSshCaId: requestedHostSshCaId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshHostDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Create, + subject(ProjectPermissionSub.SshHosts, { + hostname + }) + ); + + const resolveSshCaId = async ({ + requestedId, + fallbackId, + label + }: { + requestedId?: string; + fallbackId?: string | null; + label: "User" | "Host"; + }) => { + const finalId = requestedId ?? fallbackId; + if (!finalId) { + throw new BadRequestError({ message: `Missing ${label.toLowerCase()} SSH CA` }); + } + + const ca = await sshCertificateAuthorityDAL.findOne({ + id: finalId, + projectId + }); + + if (!ca) { + throw new BadRequestError({ + message: `${label} SSH CA with ID '${finalId}' not found in project '${projectId}'` + }); + } + + return ca.id; + }; + + const projectSshConfig = await projectSshConfigDAL.findOne({ projectId }); + + const userSshCaId = await resolveSshCaId({ + requestedId: requestedUserSshCaId, + fallbackId: projectSshConfig?.defaultUserSshCaId, + label: "User" + }); + + const hostSshCaId = await resolveSshCaId({ + requestedId: requestedHostSshCaId, + fallbackId: projectSshConfig?.defaultHostSshCaId, + label: "Host" + }); + + const newSshHost = await sshHostDAL.transaction(async (tx) => { + const host = await sshHostDAL.create( + { + projectId, + hostname, + userCertTtl, + hostCertTtl, + userSshCaId, + hostSshCaId + }, + tx + ); + + // (dangtony98): room to optimize + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + { + sshHostId: host.id, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length > 0) { + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + const foundUsernames = new Set(users.map((u) => u.username)); + + for (const uname of allowedPrincipals.usernames) { + if (!foundUsernames.has(uname)) { + throw new BadRequestError({ + message: `Invalid username: ${uname}` + }); + } + } + + for await (const user of users) { + // check that each user has access to the SSH project + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } + } + + const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); + if (!newSshHostWithLoginMappings) { + throw new NotFoundError({ message: `SSH host with ID '${host.id}' not found` }); + } + + return newSshHostWithLoginMappings; + }); + + return newSshHost; + }; + + const updateSshHost = async ({ + sshHostId, + hostname, + userCertTtl, + hostCertTtl, + loginMappings, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSshHostDTO) => { + const host = await sshHostDAL.findById(sshHostId); + if (!host) throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Edit, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + const updatedHost = await sshHostDAL.transaction(async (tx) => { + await sshHostDAL.updateById( + sshHostId, + { + hostname, + userCertTtl, + hostCertTtl + }, + tx + ); + + if (loginMappings) { + await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx); + if (loginMappings.length) { + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + { + sshHostId: host.id, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length > 0) { + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + const foundUsernames = new Set(users.map((u) => u.username)); + + for (const uname of allowedPrincipals.usernames) { + if (!foundUsernames.has(uname)) { + throw new BadRequestError({ + message: `Invalid username: ${uname}` + }); + } + } + + for await (const user of users) { + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId: host.projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } + } + } + } + + const updatedHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId, tx); + if (!updatedHostWithLoginMappings) { + throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` }); + } + + return updatedHostWithLoginMappings; + }); + + return updatedHost; + }; + + const deleteSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteSshHostDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Delete, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + await sshHostDAL.deleteById(sshHostId); + + return host; + }; + + const getSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Read, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + return host; + }; + + /** + * Return SSH certificate and corresponding new SSH public-private key pair where + * SSH public key is signed using CA behind SSH certificate with name [templateName]. + * + * Note: Used for issuing SSH credentials as part of request against a specific SSH Host. + */ + const issueSshHostUserCert = async ({ + sshHostId, + loginUser, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshHostUserCertDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + const internalPrincipals = await convertActorToPrincipals({ + actor, + actorId, + userDAL + }); + + const mapping = host.loginMappings.find( + (m) => + m.loginUser === loginUser && + m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) + ); + + if (!mapping) { + throw new UnauthorizedError({ + message: `You are not allowed to login as ${loginUser} on this host` + }); + } + + const keyId = `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.userSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + // (dangtony98): will support more algorithms in the future + const keyAlgorithm = SshCertKeyAlgorithm.ED25519; + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + // (dangtony98): include the loginUser as a principal on the issued certificate + const principals = [...internalPrincipals, loginUser]; + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl: host.userCertTtl, + certType: SshCertType.USER + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: host.userSshCaId, + sshHostId: host.id, + serialNumber, + certType: SshCertType.USER, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { + host, + principals, + serialNumber, + signedPublicKey, + privateKey, + publicKey, + ttl, + keyAlgorithm + }; + }; + + const issueSshHostHostCert = async ({ + sshHostId, + publicKey, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshHostHostCertDTO) => { + const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.IssueHostCert, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.hostSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const principals = [host.hostname]; + const keyId = `host-${host.id}`; + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl: host.hostCertTtl, + certType: SshCertType.HOST + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: host.hostSshCaId, + sshHostId: host.id, + serialNumber, + certType: SshCertType.HOST, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { host, principals, serialNumber, signedPublicKey }; + }; + + const getSshHostUserCaPk = async (sshHostId: string) => { + const host = await sshHostDAL.findById(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.userSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + const getSshHostHostCaPk = async (sshHostId: string) => { + const host = await sshHostDAL.findById(sshHostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${sshHostId} not found` + }); + } + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.hostSshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: host.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + return { + listSshHosts, + createSshHost, + updateSshHost, + deleteSshHost, + getSshHost, + issueSshHostUserCert, + issueSshHostHostCert, + getSshHostUserCaPk, + getSshHostHostCaPk + }; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts new file mode 100644 index 000000000..0c7cb25e1 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -0,0 +1,48 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TListSshHostsDTO = Omit; + +export type TCreateSshHostDTO = { + hostname: string; + userCertTtl: string; + hostCertTtl: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; + userSshCaId?: string; + hostSshCaId?: string; +} & TProjectPermission; + +export type TUpdateSshHostDTO = { + sshHostId: string; + hostname?: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +} & Omit; + +export type TGetSshHostDTO = { + sshHostId: string; +} & Omit; + +export type TDeleteSshHostDTO = { + sshHostId: string; +} & Omit; + +export type TIssueSshHostUserCertDTO = { + sshHostId: string; + loginUser: string; +} & Omit; + +export type TIssueSshHostHostCertDTO = { + sshHostId: string; + publicKey: string; +} & Omit; diff --git a/backend/src/ee/services/ssh-host/ssh-host-validators.ts b/backend/src/ee/services/ssh-host/ssh-host-validators.ts new file mode 100644 index 000000000..7b739b9cb --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-validators.ts @@ -0,0 +1,15 @@ +import { isFQDN } from "@app/lib/validator/validate-url"; + +export const isValidHostname = (value: string): boolean => { + if (typeof value !== "string") return false; + if (value.length > 255) return false; + + // Only allow strict FQDNs, no wildcards or IPs + return isFQDN(value, { + require_tld: true, + allow_underscores: false, + allow_trailing_dot: false, + allow_numeric_tld: true, + allow_wildcard: false + }); +}; diff --git a/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts b/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts new file mode 100644 index 000000000..88a9bf59a --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostLoginUserDALFactory = ReturnType; + +export const sshHostLoginUserDALFactory = (db: TDbClient) => { + const sshHostLoginUserOrm = ormify(db, TableName.SshHostLoginUser); + return sshHostLoginUserOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index deb77cecc..92f946747 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -1,21 +1,31 @@ import { execFile } from "child_process"; import crypto from "crypto"; import { promises as fs } from "fs"; +import { Knex } from "knex"; import os from "os"; import path from "path"; import { promisify } from "util"; import { TSshCertificateTemplates } from "@app/db/schemas"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { KmsDataKey } from "@app/services/kms/kms-types"; import { isValidHostPattern, isValidUserPattern } from "../ssh-certificate-template/ssh-certificate-template-validators"; -import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; +import { + SshCaKeySource, + SshCaStatus, + SshCertType, + TConvertActorToPrincipalsDTO, + TCreateSshCaHelperDTO, + TCreateSshCertDTO +} from "./ssh-certificate-authority-types"; const execFileAsync = promisify(execFile); @@ -31,31 +41,35 @@ export const createSshCertSerialNumber = () => { * Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm]. * We use this function because the key format generated by `ssh-keygen` is unique. */ -export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { +export const createSshKeyPair = async (keyAlgorithm: SshCertKeyAlgorithm) => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); const privateKeyFile = path.join(tempDir, "id_key"); const publicKeyFile = `${privateKeyFile}.pub`; let keyType: string; - let keyBits: string; + let keyBits: string | null; switch (keyAlgorithm) { - case CertKeyAlgorithm.RSA_2048: + case SshCertKeyAlgorithm.RSA_2048: keyType = "rsa"; keyBits = "2048"; break; - case CertKeyAlgorithm.RSA_4096: + case SshCertKeyAlgorithm.RSA_4096: keyType = "rsa"; keyBits = "4096"; break; - case CertKeyAlgorithm.ECDSA_P256: + case SshCertKeyAlgorithm.ECDSA_P256: keyType = "ecdsa"; keyBits = "256"; break; - case CertKeyAlgorithm.ECDSA_P384: + case SshCertKeyAlgorithm.ECDSA_P384: keyType = "ecdsa"; keyBits = "384"; break; + case SshCertKeyAlgorithm.ED25519: + keyType = "ed25519"; + keyBits = null; + break; default: throw new BadRequestError({ message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm" @@ -63,10 +77,16 @@ export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { } try { + const args = ["-t", keyType]; + if (keyBits !== null) { + args.push("-b", keyBits); + } + args.push("-f", privateKeyFile, "-N", ""); + // Generate the SSH key pair // The "-N ''" sets an empty passphrase // The keys are created in the temporary directory - await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""], { + await execFileAsync("ssh-keygen", args, { timeout: EXEC_TIMEOUT_MS }); @@ -280,7 +300,12 @@ export const validateSshCertificateTtl = (template: TSshCertificateTemplates, tt * that it only contains alphanumeric characters with no spaces. */ export const validateSshCertificateKeyId = (keyId: string) => { - const regex = characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen]); + const regex = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Colon, + CharacterType.Period + ]); if (!regex(keyId)) { throw new BadRequestError({ message: @@ -322,6 +347,96 @@ const validateSshPublicKey = async (publicKey: string) => { } }; +export const getKeyAlgorithmFromFingerprintOutput = (output: string): SshCertKeyAlgorithm | undefined => { + const parts = output.trim().split(" "); + const bitsInt = parseInt(parts[0], 10); + const keyTypeRaw = parts.at(-1)?.replace(/[()]/g, ""); // remove surrounding parentheses + + if (keyTypeRaw === "RSA") { + return bitsInt === 2048 ? SshCertKeyAlgorithm.RSA_2048 : SshCertKeyAlgorithm.RSA_4096; + } + + if (keyTypeRaw === "ECDSA") { + return bitsInt === 256 ? SshCertKeyAlgorithm.ECDSA_P256 : SshCertKeyAlgorithm.ECDSA_P384; + } + + if (keyTypeRaw === "ED25519") { + return SshCertKeyAlgorithm.ED25519; + } + + return undefined; +}; + +export const normalizeSshPrivateKey = (raw: string): string => { + return `${raw + .replace(/\r\n/g, "\n") // Windows CRLF → LF + .replace(/\r/g, "\n") // Old Mac CR → LF + .replace(/\\n/g, "\n") // Double-escaped \n + .trim()}\n`; +}; + +/** + * Validate the format of the SSH private key + * + * Returns the SSH public key corresponding to the private key + * and the key algorithm categorization. + */ +export const validateSshPrivateKey = async (privateKey: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-privkey-")); + const privateKeyFile = path.join(tempDir, "id_key"); + + try { + await fs.writeFile(privateKeyFile, privateKey, { + encoding: "utf8", + mode: 0o600 + }); + + // This will fail if the private key is malformed or unreadable + const { stdout: publicKey } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { + timeout: EXEC_TIMEOUT_MS + }); + + const { stdout: fingerprint } = await execFileAsync("ssh-keygen", ["-lf", privateKeyFile]); + const keyAlgorithm = getKeyAlgorithmFromFingerprintOutput(fingerprint); + + if (!keyAlgorithm) { + throw new BadRequestError({ + message: "Failed to validate SSH private key format: The key algorithm is not supported." + }); + } + + return { + publicKey, + keyAlgorithm + }; + } catch (err) { + throw new BadRequestError({ + message: "Failed to validate SSH private key format: could not be parsed." + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Validate that the provided public and private keys are valid and constitute + * a matching SSH key pair. + */ +export const validateExternalSshCaKeyPair = async (publicKey: string, privateKey: string) => { + await validateSshPublicKey(publicKey); + + const { publicKey: derivedPublicKey, keyAlgorithm } = await validateSshPrivateKey(privateKey); + + if (publicKey.trim() !== derivedPublicKey.trim()) { + throw new BadRequestError({ + message: + "Failed to validate matching SSH key pair: The provided public key does not match the public key derived from the private key." + }); + } + + return keyAlgorithm; +}; + /** * Create an SSH certificate for a user or host. */ @@ -331,17 +446,32 @@ export const createSshCert = async ({ clientPublicKey, keyId, principals, - requestedTtl, + requestedTtl, // in ms lib format certType }: TCreateSshCertDTO) => { - // validate if the requested [certType] is allowed under the template configuration - validateSshCertificateType(template, certType); + let ttl: number | undefined; - // validate if the requested [principals] are valid for the given [certType] under the template configuration - validateSshCertificatePrincipals(certType, template, principals); + if (!template && requestedTtl) { + const parsedTtl = Math.ceil(ms(requestedTtl) / 1000); + if (parsedTtl > 0) ttl = parsedTtl; + } - // validate if the requested TTL is valid under the template configuration - const ttl = validateSshCertificateTtl(template, requestedTtl); + if (template) { + // validate if the requested [certType] is allowed under the template configuration + validateSshCertificateType(template, certType); + + // validate if the requested [principals] are valid for the given [certType] under the template configuration + validateSshCertificatePrincipals(certType, template, principals); + + // validate if the requested TTL is valid under the template configuration + ttl = validateSshCertificateTtl(template, requestedTtl); + } + + if (!ttl) { + throw new BadRequestError({ + message: "Failed to create SSH certificate due to missing TTL" + }); + } validateSshCertificateKeyId(keyId); await validateSshPublicKey(clientPublicKey); @@ -388,3 +518,88 @@ export const createSshCert = async ({ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); } }; + +export const createSshCaHelper = async ({ + projectId, + friendlyName, + keyAlgorithm: requestedKeyAlgorithm, + keySource, + externalPk, + externalSk, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + tx: outerTx +}: TCreateSshCaHelperDTO) => { + // Function to handle the actual creation logic + const processCreation = async (tx: Knex) => { + let publicKey: string; + let privateKey: string; + let keyAlgorithm: SshCertKeyAlgorithm = requestedKeyAlgorithm; + if (keySource === SshCaKeySource.INTERNAL) { + // generate SSH CA key pair internally + ({ publicKey, privateKey } = await createSshKeyPair(requestedKeyAlgorithm)); + } else { + // use external SSH CA key pair + if (!externalPk || !externalSk) { + throw new BadRequestError({ + message: "Public and private keys are required when key source is external" + }); + } + publicKey = externalPk; + privateKey = externalSk; + keyAlgorithm = await validateExternalSshCaKeyPair(publicKey, privateKey); + } + const ca = await sshCertificateAuthorityDAL.create( + { + projectId, + friendlyName, + status: SshCaStatus.ACTIVE, + keyAlgorithm, + keySource + }, + tx + ); + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.SecretManager, + projectId + }, + tx + ); + await sshCertificateAuthoritySecretDAL.create( + { + sshCaId: ca.id, + encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob + }, + tx + ); + return { ...ca, publicKey }; + }; + + if (outerTx) { + return processCreation(outerTx); + } + + return sshCertificateAuthorityDAL.transaction(processCreation); +}; + +/** + * Convert an actor to a list of principals to be included in an SSH certificate. + * + * (dangtony98): This function is only supported for user actors at the moment and returns + * only the email of the associated user. In the future, we will consider other + * actor types and attributes such as group membership slugs and/or metadata to be + * included in the list of principals. + */ +export const convertActorToPrincipals = async ({ userDAL, actor, actorId }: TConvertActorToPrincipalsDTO) => { + if (actor !== ActorType.USER) { + throw new BadRequestError({ + message: "Failed to convert actor to principals due to unsupported actor type" + }); + } + + const user = await userDAL.findById(actorId); + + return [user.username]; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts index 9ff76efbc..af66e83ca 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -5,5 +5,6 @@ export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ projectId: true, friendlyName: true, status: true, - keyAlgorithm: true + keyAlgorithm: true, + keySource: true }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index d7ca511e4..312b7966b 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -13,7 +13,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; -import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; +import { createSshCaHelper, createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; import { SshCaStatus, TCreateSshCaDTO, @@ -59,7 +59,10 @@ export const sshCertificateAuthorityServiceFactory = ({ const createSshCa = async ({ projectId, friendlyName, - keyAlgorithm, + keyAlgorithm: requestedKeyAlgorithm, + publicKey: externalPk, + privateKey: externalSk, + keySource, actorId, actorAuthMethod, actor, @@ -79,33 +82,16 @@ export const sshCertificateAuthorityServiceFactory = ({ ProjectPermissionSub.SshCertificateAuthorities ); - const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { - const ca = await sshCertificateAuthorityDAL.create( - { - projectId, - friendlyName, - status: SshCaStatus.ACTIVE, - keyAlgorithm - }, - tx - ); - - const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); - - const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - - await sshCertificateAuthoritySecretDAL.create( - { - sshCaId: ca.id, - encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob - }, - tx - ); - - return { ...ca, publicKey }; + const newCa = await createSshCaHelper({ + projectId, + friendlyName, + keyAlgorithm: requestedKeyAlgorithm, + keySource, + externalPk, + externalSk, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService }); return newCa; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts index 3f202ebf0..d433bd5ad 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -1,12 +1,24 @@ +import { Knex } from "knex"; + import { TSshCertificateTemplates } from "@app/db/schemas"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { TProjectPermission } from "@app/lib/types"; -import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; export enum SshCaStatus { ACTIVE = "active", DISABLED = "disabled" } +export enum SshCaKeySource { + INTERNAL = "internal", + EXTERNAL = "external" +} + export enum SshCertType { USER = "user", HOST = "host" @@ -14,9 +26,25 @@ export enum SshCertType { export type TCreateSshCaDTO = { friendlyName: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; + publicKey?: string; + privateKey?: string; + keySource: SshCaKeySource; } & TProjectPermission; +export type TCreateSshCaHelperDTO = { + projectId: string; + friendlyName: string; + keyAlgorithm: SshCertKeyAlgorithm; + keySource: SshCaKeySource; + externalPk?: string; + externalSk?: string; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; + kmsService: Pick; + tx?: Knex; +}; + export type TGetSshCaDTO = { caId: string; } & Omit; @@ -37,7 +65,7 @@ export type TDeleteSshCaDTO = { export type TIssueSshCredsDTO = { certificateTemplateId: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; certType: SshCertType; principals: string[]; ttl?: string; @@ -58,7 +86,7 @@ export type TGetSshCaCertificateTemplatesDTO = { } & Omit; export type TCreateSshCertDTO = { - template: TSshCertificateTemplates; + template?: TSshCertificateTemplates; caPrivateKey: string; clientPublicKey: string; keyId: string; @@ -66,3 +94,9 @@ export type TCreateSshCertDTO = { requestedTtl?: string; certType: SshCertType; }; + +export type TConvertActorToPrincipalsDTO = { + actor: ActorType; + actorId: string; + userDAL: Pick; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1f11e3077..066314228 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -519,6 +519,9 @@ export const PROJECTS = { LIST_SSH_CAS: { projectId: "The ID of the project to list SSH CAs for." }, + LIST_SSH_HOSTS: { + projectId: "The ID of the project to list SSH hosts for." + }, LIST_SSH_CERTIFICATES: { projectId: "The ID of the project to list SSH certificates for.", offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", @@ -1253,7 +1256,11 @@ export const SSH_CERTIFICATE_AUTHORITIES = { CREATE: { projectId: "The ID of the project to create the SSH CA in.", friendlyName: "A friendly name for the SSH CA.", - keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA." + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the SSH CA; required if keySource is internal.", + publicKey: "The public key for the SSH CA key pair; required if keySource is external.", + privateKey: "The private key for the SSH CA key pair; required if keySource is external.", + keySource: "The source of the SSH CA key pair. This can be one of internal or external." }, GET: { sshCaId: "The ID of the SSH CA to get." @@ -1327,6 +1334,62 @@ export const SSH_CERTIFICATE_TEMPLATES = { } }; +export const SSH_HOSTS = { + GET: { + sshHostId: "The ID of the SSH host to get." + }, + CREATE: { + projectId: "The ID of the project to create the SSH host in.", + hostname: "The hostname of the SSH host.", + userCertTtl: "The time to live for user certificates issued under this host.", + hostCertTtl: "The time to live for host certificates issued under this host.", + loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", + allowedPrincipals: "A list of allowed principals that can log in as the login user.", + loginMappings: + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project.", + userSshCaId: + "The ID of the SSH CA to use for user certificates. If not specified, the default user SSH CA will be used if it exists.", + hostSshCaId: + "The ID of the SSH CA to use for host certificates. If not specified, the default host SSH CA will be used if it exists." + }, + UPDATE: { + sshHostId: "The ID of the SSH host to update.", + hostname: "The hostname of the SSH host to update to.", + userCertTtl: "The time to live for user certificates issued under this host to update to.", + hostCertTtl: "The time to live for host certificates issued under this host to update to.", + loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", + allowedPrincipals: "A list of allowed principals that can log in as the login user.", + loginMappings: + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + }, + DELETE: { + sshHostId: "The ID of the SSH host to delete." + }, + ISSUE_SSH_CREDENTIALS: { + sshHostId: "The ID of the SSH host to issue the SSH credentials for.", + loginUser: "The login user to issue the SSH credentials for.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH host.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key.", + privateKey: "The private key corresponding to the issued SSH certificate.", + publicKey: "The public key of the issued SSH certificate." + }, + ISSUE_HOST_CERT: { + sshHostId: "The ID of the SSH host to issue the SSH certificate for.", + publicKey: "The SSH public key to issue the SSH certificate for.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + GET_USER_CA_PUBLIC_KEY: { + sshHostId: "The ID of the SSH host to get the user SSH CA public key for.", + publicKey: "The public key of the user SSH CA linked to the SSH host." + }, + GET_HOST_CA_PUBLIC_KEY: { + sshHostId: "The ID of the SSH host to get the host SSH CA public key for.", + publicKey: "The public key of the host SSH CA linked to the SSH host." + } +}; + export const CERTIFICATE_AUTHORITIES = { CREATE: { projectSlug: "Slug of the project to create the CA in.", diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 176d44183..681442d1b 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -93,3 +93,10 @@ export const userEngagementLimit: RateLimitOptions = { max: 5, keyGenerator: (req) => req.realIp }; + +export const publicSshCaLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + hook: "preValidation", + max: 30, // conservative default + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 21988e12d..595a9626c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -96,6 +96,10 @@ import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/s import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; +import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; +import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -184,6 +188,7 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; +import { projectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { projectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -292,6 +297,7 @@ export const registerRoutes = async ( const apiKeyDAL = apiKeyDALFactory(db); const projectDAL = projectDALFactory(db); + const projectSshConfigDAL = projectSshConfigDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db); const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); @@ -385,6 +391,9 @@ export const registerRoutes = async ( const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); + const sshHostDAL = sshHostDALFactory(db); + const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db); + const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); @@ -796,6 +805,21 @@ export const registerRoutes = async ( permissionService }); + const sshHostService = sshHostServiceFactory({ + userDAL, + projectDAL, + projectSshConfigDAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + sshHostDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + permissionService, + kmsService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -938,6 +962,7 @@ export const registerRoutes = async ( const projectService = projectServiceFactory({ permissionService, projectDAL, + projectSshConfigDAL, secretDAL, secretV2BridgeDAL, queueService, @@ -959,8 +984,10 @@ export const registerRoutes = async ( pkiAlertDAL, pkiCollectionDAL, sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, sshCertificateDAL, sshCertificateTemplateDAL, + sshHostDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1603,6 +1630,7 @@ export const registerRoutes = async ( certificate: certificateService, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, + sshHost: sshHostService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 84d2ee6cd..6e4a8170e 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -13,6 +13,7 @@ import { InfisicalProjectTemplate } from "@app/ee/services/project-template/proj import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -600,4 +601,38 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { cas }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-hosts", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOSTS.projectId) + }), + response: { + 200: z.object({ + hosts: z.array( + sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const hosts = await server.services.project.listProjectSshHosts({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { hosts }; + } + }); }; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 3dfe11d2c..fc2fb9319 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -141,6 +141,7 @@ export const projectRoleServiceFactory = ({ validateHandlebarTemplate("Project Role Update", JSON.stringify(data.permissions || []), { allowedExpressions: (val) => val.includes("identity.") }); + const updatedRole = await projectRoleDAL.updateById(projectRole.id, { ...data, permissions: data.permissions ? data.permissions : undefined diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index 92d0dfc39..08652e348 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,12 +1,15 @@ import crypto from "crypto"; import { ProjectVersion, TProjects } from "@app/db/schemas"; +import { createSshCaHelper } from "@app/ee/services/ssh/ssh-certificate-authority-fns"; +import { SshCaKeySource } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { AddUserToWsDTO } from "./project-types"; +import { AddUserToWsDTO, TBootstrapSshProjectDTO } from "./project-types"; export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => { const plaintextProjectKey = decryptAsymmetric({ @@ -102,3 +105,48 @@ export const getProjectKmsCertificateKeyId = async ({ return keyId; }; + +/** + * Bootstraps an SSH project. + * - Creates a user and host SSH CA + * - Creates a project SSH config with the user and host SSH CA as defaults + */ +export const bootstrapSshProject = async ({ + projectId, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + projectSshConfigDAL, + tx +}: TBootstrapSshProjectDTO) => { + const userSshCa = await createSshCaHelper({ + projectId, + friendlyName: "User CA", + keyAlgorithm: SshCertKeyAlgorithm.ED25519, + keySource: SshCaKeySource.INTERNAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + tx + }); + + const hostSshCa = await createSshCaHelper({ + projectId, + friendlyName: "Host CA", + keyAlgorithm: SshCertKeyAlgorithm.ED25519, + keySource: SshCaKeySource.INTERNAL, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + tx + }); + + await projectSshConfigDAL.create( + { + projectId, + defaultHostSshCaId: hostSshCa.id, + defaultUserSshCaId: userSshCa.id + }, + tx + ); +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 58e3f9b54..1f45734b3 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import { @@ -15,13 +15,16 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSecretActions, + ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -61,8 +64,9 @@ import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; -import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; +import { assignWorkspaceKeysToMembers, bootstrapSshProject, createProjectKey } from "./project-fns"; import { TProjectQueueFactory } from "./project-queue"; +import { TProjectSshConfigDALFactory } from "./project-ssh-config-dal"; import { TCreateProjectDTO, TDeleteProjectDTO, @@ -77,6 +81,7 @@ import { TListProjectSshCasDTO, TListProjectSshCertificatesDTO, TListProjectSshCertificateTemplatesDTO, + TListProjectSshHostsDTO, TLoadProjectKmsBackupDTO, TProjectAccessRequestDTO, TSearchProjectsDTO, @@ -97,8 +102,8 @@ export const DEFAULT_PROJECT_ENVS = [ ]; type TProjectServiceFactoryDep = { - // TODO: Pick projectDAL: TProjectDALFactory; + projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; projectBotService: Pick; @@ -123,9 +128,11 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; - sshCertificateAuthorityDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; + sshHostDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -144,6 +151,7 @@ type TProjectServiceFactoryDep = { | "getKmsById" | "getProjectSecretManagerKmsKeyId" | "deleteInternalKms" + | "createCipherPairWithDataKey" >; projectTemplateService: TProjectTemplateServiceFactory; }; @@ -152,6 +160,7 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectSshConfigDAL, secretDAL, secretV2BridgeDAL, projectQueue, @@ -177,8 +186,10 @@ export const projectServiceFactory = ({ pkiCollectionDAL, pkiAlertDAL, sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, sshCertificateDAL, sshCertificateTemplateDAL, + sshHostDAL, keyStore, kmsService, projectBotDAL, @@ -266,6 +277,17 @@ export const projectServiceFactory = ({ tx ); + if (type === ProjectType.SSH) { + await bootstrapSshProject({ + projectId: project.id, + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + kmsService, + projectSshConfigDAL, + tx + }); + } + // set ghost user as admin of project const projectMembership = await projectMembershipDAL.create( { @@ -1046,6 +1068,48 @@ export const projectServiceFactory = ({ return cas; }; + /** + * Return list of SSH hosts for project + */ + const listProjectSshHosts = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshHostsDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + const allowedHosts = []; + + // (dangtony98): room to optimize + const hosts = await sshHostDAL.findSshHostsWithLoginMappings(projectId); + + for (const host of hosts) { + try { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSshHostActions.Read, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + + allowedHosts.push(host); + } catch { + // intentionally ignore projects where user lacks access + } + } + + return allowedHosts; + }; + /** * Return list of SSH certificates for project */ @@ -1443,6 +1507,7 @@ export const projectServiceFactory = ({ listProjectPkiCollections, listProjectCertificateTemplates, listProjectSshCas, + listProjectSshHosts, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, diff --git a/backend/src/services/project/project-ssh-config-dal.ts b/backend/src/services/project/project-ssh-config-dal.ts new file mode 100644 index 000000000..5085bd438 --- /dev/null +++ b/backend/src/services/project/project-ssh-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectSshConfigDALFactory = ReturnType; + +export const projectSshConfigDALFactory = (db: TDbClient) => { + const projectSshConfigOrm = ormify(db, TableName.ProjectSshConfig); + + return projectSshConfigOrm; +}; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 30519005d..4195f3dfc 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,6 +1,10 @@ import { Knex } from "knex"; -import { ProjectType, SortDirection, TProjectKeys } from "@app/db/schemas"; +import { ProjectType, TProjectKeys, SortDirection } from "@app/db/schemas"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { OrgServiceActor, TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -143,6 +147,7 @@ export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; export type TListProjectSshCasDTO = TProjectPermission; +export type TListProjectSshHostsDTO = TProjectPermission; export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; export type TListProjectSshCertificatesDTO = { offset: number; @@ -159,6 +164,15 @@ export type TUpdateProjectSlackConfig = { secretRequestChannels: string; } & TProjectPermission; +export type TBootstrapSshProjectDTO = { + projectId: string; + sshCertificateAuthorityDAL: Pick; + sshCertificateAuthoritySecretDAL: Pick; + projectSshConfigDAL: Pick; + kmsService: Pick; + tx?: Knex; +}; + export enum SearchProjectSortBy { NAME = "name" } diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 45510899a..ab90a71d4 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -18,6 +18,8 @@ export enum PostHogEventTypes { SecretRequestDeleted = "Secret Request Deleted", SignSshKey = "Sign SSH Key", IssueSshCreds = "Issue SSH Credentials", + IssueSshHostUserCert = "Issue SSH Host User Certificate", + IssueSshHostHostCert = "Issue SSH Host Host Certificate", SignCert = "Sign PKI Certificate", IssueCert = "Issue PKI Certificate" } @@ -161,6 +163,26 @@ export type TIssueSshCredsEvent = { }; }; +export type TIssueSshHostUserCertEvent = { + event: PostHogEventTypes.IssueSshHostUserCert; + properties: { + sshHostId: string; + hostname: string; + principals: string[]; + userAgent?: string; + }; +}; + +export type TIssueSshHostHostCertEvent = { + event: PostHogEventTypes.IssueSshHostHostCert; + properties: { + sshHostId: string; + hostname: string; + principals: string[]; + userAgent?: string; + }; +}; + export type TSignCertificateEvent = { event: PostHogEventTypes.SignCert; properties: { @@ -195,6 +217,8 @@ export type TPostHogEvent = { distinctId: string } & ( | TSecretRequestDeletedEvent | TSignSshKeyEvent | TIssueSshCredsEvent + | TIssueSshHostUserCertEvent + | TIssueSshHostHostCertEvent | TSignCertificateEvent | TIssueCertificateEvent ); diff --git a/cli/go.mod b/cli/go.mod index 5f3992e17..c713417e2 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -12,7 +12,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.1 + github.com/infisical/go-sdk v0.5.8 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a diff --git a/cli/go.sum b/cli/go.sum index da221fd6f..68bce9cd3 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -277,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.5.1 h1:bl0D4A6CmvfL8RwEQTcZh39nsxC6q3HSs76/4J8grWY= -github.com/infisical/go-sdk v0.5.1/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.8 h1:bCetYLp7HWt8DnU9KPh1n8n3z5pjmunkGDB4bA3lEFs= +github.com/infisical/go-sdk v0.5.8/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index a3d10fc91..5b2bb37bb 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "os" + "os/exec" "path/filepath" "strings" "time" @@ -17,6 +18,7 @@ import ( "github.com/Infisical/infisical-merge/packages/util" infisicalSdk "github.com/infisical/go-sdk" infisicalSdkUtil "github.com/infisical/go-sdk/packages/util" + "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -48,6 +50,18 @@ var sshSignKeyCmd = &cobra.Command{ Run: signKey, } +var sshConnectCmd = &cobra.Command{ + Use: "connect", + Short: "Connect to an SSH host using issued credentials", + Run: sshConnect, +} + +var sshAddHostCmd = &cobra.Command{ + Use: "add-host", + Short: "Register a new SSH host with Infisical", + Run: sshAddHost, +} + var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{ infisicalSdkUtil.RSA2048: "id_rsa_2048", infisicalSdkUtil.RSA4096: "id_rsa_4096", @@ -240,7 +254,7 @@ func issueCredentials(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse addToAgent flag") } - if outFilePath == "" && addToAgent == false { + if outFilePath == "" && !addToAgent { util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command") } @@ -595,6 +609,380 @@ func signKey(cmd *cobra.Command, args []string) { fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath) } +func sshConnect(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + infisicalToken := loggedInUserDetails.UserCredentials.JTWToken + + writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeHostCaToFile flag") + } + + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + CustomHeaders: customHeaders, + }) + infisicalClient.Auth().SetAccessToken(infisicalToken) + + // Fetch SSH Hosts + hosts, err := infisicalClient.Ssh().GetSshHosts(infisicalSdk.GetSshHostsOptions{}) + if err != nil { + util.HandleError(err, "Failed to fetch SSH hosts") + } + if len(hosts) == 0 { + util.PrintErrorMessageAndExit("You do not have access to any SSH hosts") + } + + // Prompt to select host + hostNames := make([]string, len(hosts)) + for i, h := range hosts { + hostNames[i] = h.Hostname + } + + hostPrompt := promptui.Select{ + Label: "Select an SSH Host", + Items: hostNames, + Size: 10, + } + hostIdx, _, err := hostPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedHost := hosts[hostIdx] + + // Prompt to select login user + if len(selectedHost.LoginMappings) == 0 { + util.PrintErrorMessageAndExit("No login users available for selected host") + } + + loginUsers := make([]string, len(selectedHost.LoginMappings)) + for i, m := range selectedHost.LoginMappings { + loginUsers[i] = m.LoginUser + } + + loginPrompt := promptui.Select{ + Label: "Select Login User", + Items: loginUsers, + Size: 5, + } + loginIdx, _, err := loginPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser + + // Issue SSH creds for host + creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ + LoginUser: selectedLoginUser, + }) + if err != nil { + util.HandleError(err, "Failed to issue SSH credentials") + } + + // Write Host CA public key to known_hosts if enabled + if writeHostCaToFile { + hostCaPublicKey, err := infisicalClient.Ssh().GetSshHostHostCaPublicKey(selectedHost.ID) + if err != nil { + util.HandleError(err, "Failed to fetch Host CA public key") + } + + // Build @cert-authority line + caLine := fmt.Sprintf("@cert-authority %s %s\n", selectedHost.Hostname, strings.TrimSpace(hostCaPublicKey)) + + // Determine known_hosts path + sshDir := filepath.Join(os.Getenv("HOME"), ".ssh") + knownHostsPath := filepath.Join(sshDir, "known_hosts") + + // Ensure ~/.ssh exists + if _, err := os.Stat(sshDir); os.IsNotExist(err) { + if err := os.MkdirAll(sshDir, 0700); err != nil { + util.HandleError(err, "Failed to create ~/.ssh directory") + } + } + + // Check if CA line already exists + knownHostsBytes, _ := os.ReadFile(knownHostsPath) + if !strings.Contains(string(knownHostsBytes), caLine) { + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + util.HandleError(err, "Failed to open known_hosts file") + } + defer f.Close() + + if _, err := f.WriteString(caLine); err != nil { + util.HandleError(err, "Failed to write Host CA to known_hosts") + } + + fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath) + } + } + + // Load credentials into SSH agent + err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) + if err != nil { + util.HandleError(err, "Failed to add credentials to SSH agent") + } + fmt.Println("✔ SSH credentials successfully added to agent") + + // Connect to host using system ssh and agent + target := fmt.Sprintf("%s@%s", selectedLoginUser, selectedHost.Hostname) + fmt.Printf("Connecting to %s...\n", target) + + sshCmd := exec.Command("ssh", target) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + err = sshCmd.Run() + if err != nil { + util.HandleError(err, "SSH connection failed") + } +} + +func sshAddHost(cmd *cobra.Command, args []string) { + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse token") + } + + var infisicalToken string + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login]") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken + } + + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse --projectId flag") + } + if projectId == "" { + util.PrintErrorMessageAndExit("You must provide --projectId") + } + + hostname, err := cmd.Flags().GetString("hostname") + if err != nil { + util.HandleError(err, "Unable to parse --hostname flag") + } + if hostname == "" { + util.PrintErrorMessageAndExit("You must provide --hostname") + } + + writeUserCaToFile, err := cmd.Flags().GetBool("writeUserCaToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeUserCaToFile flag") + } + + userCaOutFilePath, err := cmd.Flags().GetString("userCaOutFilePath") + if err != nil { + util.HandleError(err, "Unable to parse --userCaOutFilePath flag") + } + + writeHostCertToFile, err := cmd.Flags().GetBool("writeHostCertToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeHostCertToFile flag") + } + + configureSshd, err := cmd.Flags().GetBool("configureSshd") + if err != nil { + util.HandleError(err, "Unable to parse --configureSshd flag") + } + + forceOverwrite, err := cmd.Flags().GetBool("force") + if err != nil { + util.HandleError(err, "Unable to parse --force flag") + } + + if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) { + util.PrintErrorMessageAndExit("--configureSshd requires both --writeUserCaToFile and --writeHostCertToFile to also be set") + } + + // Pre-check for file overwrites before proceeding + if writeUserCaToFile { + if strings.HasPrefix(userCaOutFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Unable to resolve ~ in userCaOutFilePath") + } + userCaOutFilePath = strings.Replace(userCaOutFilePath, "~", homeDir, 1) + } + if _, err := os.Stat(userCaOutFilePath); err == nil && !forceOverwrite { + util.PrintErrorMessageAndExit("File already exists at " + userCaOutFilePath + ". Use --force to overwrite.") + } + } + + keyTypes := []string{"ed25519", "ecdsa", "rsa"} + var hostKeyPath, certOutPath, hostPrivateKeyPath string + if writeHostCertToFile { + for _, keyType := range keyTypes { + pub := fmt.Sprintf("/etc/ssh/ssh_host_%s_key.pub", keyType) + cert := fmt.Sprintf("/etc/ssh/ssh_host_%s_key-cert.pub", keyType) + priv := fmt.Sprintf("/etc/ssh/ssh_host_%s_key", keyType) + + if _, err := os.Stat(pub); err == nil { + hostKeyPath = pub + certOutPath = cert + hostPrivateKeyPath = priv + break + } + } + + if hostKeyPath == "" { + util.PrintErrorMessageAndExit("No supported SSH host public key found at /etc/ssh") + } + + if _, err := os.Stat(certOutPath); err == nil && !forceOverwrite { + util.PrintErrorMessageAndExit("File already exists at " + certOutPath + ". Use --force to overwrite.") + } + } + + if configureSshd { + sshdConfig := "/etc/ssh/sshd_config" + existing, err := os.ReadFile(sshdConfig) + if err != nil { + util.HandleError(err, "Failed to read sshd_config") + } + configLines := []string{ + "TrustedUserCAKeys " + userCaOutFilePath, + "HostKey " + hostPrivateKeyPath, + "HostCertificate " + certOutPath, + } + for _, line := range configLines { + for _, existingLine := range strings.Split(string(existing), "\n") { + trimmed := strings.TrimSpace(existingLine) + if trimmed == line && !strings.HasPrefix(trimmed, "#") && !forceOverwrite { + util.PrintErrorMessageAndExit("sshd_config already contains: " + line + ". Use --force to overwrite.") + } + } + } + } + + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + + client := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + AutoTokenRefresh: false, + CustomHeaders: customHeaders, + }) + client.Auth().SetAccessToken(infisicalToken) + + host, err := client.Ssh().AddSshHost(infisicalSdk.AddSshHostOptions{ + ProjectID: projectId, + Hostname: hostname, + }) + if err != nil { + util.HandleError(err, "Failed to register SSH host") + } + + fmt.Println("✅ Successfully registered host:", host.Hostname) + + if writeUserCaToFile { + publicKey, err := client.Ssh().GetSshHostUserCaPublicKey(host.ID) + if err != nil { + util.HandleError(err, "Failed to fetch associated User CA public key") + } + + if err := writeToFile(userCaOutFilePath, publicKey, 0644); err != nil { + util.HandleError(err, "Failed to write User CA public key to file") + } + + fmt.Println("📁 Wrote User CA public key to:", userCaOutFilePath) + } + + if writeHostCertToFile { + pubKeyBytes, err := os.ReadFile(hostKeyPath) + if err != nil { + util.HandleError(err, "Failed to read SSH host public key") + } + res, err := client.Ssh().IssueSshHostHostCert(host.ID, infisicalSdk.IssueSshHostHostCertOptions{ + PublicKey: string(pubKeyBytes), + }) + if err != nil { + util.HandleError(err, "Failed to issue SSH host certificate") + } + if err := writeToFile(certOutPath, res.SignedKey, 0644); err != nil { + util.HandleError(err, "Failed to write SSH host certificate to file") + } + fmt.Println("📁 Wrote host certificate to:", certOutPath) + } + + if configureSshd { + sshdConfig := "/etc/ssh/sshd_config" + contentBytes, err := os.ReadFile(sshdConfig) + if err != nil { + util.HandleError(err, "Failed to read sshd_config") + } + lines := strings.Split(string(contentBytes), "\n") + + configMap := map[string]string{ + "TrustedUserCAKeys": userCaOutFilePath, + "HostKey": hostPrivateKeyPath, + "HostCertificate": certOutPath, + } + + seenKeys := map[string]bool{} + for i, line := range lines { + trimmed := strings.TrimSpace(line) + for key, value := range configMap { + if strings.HasPrefix(trimmed, key+" ") { + seenKeys[key] = true + if strings.HasPrefix(trimmed, "#") || forceOverwrite { + lines[i] = fmt.Sprintf("%s %s", key, value) + } else { + util.PrintErrorMessageAndExit("sshd_config already contains: " + trimmed + ". Use --force to overwrite.") + } + } + } + } + + // Append missing lines + for key, value := range configMap { + if !seenKeys[key] { + lines = append(lines, fmt.Sprintf("%s %s", key, value)) + } + } + + // Write back to file + if err := os.WriteFile(sshdConfig, []byte(strings.Join(lines, "\n")), 0644); err != nil { + util.HandleError(err, "Failed to update sshd_config") + } + fmt.Println("📄 Updated sshd_config entries") + } +} + func init() { sshSignKeyCmd.Flags().String("token", "", "Issue SSH certificate using machine identity access token") sshSignKeyCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue the SSH certificate for") @@ -617,5 +1005,20 @@ func init() { sshIssueCredentialsCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be saved to the current working directory") sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) + + sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshCmd.AddCommand(sshConnectCmd) + + sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") + sshAddHostCmd.Flags().String("projectId", "", "Project ID the host belongs to (required)") + sshAddHostCmd.Flags().String("hostname", "", "Hostname of the SSH host (required)") + sshAddHostCmd.Flags().Bool("writeUserCaToFile", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") + sshAddHostCmd.Flags().String("userCaOutFilePath", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") + sshAddHostCmd.Flags().Bool("writeHostCertToFile", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") + sshAddHostCmd.Flags().Bool("configureSshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") + sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile") + + sshCmd.AddCommand(sshAddHostCmd) + rootCmd.AddCommand(sshCmd) } diff --git a/docs/documentation/platform/ssh-old.mdx b/docs/documentation/platform/ssh-old.mdx new file mode 100644 index 000000000..9e9e8aac4 --- /dev/null +++ b/docs/documentation/platform/ssh-old.mdx @@ -0,0 +1,363 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Infisical SSH" +description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure." +--- + +## Concept + +Infisical can be used to issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure; +this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management, +unauthorized access, and SSH key sprawl. + +The following concepts are useful to know when working with Infisical SSH: + +- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates. +- Certificate Template: A set of policies bound to an SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access). +- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure. + +
+ +```mermaid +graph TD + A[SSH CA] + A --> B[Certificate Template A] + A --> C[Certificate Template N] + B --> D[SSH Certificate A] + C --> E[SSH Certificate N] + +``` + +
+ +When using Infisical SSH to provision client access to a remote host, an operator must create an SSH CA in Infisical; a certificate template under it, +specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA. + +When a client needs access to a host, they authenticate with Infisical and request an SSH certificate (and optionally key pair) +to be used to access the host for a time-bound session as part of the SSH operation. + +## Client Workflow + +The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair) +supplied by Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infisical as Infisical (SSH CA) + participant Host as Remote Host + + Note over Client,Client: Step 1: Client Authentication with Infisical + Client->>Infisical: Send credential(s) to authenticate with Infisical + + Infisical-->>Client: Return access token + + Note over Client,Infisical: Step 2: SSH Certificate Request + Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign + + Infisical-->>Client: Return signed SSH certificate (and optionally key pair) + + Note over Client,Client: Step 3: SSH Operation + Client->>Host: SSH into Host using the SSH certificate + + Host-->>Client: Grant access to the host +``` + +At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host. + +To be more specific: + +1. The client authenticates with Infisical; this can be done using a user or machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities). +2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair along with the certificate. +3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host. + + + Note that the workflow above requires an operator to perform additional + configuration on the remote host to trust SSH certificates issued by + Infisical. + + +## Guide to Configuring Infisical SSH + +In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates +as part of the SSH operation. + + + + 1.1. Start by creating an SSH project in the SSH tab of your organization. + + ![ssh project create](/images/platform/ssh/ssh-project.png) + + 1.2. Next, create an SSH CA in the **Certificate Authorities** tab of the + project; this CA will be used for client key signing. + + ![ssh create client ca](/images/platform/ssh/ssh-client-create-ca-1.png) + + ![ssh create client ca popup](/images/platform/ssh/ssh-client-create-ca-2.png) + + Here's some guidance on each field: + + - Friendly Name: A friendly name for the CA; this is only for display. + - Key Source: Whether the CA's key pair should be generated internally or supplied from an external source. Select **Internal**. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + + + + + 2.1. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA. + + With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 hour. + + ![ssh client create template](/images/platform/ssh/ssh-client-create-template-1.png) + + ![ssh client create template popup](/images/platform/ssh/ssh-client-create-template-2.png) + + Here's some guidance on each field: + + - SSH Template Name: A name for the certificate template; this must be a valid slug. + - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. + - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. + - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. We recommend setting a shorter **Default TTL** for client certificates such as `30m`. + - Max TTL: The maximum TTL for certificates issued under this template. + - Allow User Certificates: Whether or not to allow issuance of user certificates; this should be set to `true`. + - Allow Host Certificates: Whether or not to allow issuance of host certificates; this is not relevant for this step. + - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. + + 2.2. Finally, add the user(s) you wish to be able to request an SSH certificate to the SSH project through the **Access Control** tab. + + + + + 3.1. Begin by downloading the client CA's public key from the CA's details section. + + ![ssh ca public key](/images/platform/ssh/ssh-client-ca-public-key.png) + + + The CA's public key can also be retrieved programmatically via API by making a `GET` request to the endpoint [here](/api-reference/endpoints/ssh/ca/public-key). + + + 3.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`. + + This would result in the file at the path `/etc/ssh/ca.pub`. + + 3.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + + ```bash + TrustedUserCAKeys /etc/ssh/ca.pub + + PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + ``` + + 3.4. Finally, reload the SSH daemon on the remote host to apply the changes. + + ```bash + sudo systemctl reload sshd + ``` + + At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA. + + + + +## Guide to Using Infisical SSH to Access a Host + +In the following steps, we show how to obtain an SSH certificate and use it for a client to access a host via CLI: + + + The subsequent guide assumes the following prerequisites: + +- SSH Agent is running: The `ssh-agent` must be actively running on the host machine. +- OpenSSH is installed: The system should have OpenSSH installed; this includes + both the `ssh` client and `ssh-agent`. +- `SSH_AUTH_SOCK` environment variable + is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that + `ssh-agent` uses for communication. + + + + + + +```bash +infisical login +``` + + + + Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent. + ```bash + infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent + ``` + + Here's some guidance on each flag: + + - `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate. + - `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate. + + For fuller documentation on commands and flags supported by the Infisical CLI for SSH, refer to the docs [here](/cli/commands/ssh). + + + + Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent. + + ```bash + ssh username@hostname + ``` + + + + + + Note that the above workflow can be executed via API or other client methods + such as SDK. + + +## Guide to Configuring Host Key Signing + +In the following steps, we show how to configure host key signing for clients to verify the identity of a remote host before attempting the SSH operation; this is recommended to reduce the probability of a client accessing a malicious machine. + + +This guide expects that the remote host already has an existing SSH key pair (typically found in the `/etc/ssh/` folder at `/etc/ssh/ssh_host__key` and `.pub`). + +If the remote host does not have an existing SSH key pair, you can generate a new key pair using the `ssh-keygen` command: `ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ''`. This will generate: + +- A private key: `/etc/ssh/ssh_host_rsa_key`. +- A public key: `/etc/ssh/ssh_host_rsa_key.pub`. + + + + + + 1.1. In the same SSH project, create another SSH CA in the **Certificate Authorities** tab; this CA will be used for host key signing. + + ![ssh create host ca](/images/platform/ssh/ssh-host-create-ca-1.png) + + ![ssh create host ca popup](/images/platform/ssh/ssh-host-create-ca-2.png) + + Here's some guidance on each field: + + - Friendly Name: A friendly name for the CA; this is only for display. + - Key Source: Whether the CA's key pair should be generated internally or supplied from an external source. Select **External**. + - Public Key: The public key for the CA (i.e. the host's SSH public key). + - Private Key: The private key for the CA (i.e. the host's SSH private key). + + + + + 2.1. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + ![ssh host create template](/images/platform/ssh/ssh-host-create-template-1.png) + + ![ssh host create template popup](/images/platform/ssh/ssh-host-create-template-2.png) + + Here's some guidance on each field: + + - SSH Template Name: A name for the certificate template; this must be a valid slug. + - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. + - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. + - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. We recommend setting a longer **Default TTL** for host certificates such as `2y`. + - Max TTL: The maximum TTL for certificates issued under this template. + - Allow User Certificates: Whether or not to allow issuance of user certificates; this is not relevant for this step. + - Allow Host Certificates: Whether or not to allow issuance of host certificates; this should be set to `true`. + - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. + + + + + 3.1. Obtain an SSH certificate for the host by requesting one from the **Certificates** tab. + + ![ssh host issue certificate 1](/images/platform/ssh/ssh-host-issue-cert-1.png) + + ![ssh host issue certificate 2](/images/platform/ssh/ssh-host-issue-cert-2.png) + + + You should select **Sign SSH Key** under the **Operation** field. + + Then input your host's SSH public key under the **SSH Public Key** field and hostname under the **Principal(s)** field; the host's public key should be in the `/etc/ssh` folder of the host as used in step 1. + + + ![ssh host issue certificate 3](/images/platform/ssh/ssh-host-issue-cert-3.png) + + 3.2. Create a file containing the certificate in the SSH folder of the remote host; we'll call it `ssh_host_key-cert.pub`. + + 3.3. Set permissions on the certificate to be `0640`: + + ```bash + sudo chmod 0640 /etc/ssh/ssh_host_key-cert.pub + ``` + + 3.4. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + + ```bash + HostKey /etc/ssh/ssh_host_rsa_key + HostCertificate /etc/ssh/ssh_host_key-cert.pub + ``` + + + You should adjust the `HostKey` directive to match the path to the host's SSH private key as used in step 1. + + + 3.5. Finally, reload the SSH daemon on the remote host to apply the changes. + + ```bash + sudo systemctl reload sshd + ``` + + + + 4.1. Begin by downloading the host CA's public key from the CA's details section. + + ![ssh host ca public key](/images/platform/ssh/ssh-host-ca-public-key.png) + + + The CA's public key can also be retrieved programmatically via API by making a `GET` request to the endpoint [here](/api-reference/endpoints/ssh/ca/public-key). + + + 4.2. Next, add the resulting public key to the `known_hosts` file on the client machine (e.g. at the path `~/.ssh/known_hosts`). + + ```bash + @cert-authority *.example.com ssh-rsa ... + ``` + + + + Finally, SSH into the desired host as usual; the SSH operation will now also include client-side host verification. + + ```bash + ssh username@hostname + ``` + + + + +## FAQ + + + + After configuring Infisical SSH, you can add the `-vvv` flag as part of the + SSH operation to see verbose output from the SSH client. + + ```bash + ssh -vvv username@hostname + ``` + + You should see output from the SSH client that includes the following if both client key signing and host key signing are working: + + Host certificate was verified and trusted: + + ```bash + debug1: Host 'example.com' is known and matches the ECDSA-CERT host certificate. + debug1: Found CA key in /Users/user/.ssh/known_hosts:1 + ``` + + You authenticated with your user certificate: + + ```bash + debug1: Offering public key: Added via Infisical CLI RSA-CERT SHA256:... + debug1: Server accepts key: Added via Infisical CLI RSA-CERT SHA256:... + ``` + + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index 16faf1267..ae1e43df5 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -1,210 +1,179 @@ --- title: "Infisical SSH" sidebarTitle: "Infisical SSH" -description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure." +description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." --- ## Concept -Infisical can be used to issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure; -this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management, +Infisical SSH can be configured to provide users on your team short-lived, secure SSH access to infrastructure. Under the hood, it uses SSH certificates +and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, unauthorized access, and SSH key sprawl. -The following concepts are useful to know when working with Infisical SSH: +The following entities and concepts are important to understand when using Infisical SSH: -- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates. -- Certificate Template: A set of policies bound to a SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access). -- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure. +- Administrator: An individual on your team who is responsible for configuring Infisical SSH. +- Users: Other individuals on your team that need access to the remote host. +- Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. -
+## Workflow -```mermaid -graph TD - A[SSH CA] - A --> B[Certificate Template A] - A --> C[Certificate Template N] - B --> D[SSH Certificate A] - C --> E[SSH Certificate N] +The typical workflow for using Infisical SSH consists of the following steps: -``` +1. The administrator registers a remote host with Infisical using the Infisical CLI via the `infisical ssh add-host` command. +2. The administrator configures Infisical SSH to grant users access to the remote host. +3. User(s) access the remote host using the Infisical CLI via the `infisical ssh connect` command. -
+## Admin Guide for Configuring Infisical SSH -When using Infisical SSH to provision client access to a remote host, an operator must create a SSH CA in Infisical; a certificate template under it, -specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA. - -When a client needs access to a host, they authenticate with Infisical and request a SSH certificate (and optionally key pair) -to be used to access the host for a time-bound session as part of the SSH operation. - -## Client Workflow - -The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair) -supplied by Infisical. - -```mermaid -sequenceDiagram - participant Client as Client - participant Infisical as Infisical (SSH CA) - participant Host as Remote Host - - Note over Client,Client: Step 1: Client Authentication with Infisical - Client->>Infisical: Send credential(s) to authenticate with Infisical - - Infisical-->>Client: Return access token - - Note over Client,Infisical: Step 2: SSH Certificate Request - Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign - - Infisical-->>Client: Return signed SSH certificate (and optionally key pair) - - Note over Client,Client: Step 3: SSH Operation - Client->>Host: SSH into Host using the SSH certificate - - Host-->>Client: Grant access to the host -``` - -At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host. - -To be more specific: - -1. The client authenticates with Infisical; this can be done using a machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities). -2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair in conjunction with the certificate. -3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host. - - - Note that the workflow above requires an operator to perform additional - configuration on the remote host to trust SSH certificates issued by - Infisical. - - -## Guide to Configuring Infisical SSH - -In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates -as part of the SSH operation. +In the following steps, we explore how to configure Infisical SSH to control and streamline your team's SSH access to infrastructure. As part of this guide, +we will register a remote host with Infisical through a [machine identity](/documentation/platform/identities/machine-identities) and configure Infisical to grant user(s) access to the remote host. - - 1.1. Start by creating a SSH project in the SSH tab of your organization. + + 1.1. Start by creating a new Infisical SSH project in Infisical. - ![ssh project create](/images/platform/ssh/ssh-project.png) + ![ssh project create](/images/platform/ssh/v2/ssh-create-project.png) - 1.2. Next, create a CA in the **Certificate Authorities** tab of the - project. + 1.2. Create a custom role in the project under Access Control > Project Roles to grant the machine identity that we will create in step 2 the ability to **Create** and **Issue Host Certificates** on the **SSH Host** resource; this will enable the linked machine identity to bootstrap a remote host with Infisical + and establish the necessary configuration on it. - ![ssh create ca](/images/platform/ssh/ssh-create-ca-1.png) + ![ssh custom role bootstrap 1](/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png) - ![ssh create ca popup](/images/platform/ssh/ssh-create-ca-2.png) - - Here's some guidance on each field: + ![ssh custom role bootstrap 2](/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png) + + + 2.1. Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - - Friendly Name: A friendly name for the CA; this is only for display. - - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the identity to authenticate with Infisical + as part of registering a remote host in step 3. - 1.3. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + You may use other authentication methods as suitable (e.g. [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), [GCP Auth](/documentation/platform/identities/gcp-auth), etc.) as part of the machine identity configuration but, to keep this example simple, we will be using Universal Auth. + - A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA. + 2.2. Add the machine identity to the Infisical SSH project you created in the previous step and assign it the custom role you created in step 1.2. - With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 year. - - ![ssh create template](/images/platform/ssh/ssh-create-template-1.png) - - ![ssh create template popup](/images/platform/ssh/ssh-create-template-2.png) - - Here's some guidance on each field: - - - SSH Template Name: A name for the certificate template; this must be a valid slug. - - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. - - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. - - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. - - Max TTL: The maximum TTL for certificates issued under this template. - - Allow User Certificates: Whether or not to allow issuance of user certificates. - - Allow Host Certificates: Whether or not to allow issuance of host certificates. - - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. - - 1.4. Finally, add the user(s) you wish to be able to request a SSH certificate to the SSH project through the **Access Control** tab. + ![ssh add identity to project](/images/platform/ssh/v2/ssh-add-identity-to-project.png) - - - 2.1. Begin by downloading the CA's public key from the CA's details section. + + 3.1. Follow the instructions [here](/cli/overview) to install the Infisical CLI onto the remote host. - ![ssh ca public key](/images/platform/ssh/ssh-ca-public-key.png) - - - The CA's public key can also be retrieved programmatically via API by making a `GET` request to the `/ssh/ca//public-key` endpoint. - - - 2.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`. + 3.2. Run the commands below to register the remote host with Infisical. - This would result in the file at the path `/etc/ssh/ca.pub`. - - 2.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + Use the **Client ID** and **Client Secret** from the machine identity you created in step 2.1 as part of the `infisical login` command + to obtain an access token and save it as an environment variable. ```bash - TrustedUserCAKeys /etc/ssh/ca.pub - - PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) ``` - 2.4. Finally, reload the SSH daemon on the remote host to apply the changes. + Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. + + ```bash + sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --writeUserCaToFile --writeHostCertToFile --configureSshd + ``` + + + Note that if you're self-hosting Infisical, you can use the `--domain` flag on the `infisical login` command to specify the domain of your Infisical instance. + + For more information on the `infisical ssh add-host` command, please refer to the Infisical CLI [documentation](/cli/overview). + + + If successful, you should see output similar to the following: + + ```bash + ✅ Successfully registered host: + 📁 Wrote User CA public key to: /etc/ssh/infisical_user_ca.pub + 📁 Wrote host certificate to: /etc/ssh/ssh_host_ed25519_key-cert.pub + 📄 Updated sshd_config entries + ``` + + Finally, use the following command to reload the SSH daemon on the remote host to apply the changes: ```bash sudo systemctl reload sshd ``` - At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA. + + The command may differ depending on the host. For older versions of Ubuntu/Debian/CentOS, you may need to use `sudo service ssh reload` instead; + for Alpine or minimal systems, `/etc/init.d/sshd reload`. + + + Back in Infisical, you should now see the remote host you just registered in the Infisical SSH project you created in step 1 under the **Hosts** tab. + + ![ssh hosts](/images/platform/ssh/v2/ssh-added-hosts.png) + + + + 4.1. Add the user(s) you wish to grant access to the remote host to the Infisical SSH project under Access Control > Users. + + ![ssh hosts](/images/platform/ssh/v2/ssh-add-user.png) + + 4.2. On the registered host in the **Hosts** tab, click **Edit SSH Host** and add a login mapping for the user(s) you added in step 4.1. + + The login mapping dictates what user(s) will be allowed access to the remote host and under a specific login user; in the allowed principals, + you should select user(s) part of the Infisical SSH project that will be allowed to login to the remote host as the login user. + + For instance, if you add a mapping with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to the remote host as `ec2-user` which is a system user that + exists on the remote host. + + ![ssh host mappings](/images/platform/ssh/v2/ssh-host-login-mappings.png) + + + Note that you should configure authorized principals files for each login user you add to the remote host. + -## Guide to Using Infisical SSH to Access a Host +## User Guide for SSHing to a Host -We show how to obtain a SSH certificate and use it for a client to access a host via CLI: - - - The subsequent guide assumes the following prerequisites: - -- SSH Agent is running: The `ssh-agent` must be actively running on the host machine. -- OpenSSH is installed: The system should have OpenSSH installed; this includes - both the `ssh` client and `ssh-agent`. -- `SSH_AUTH_SOCK` environment variable - is set; the `SSH_AUTH_SOCK` variable should point to the UNIX socket that - `ssh-agent` uses for communication. - - +Once Infisical SSH is configured by an administrator, users can SSH to the remote host using the Infisical CLI. - + + Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. + + + Run the `infisical login` command to authenticate with Infisical. + + ```bash + infisical login + ``` + + + Run the `infisical ssh connect` command to connect to a remote host. -```bash -infisical login -``` + ```bash + infisical ssh connect + ``` - - - Run the `infisical ssh issue-credentials` command, specifying the `--addToAgent` flag to automatically load the SSH certificate into the SSH agent. - ```bash - infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent - ``` + You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by + the administrator. - Here's some guidance on each flag: + ```bash + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select an SSH Host: + ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com + ``` - - `certificateTemplateId`: The ID of the certificate template to use for issuing the SSH certificate. - - `principals`: The comma-delimited username(s) or hostname(s) to include in the SSH certificate. - - For fuller documentation on commands and flags supported by the Infisical CLI for SSH, refer to the docs [here](/cli/commands/ssh). - - - - Finally, SSH into the desired host; the SSH operation will be performed using the SSH certificate loaded into the SSH agent. + After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + + ```bash + ? Select Login User: + ▸ ec2-user + ``` + + If successful, you should be able to SSH to the remote host. + + ```bash + ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com + ✔ ec2-user + ✔ SSH credentials successfully added to agent + Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... + ``` + - ```bash - ssh username@hostname - ``` - - - - Note that the above workflow can be executed via API or other client methods - such as SDK. - \ No newline at end of file diff --git a/docs/images/platform/ssh/ssh-client-ca-public-key.png b/docs/images/platform/ssh/ssh-client-ca-public-key.png new file mode 100644 index 000000000..058b844fb Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-ca-1.png b/docs/images/platform/ssh/ssh-client-create-ca-1.png new file mode 100644 index 000000000..30658944f Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-ca-2.png b/docs/images/platform/ssh/ssh-client-create-ca-2.png new file mode 100644 index 000000000..3a0bf155c Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-template-1.png b/docs/images/platform/ssh/ssh-client-create-template-1.png new file mode 100644 index 000000000..0c0ba97c8 Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-client-create-template-2.png b/docs/images/platform/ssh/ssh-client-create-template-2.png new file mode 100644 index 000000000..8c64ec62b Binary files /dev/null and b/docs/images/platform/ssh/ssh-client-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-ca-public-key.png b/docs/images/platform/ssh/ssh-host-ca-public-key.png new file mode 100644 index 000000000..f77034896 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-ca-1.png b/docs/images/platform/ssh/ssh-host-create-ca-1.png new file mode 100644 index 000000000..f064dec35 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-ca-2.png b/docs/images/platform/ssh/ssh-host-create-ca-2.png new file mode 100644 index 000000000..75b0fe76c Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-template-1.png b/docs/images/platform/ssh/ssh-host-create-template-1.png new file mode 100644 index 000000000..e8ec9ec20 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-host-create-template-2.png b/docs/images/platform/ssh/ssh-host-create-template-2.png new file mode 100644 index 000000000..95b41be9b Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-issue-cert-1.png b/docs/images/platform/ssh/ssh-host-issue-cert-1.png new file mode 100644 index 000000000..c4483eb83 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-issue-cert-1.png differ diff --git a/docs/images/platform/ssh/ssh-host-issue-cert-2.png b/docs/images/platform/ssh/ssh-host-issue-cert-2.png new file mode 100644 index 000000000..ec5f677bc Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-issue-cert-2.png differ diff --git a/docs/images/platform/ssh/ssh-host-issue-cert-3.png b/docs/images/platform/ssh/ssh-host-issue-cert-3.png new file mode 100644 index 000000000..41af0c9f3 Binary files /dev/null and b/docs/images/platform/ssh/ssh-host-issue-cert-3.png differ diff --git a/docs/images/platform/ssh/ssh-project.png b/docs/images/platform/ssh/ssh-project.png index 0b57f9245..9b28f04ab 100644 Binary files a/docs/images/platform/ssh/ssh-project.png and b/docs/images/platform/ssh/ssh-project.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png new file mode 100644 index 000000000..8acc1efe9 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png new file mode 100644 index 000000000..2ad9804d4 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-bootstrap-role-2.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png b/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png new file mode 100644 index 000000000..83bd3c984 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-identity-to-project.png differ diff --git a/docs/images/platform/ssh/v2/ssh-add-user.png b/docs/images/platform/ssh/v2/ssh-add-user.png new file mode 100644 index 000000000..363a2a898 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-add-user.png differ diff --git a/docs/images/platform/ssh/v2/ssh-added-hosts.png b/docs/images/platform/ssh/v2/ssh-added-hosts.png new file mode 100644 index 000000000..20c7f9861 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-added-hosts.png differ diff --git a/docs/images/platform/ssh/v2/ssh-create-project.png b/docs/images/platform/ssh/v2/ssh-create-project.png new file mode 100644 index 000000000..792d49612 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-create-project.png differ diff --git a/docs/images/platform/ssh/v2/ssh-host-login-mappings.png b/docs/images/platform/ssh/v2/ssh-host-login-mappings.png new file mode 100644 index 000000000..cdc192274 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-host-login-mappings.png differ diff --git a/frontend/public/lotties/certificate-authority.json b/frontend/public/lotties/certificate-authority.json new file mode 100644 index 000000000..44e1488a0 --- /dev/null +++ b/frontend/public/lotties/certificate-authority.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":180,"w":430,"h":430,"nm":"wired-outline-1945-court","ddd":0,"assets":[{"id":"comp_1","nm":"hover-pinch","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215.377,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250.377,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[2.391,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[42.99,0],[-43.164,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-117.51,0],[-94.411,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[42.99,0],[-43.164,0]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[42.99,240.179],[26.99,204.803],[27.097,204.803]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[-117.51,240.179],[-117.46,205.303],[27.097,205.303]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[42.99,240.179],[26.99,204.803],[27.097,204.803]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.687],[15.1,59.803],[14.994,59.803],[14.994,26.687]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.187],[15.1,59.303],[-117.423,59.303],[-117.423,26.187]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.687],[15.1,59.803],[14.994,59.803],[14.994,26.687]],"c":false}]}],"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.687],[37.533,26.687],[42.99,0]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.187],[-117.493,26.187],[-117.51,0]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.687],[37.533,26.687],[42.99,0]],"c":true}]}],"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[367.01,169.94],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215.377,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250.377,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-116.885,0],[-140.433,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.109,0],[-43.109,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-277.129,0],[-190.911,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-277.129,0],[-238.911,0]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-277.129,0],[-190.911,0]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[-277.129,240.179],[-261.117,205.303],[27.097,205.303]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-115.891,239.963],[-277.129,240.179],[-261.117,205.303],[-116.403,205.105]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[43.109,240.179],[-277.129,240.179],[-261.117,205.303],[27.097,205.303]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.187],[15.1,59.303],[-249.113,59.303],[-249.113,26.187]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-116.907,26.187],[-116.907,59.303],[-249.113,59.303],[-249.113,26.187]],"c":false}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[15.1,26.187],[15.1,59.303],[-249.113,59.303],[-249.113,26.187]],"c":false}]}],"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.187],[-271.669,26.187],[-277.129,0]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[-116.885,0],[-116.889,26.187],[-271.669,26.187],[-277.129,0]],"c":true}]},{"t":180,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-117.01,-79.306],[43.109,0],[37.648,26.187],[-271.669,26.187],[-277.129,0]],"c":true}]}],"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[367.01,169.94],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,-14.739],[-14.739,0],[0,14.739],[14.739,0]],"o":[[0,14.739],[14.739,0],[0,-14.739],[-14.739,0]],"v":[[-26.687,0],[0,26.687],[26.687,0],[0,-26.687]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[{"i":[[0,-10.29],[-10.29,0],[0,10.29],[10.29,0]],"o":[[0,10.29],[10.29,0],[0,-10.29],[-10.29,0]],"v":[[-18.631,0],[0,18.631],[18.631,0],[0,-18.631]],"c":true}]},{"t":180,"s":[{"i":[[0,-14.739],[-14.739,0],[0,14.739],[14.739,0]],"o":[[0,14.739],[14.739,0],[0,-14.739],[-14.739,0]],"v":[[-26.687,0],[0,26.687],[26.687,0],[0,-26.687]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[250,147.631],"to":[-8.667,0],"ti":[0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[198,147.631],"to":[0,0],"ti":[-8.667,0]},{"t":180,"s":[250,147.631]}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":90,"s":[30]},{"t":180,"s":[0]}],"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"outline 12","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 4","tt":2,"tp":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"mask-1","td":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"outline","tt":2,"tp":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.4},"o":{"x":0.333,"y":0.333},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":0.4},"o":{"x":0.6,"y":0.6},"t":90,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"mask-line-1","td":1,"refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"Columns-2","tt":2,"tp":8,"refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"mask-line-2","td":1,"refId":"comp_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"columns-3","tt":2,"tp":10,"refId":"comp_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0}]},{"id":"comp_2","nm":"mask-1","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"mask-line-1","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.4},"o":{"x":0.333,"y":0.333},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":0.4},"o":{"x":0.6,"y":0.6},"t":90,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_4","nm":"Columns-2","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 13","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 7","tt":2,"tp":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[215.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"mask-3","td":1,"refId":"comp_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 6","tt":2,"tp":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[186.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_5","nm":"mask-3","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[215.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_6","nm":"mask-line-2","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 19","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[173.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 18","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[144.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 17","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.4},"o":{"x":0.333,"y":0.333},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":0.4},"o":{"x":0.6,"y":0.6},"t":90,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"outline 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[244.268,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[215.253,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[186.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.032258063555,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_7","nm":"columns-3","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[313.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"mask","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[313.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"outline 10","tt":2,"tp":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[284.753,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"mask","td":1,"refId":"comp_8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"outline 9","tt":2,"tp":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[115.239,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[255.739,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[115.239,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[150.239,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-167.186,-132.286],[-178.036,-106.857],[-221.02,-106.857],[-231.87,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-231.87,12.714],[-221.02,-12.714],[-178.036,-12.714],[-167.186,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-178.036,-12.714],[-221.02,-12.714],[-221.02,-106.857],[-178.036,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18.06,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]},{"id":"comp_8","nm":"mask","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"mask 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[314.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[313.768,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[314.768,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[349.768,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-32.342,12.714],[-21.492,-12.714],[21.492,-12.714],[32.342,12.714]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[32.342,-132.286],[21.492,-106.857],[-21.492,-106.857],[-32.342,-132.286]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.492,-12.714],[-21.492,-12.714],[-21.492,-106.857],[21.492,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[215,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.6,"y":0},"t":90,"s":[284.753,267.243,0],"to":[0,0,0],"ti":[0,0,0]},{"t":180,"s":[215,267.243,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250,302.243,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-67.425,-132.286],[-78.275,-106.857],[-121.26,-106.857],[-132.11,-132.286]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-132.11,12.714],[-121.26,-12.714],[-78.275,-12.714],[-67.425,12.714]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-78.275,-12.714],[-121.26,-12.714],[-121.26,-106.857],[-78.275,-106.857]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-1945-court').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-1945-court').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[349.768,362.029],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@eNFtiauHQXSOqu227cRFCQ","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@eNFtiauHQXSOqu227cRFCQ-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":281,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-pinch","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":190,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-pinch","dr":180}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/certificate.json b/frontend/public/lotties/certificate.json new file mode 100644 index 000000000..d634f9446 --- /dev/null +++ b/frontend/public/lotties/certificate.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":89,"w":430,"h":430,"nm":"966-privacy-policy-outline","ddd":0,"assets":[{"id":"comp_1","nm":"Content-28","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"outline 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[17]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[-15]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[4]},{"t":50,"s":[0]}],"ix":10},"p":{"a":0,"k":[215,214.76,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.161,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[0,0],[0.398,-0.317],[0,0],[0,0],[0.118,0.495],[0,0],[-0.308,0.344]],"o":[[0,0],[-0.118,0.495],[0,0],[0,0],[-0.398,-0.317],[0,0],[0,0],[0.308,0.344]],"v":[[2,-1.535],[1.71,0.268],[0.912,1.521],[0,2.236],[-0.912,1.521],[-1.71,0.268],[-2,-1.535],[0,-2.236]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":19,"s":[{"i":[[0,0],[0,0],[7.971,-6.344],[0,0],[0,0],[2.369,9.908],[0,0],[-6.168,6.878]],"o":[[0,0],[-2.369,9.908],[0,0],[0,0],[-7.971,-6.344],[0,0],[0,0],[6.169,6.878]],"v":[[40.037,-30.733],[34.222,5.361],[18.265,30.444],[0,44.76],[-18.265,30.444],[-34.222,5.361],[-40.037,-30.733],[0,-44.76]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":32,"s":[{"i":[[0,0],[0,0],[6.19,-4.927],[0,0],[0,0],[1.84,7.694],[0,0],[-4.79,5.341]],"o":[[0,0],[-1.84,7.694],[0,0],[0,0],[-6.19,-4.927],[0,0],[0,0],[4.79,5.341]],"v":[[31.092,-23.867],[26.577,4.163],[14.185,23.642],[0,34.76],[-14.185,23.642],[-26.577,4.163],[-31.092,-23.867],[0,-34.76]],"c":true}]},{"t":44,"s":[{"i":[[0,0],[0,0],[7.128,-5.674],[0,0],[0,0],[2.119,8.861],[0,0],[-5.516,6.151]],"o":[[0,0],[-2.119,8.861],[0,0],[0,0],[-7.128,-5.674],[0,0],[0,0],[5.517,6.151]],"v":[[35.806,-27.485],[30.606,4.795],[16.335,27.226],[0,40.03],[-16.335,27.226],[-30.606,4.795],[-35.806,-27.485],[0,-40.03]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"outline","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,321.746,0],"ix":2,"l":2},"a":{"a":0,"k":[135,291.746,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-40.03,0],[40.03,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":13,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[94.97,318.433],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-26.687,0],[26.687,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.21],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":17,"s":[0]},{"t":46,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[188.373,318.433],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-80.06,0],[80.06,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.21],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":36,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[135,265.06],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0}]},{"id":"comp_3","nm":"hover-swipe","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Page-corner","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.001,249.76,0],"ix":2,"l":2},"a":{"a":0,"k":[250.001,249.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.22,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[{"i":[[0,0],[-49.694,-50.431],[0,0]],"o":[[0,0],[50.313,51.06],[0,0]],"v":[[-53.373,-53.373],[-0.373,-0.627],[53.373,53.373]],"c":false}]},{"t":89,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[330.06,116.567],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Page","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.243],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.326],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[9]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":47,"s":[-7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":70,"s":[5]},{"t":89,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.243,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[317.001,368.76,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.326,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[351.001,381.76,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[291.751,356.51,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":65,"s":[321.001,369.26,0],"to":[0,0,0],"ti":[0,0,0]},{"t":80,"s":[317.001,368.76,0]}],"ix":2,"l":2},"a":{"a":0,"k":[352.001,403.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-53.373,-53.373],[-53.373,53.373],[53.373,53.373]],"c":false}]},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-213.237,-53.373],[-213.237,319.57],[53.373,319.57]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[330.06,116.567],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":1,"k":[{"t":20,"s":[100],"h":1},{"t":38,"s":[0],"h":1}],"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[-133.43,-186.57],[-133.43,186.57],[133.43,186.57],[133.43,-79.82]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('966-privacy-policy-outline').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('966-privacy-policy-outline').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250,249.76],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":844,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"mask","parent":2,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.001,249.76,0],"ix":2,"l":2},"a":{"a":0,"k":[250.001,249.76,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[26.75,-186.57],[26.75,-79.76],[133.43,-79.76],[133.43,-79.82]],"c":true}]},{"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[26.69,-186.57],[-133.43,-186.57],[-133.43,186.57],[133.43,186.57],[133.43,-79.82]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250,249.76],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":51,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Content-28","parent":2,"tt":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":51,"st":-50,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Content-28","parent":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":39,"op":883,"st":39,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@C7/bkxIlQrGojTEoYN8oxw","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@C7/bkxIlQrGojTEoYN8oxw-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":375,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"hover-swipe","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":99,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-swipe","dr":89}]} \ No newline at end of file diff --git a/frontend/public/lotties/server.json b/frontend/public/lotties/server.json new file mode 100644 index 000000000..537e6bfe0 --- /dev/null +++ b/frontend/public/lotties/server.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":430,"h":430,"nm":"wired-outline-57-server","ddd":0,"assets":[{"id":"comp_1","nm":"hover-pinch","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Rectangle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,285.471,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-11.046],[0,0],[-11.046,0],[0,0],[0,11.046],[0,0],[11.046,0]],"o":[[-11.046,0],[0,0],[0,11.046],[0,0],[11.046,0],[0,0],[0,-11.046],[0,0]],"v":[[-165,-45.685],[-185,-25.685],[-185,25.685],[-165,45.685],[165,45.685],[185,25.685],[185,-25.685],[165,-45.685]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":1,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector 2","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":0,"s":[-25.74,-14.872,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[-25.74,-14.872,0]}],"ix":2,"l":2},"a":{"a":0,"k":[106.014,-14.875,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[106.014,-14.873],[286.263,-14.779]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.578],"y":[1]},"o":{"x":[0.182],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.703],"y":[1]},"o":{"x":[0.344],"y":[0]},"t":9,"s":[37]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":44,"s":[44]},{"t":56,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":24,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":0,"ix":1}},{"n":"g","nm":"gap","v":{"a":0,"k":30,"ix":2}},{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":7}}],"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[106.014,-14.873],[286.263,-14.779]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.573],"y":[1]},"o":{"x":[0.187],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.704],"y":[1]},"o":{"x":[0.337],"y":[0]},"t":17,"s":[6]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":34,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":48,"s":[60]},{"t":60,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":24,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":0,"ix":1}},{"n":"g","nm":"gap","v":{"a":0,"k":30,"ix":2}},{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":7}}],"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,30],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Vector","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-131.754,0.002,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[17.5,0],[0.192,-17.499],[0,-17.5],[-17.5,0],[0,17.5],[0.176,17.499]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[72.75,-0.017],[55.442,-17.516],[0,-17.5],[-17.5,0],[0,17.5],[55.426,17.482]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":30,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[72.875,-0.027],[55.567,-17.526],[55.375,-17.527],[37.875,-0.027],[55.375,17.473],[55.551,17.473]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[72.75,-0.017],[55.442,-17.516],[0,-17.5],[-17.5,0],[0,17.5],[55.426,17.482]],"c":true}]},{"t":60,"s":[{"i":[[0,9.625],[9.595,0.102],[0.064,0],[0.117,-9.683],[-9.683,0],[-0.059,0.001]],"o":[[0,-9.619],[-0.064,-0.001],[-9.567,0],[0,9.683],[0.059,0],[9.602,-0.094]],"v":[[17.5,0],[0.192,-17.499],[0,-17.5],[-17.5,0],[0,17.5],[0.176,17.499]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('secondary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".secondary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"secondary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215.001,176.112,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[8.478,0],[0,0],[2.827,-7.987],[0,0]],"o":[[0,0],[-2.823,-7.994],[0,0],[-8.473,0],[0,0],[0,0]],"v":[[183.952,77.268],[134.083,-63.929],[115.224,-77.268],[-115.115,-77.268],[-133.969,-63.942],[-183.952,77.268]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('wired-outline-57-server').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5,"x":"var $bm_rt;\n$bm_rt = $bm_mul($bm_div(value, 3), comp('wired-outline-57-server').layer('control').effect('stroke')('Menu'));"},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1800,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"stroke","np":3,"mn":"Pseudo/@@NH5Ou6jMSumHdvYySdCPdw","ix":1,"en":1,"ef":[{"ty":7,"nm":"Menu","mn":"Pseudo/@@NH5Ou6jMSumHdvYySdCPdw-0001","ix":1,"v":{"a":0,"k":3,"ix":1}}]},{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":2,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"secondary","np":3,"mn":"ADBE Color Control","ix":3,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-pinch","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[215,215,0],"ix":2,"l":2},"a":{"a":0,"k":[215,215,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":430,"h":430,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-pinch","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 656d9466b..7c92988a1 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -75,6 +75,14 @@ export enum ProjectPermissionGroupActions { GrantPrivileges = "grant-privileges" } +export enum ProjectPermissionSshHostActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueHostCert = "issue-host-cert" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -148,6 +156,7 @@ export enum ProjectPermissionSub { SshCertificateAuthorities = "ssh-certificate-authorities", SshCertificateTemplates = "ssh-certificate-templates", SshCertificates = "ssh-certificates", + SshHosts = "ssh-hosts", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -244,6 +253,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 8311dbf2d..52d6dceb2 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -41,6 +41,7 @@ export * from "./serverDetails"; export * from "./serviceTokens"; export * from "./sshCa"; export * from "./sshCertificateTemplates"; +export * from "./sshHost"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/sshCa/constants.tsx b/frontend/src/hooks/api/sshCa/constants.tsx index 2742a7bfa..05380a239 100644 --- a/frontend/src/hooks/api/sshCa/constants.tsx +++ b/frontend/src/hooks/api/sshCa/constants.tsx @@ -12,3 +12,47 @@ export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { [SshCertType.USER]: "User", [SshCertType.HOST]: "Host" }; + +export enum SshCaKeySource { + INTERNAL = "internal", + EXTERNAL = "external" +} + +export enum SshCertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1", + ED25519 = "ED25519" +} + +export const sshCertKeyAlgorithmToNameMap: { [K in SshCertKeyAlgorithm]: string } = { + [SshCertKeyAlgorithm.RSA_2048]: "RSA 2048", + [SshCertKeyAlgorithm.RSA_4096]: "RSA 4096", + [SshCertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", + [SshCertKeyAlgorithm.ECDSA_P384]: "ECDSA P384", + [SshCertKeyAlgorithm.ED25519]: "ED25519" +}; + +export const sshCertKeyAlgorithms = [ + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.RSA_2048], + value: SshCertKeyAlgorithm.RSA_2048 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.RSA_4096], + value: SshCertKeyAlgorithm.RSA_4096 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.ECDSA_P256], + value: SshCertKeyAlgorithm.ECDSA_P256 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.ECDSA_P384], + value: SshCertKeyAlgorithm.ECDSA_P384 + }, + { + label: sshCertKeyAlgorithmToNameMap[SshCertKeyAlgorithm.ED25519], + value: SshCertKeyAlgorithm.ED25519 + } +]; diff --git a/frontend/src/hooks/api/sshCa/types.ts b/frontend/src/hooks/api/sshCa/types.ts index 6e5f02c4d..f14533290 100644 --- a/frontend/src/hooks/api/sshCa/types.ts +++ b/frontend/src/hooks/api/sshCa/types.ts @@ -1,5 +1,4 @@ -import { CertKeyAlgorithm } from "../certificates/enums"; -import { SshCaStatus, SshCertType } from "./constants"; +import { SshCaKeySource, SshCaStatus, SshCertKeyAlgorithm, SshCertType } from "./constants"; export type TSshCertificate = { id: string; @@ -18,17 +17,28 @@ export type TSshCertificateAuthority = { projectId: string; status: SshCaStatus; friendlyName: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; + keySource: SshCaKeySource; createdAt: string; updatedAt: string; publicKey: string; }; -export type TCreateSshCaDTO = { - projectId: string; - friendlyName?: string; - keyAlgorithm: CertKeyAlgorithm; -}; +export type TCreateSshCaDTO = + | { + projectId: string; + friendlyName?: string; + keySource: SshCaKeySource.INTERNAL; + keyAlgorithm: SshCertKeyAlgorithm; + } + | { + projectId: string; + friendlyName?: string; + keySource: SshCaKeySource.EXTERNAL; + keyAlgorithm: SshCertKeyAlgorithm; + publicKey: string; + privateKey: string; + }; export type TUpdateSshCaDTO = { caId: string; @@ -58,7 +68,7 @@ export type TSignSshKeyResponse = { export type TIssueSshCredsDTO = { projectId: string; certificateTemplateId: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; certType: SshCertType; principals: string[]; ttl?: string; @@ -70,5 +80,5 @@ export type TIssueSshCredsResponse = { signedKey: string; privateKey: string; publicKey: string; - keyAlgorithm: CertKeyAlgorithm; + keyAlgorithm: SshCertKeyAlgorithm; }; diff --git a/frontend/src/hooks/api/sshHost/index.tsx b/frontend/src/hooks/api/sshHost/index.tsx new file mode 100644 index 000000000..a4e4da4e1 --- /dev/null +++ b/frontend/src/hooks/api/sshHost/index.tsx @@ -0,0 +1,2 @@ +export { useCreateSshHost, useDeleteSshHost, useUpdateSshHost } from "./mutations"; +export { fetchSshHostUserCaPublicKey, useGetSshHostById } from "./queries"; diff --git a/frontend/src/hooks/api/sshHost/mutations.tsx b/frontend/src/hooks/api/sshHost/mutations.tsx new file mode 100644 index 000000000..f6b831f3e --- /dev/null +++ b/frontend/src/hooks/api/sshHost/mutations.tsx @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { TCreateSshHostDTO, TDeleteSshHostDTO, TSshHost, TUpdateSshHostDTO } from "./types"; + +export const useCreateSshHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: host } = await apiRequest.post("/api/v1/ssh/hosts", body); + return host; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) }); + } + }); +}; + +export const useUpdateSshHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostId, ...body }) => { + const { data: host } = await apiRequest.patch(`/api/v1/ssh/hosts/${sshHostId}`, body); + return host; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) }); + } + }); +}; + +export const useDeleteSshHost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostId }) => { + const { data: host } = await apiRequest.delete(`/api/v1/ssh/hosts/${sshHostId}`); + return host; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/sshHost/queries.tsx b/frontend/src/hooks/api/sshHost/queries.tsx new file mode 100644 index 000000000..33974e0de --- /dev/null +++ b/frontend/src/hooks/api/sshHost/queries.tsx @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshHost } from "./types"; + +export const sshHostKeys = { + getSshHostById: (sshHostId: string) => [{ sshHostId }, "ssh-host"], + getSshHostUserCaPublicKey: (sshHostId: string) => [{ sshHostId }, "ssh-host-user-ca-public-key"] +}; + +export const useGetSshHostById = (sshHostId: string) => { + return useQuery({ + queryKey: sshHostKeys.getSshHostById(sshHostId), + queryFn: async () => { + const { data: sshHost } = await apiRequest.get(`/api/v1/ssh/hosts/${sshHostId}`); + return sshHost; + }, + enabled: Boolean(sshHostId) + }); +}; + +export const fetchSshHostUserCaPublicKey = async (sshHostId: string): Promise => { + const { data } = await apiRequest.get( + `/api/v1/ssh/hosts/${sshHostId}/user-ca-public-key` + ); + return data; +}; diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts new file mode 100644 index 000000000..4bb61008c --- /dev/null +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -0,0 +1,43 @@ +export type TSshHost = { + id: string; + projectId: string; + hostname: string; + userCertTtl: string; + hostCertTtl: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TCreateSshHostDTO = { + projectId: string; + hostname: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TUpdateSshHostDTO = { + sshHostId: string; + hostname?: string; + userCertTtl?: string; + hostCertTtl?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TDeleteSshHostDTO = { + sshHostId: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 3f5209aca..c0f5f027d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -36,6 +36,7 @@ export { useListWorkspaceSshCas, useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, + useListWorkspaceSshHosts, useNameWorkspaceSecrets, useSearchProjects, useToggleAutoCapitalization, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ae832520e..7d94972ea 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -17,6 +17,7 @@ import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types"; import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; +import { TSshHost } from "../sshHost/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -827,6 +828,19 @@ export const useListWorkspaceSshCas = (projectId: string) => { }); }; +export const useListWorkspaceSshHosts = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId), + queryFn: async () => { + const { + data: { hosts } + } = await apiRequest.get<{ hosts: TSshHost[] }>(`/api/v2/workspace/${projectId}/ssh-hosts`); + return hosts; + }, + enabled: Boolean(projectId) + }); +}; + export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 7ef482a20..539ed2ac7 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -58,6 +58,7 @@ export const workspaceKeys = { getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, allWorkspaceSshCertificates: (projectId: string) => [{ projectId }, "workspace-ssh-certificates"] as const, + getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const, specificWorkspaceSshCertificates: ({ offset, limit, diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index fda82af91..8b0d88ad5 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -134,18 +134,48 @@ export const ProjectLayout = () => { )} {isSSH && ( - - {({ isActive }) => ( - - Overview - - )} - + <> + + {({ isActive }) => ( + + Hosts + + )} + + {/* + {({ isActive }) => ( + + Certificates + + )} + */} + {/* + {({ isActive }) => ( + + Certificate Authorities + + )} + */} + )} {isSecretManager && ( { return "Manage your PKI infrastructure and issue digital certificates for services, applications, and devices."; if (type === ProjectType.KMS) return "Centralize the management of keys for cryptographic operations, such as encryption and decryption."; - return "Generate SSH credentials to provide secure and centralized SSH access control for your infrastructure."; + return "Infisical SSH lets you issue SSH credentials to users for short-lived, secure SSH access to infrastructure."; }; type Props = { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 2d77aac83..7d2210f53 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -19,6 +19,7 @@ import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretSyncActions, + ProjectPermissionSshHostActions, TPermissionCondition, TPermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; @@ -108,6 +109,14 @@ const GroupPolicyActionSchema = z.object({ [ProjectPermissionGroupActions.GrantPrivileges]: z.boolean().optional() }); +const SshHostPolicyActionSchema = z.object({ + [ProjectPermissionSshHostActions.Read]: z.boolean().optional(), + [ProjectPermissionSshHostActions.Create]: z.boolean().optional(), + [ProjectPermissionSshHostActions.Edit]: z.boolean().optional(), + [ProjectPermissionSshHostActions.Delete]: z.boolean().optional(), + [ProjectPermissionSshHostActions.IssueHostCert]: z.boolean().optional() +}); + const SecretRollbackPolicyActionSchema = z.object({ read: z.boolean().optional(), create: z.boolean().optional() @@ -215,6 +224,12 @@ export const projectRoleFormSchema = z.object({ ), [ProjectPermissionSub.SshCertificates]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SshCertificateTemplates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SshHosts]: SshHostPolicyActionSchema.extend({ + inverted: z.boolean().optional(), + conditions: ConditionSchema + }) + .array() + .default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), @@ -241,6 +256,7 @@ type TConditionalFields = | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports | ProjectPermissionSub.DynamicSecrets + | ProjectPermissionSub.SshHosts | ProjectPermissionSub.SecretRotation | ProjectPermissionSub.Identity; @@ -251,8 +267,9 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.DynamicSecrets || subject === ProjectPermissionSub.SecretImports || subject === ProjectPermissionSub.SecretFolders || - subject === ProjectPermissionSub.SecretRotation || - subject === ProjectPermissionSub.Identity; + subject === ProjectPermissionSub.Identity || + subject === ProjectPermissionSub.SshHosts || + subject === ProjectPermissionSub.SecretRotation; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -308,7 +325,10 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Tags, ProjectPermissionSub.SecretRotation, - ProjectPermissionSub.Kms + ProjectPermissionSub.Kms, + ProjectPermissionSub.SshCertificateTemplates, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -577,7 +597,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canRemoveSecrets) formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true; } + + if (subject === ProjectPermissionSub.SshHosts) { + if (!formVal[subject]) formVal[subject] = []; + + formVal[subject]!.push({ + [ProjectPermissionSshHostActions.Edit]: action.includes( + ProjectPermissionSshHostActions.Edit + ), + [ProjectPermissionSshHostActions.Delete]: action.includes( + ProjectPermissionSshHostActions.Delete + ), + [ProjectPermissionSshHostActions.Create]: action.includes( + ProjectPermissionSshHostActions.Create + ), + [ProjectPermissionSshHostActions.Read]: action.includes( + ProjectPermissionSshHostActions.Read + ), + [ProjectPermissionSshHostActions.IssueHostCert]: action.includes( + ProjectPermissionSshHostActions.IssueHostCert + ), + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], + inverted + }); + } }); + return formVal; }; @@ -901,6 +946,16 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Remove", value: "delete" } ] }, + [ProjectPermissionSub.SshHosts]: { + title: "SSH Hosts", + actions: [ + { label: "Read", value: ProjectPermissionSshHostActions.Read }, + { label: "Create", value: ProjectPermissionSshHostActions.Create }, + { label: "Modify", value: ProjectPermissionSshHostActions.Edit }, + { label: "Remove", value: ProjectPermissionSshHostActions.Delete }, + { label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert } + ] + }, [ProjectPermissionSub.PkiCollections]: { title: "PKI Collections", actions: [ diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index d44927edf..5b2dc65f2 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -19,6 +19,7 @@ import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { evaluatePermissionsAbility } from "@app/helpers/permissions"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies"; @@ -33,6 +34,7 @@ import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { SecretPermissionConditions } from "./SecretPermissionConditions"; +import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; type Props = { roleSlug: string; @@ -51,6 +53,10 @@ export const renderConditionalComponents = ( return ; } + if (subject === ProjectPermissionSub.SshHosts) { + return ; + } + return ; } @@ -134,7 +140,9 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { return (
- + {currentWorkspace.type === ProjectType.SecretManager && ( + + )}
{ + const { + control, + watch, + formState: { errors } + } = useFormContext(); + + const permissionSubject = ProjectPermissionSub.SshHosts; + const items = useFieldArray({ + control, + name: `permissions.${permissionSubject}.${position}.conditions` + }); + + return ( +
+

Conditions

+

+ Conditions determine when a policy will be applied (always if no conditions are present). +

+

+ All conditions must evaluate to true for the policy to take effect. +

+
+ {items.fields.map((el, index) => { + const condition = + (watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as { + lhs: string; + rhs: string; + operator: string; + }) || {}; + + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> + + + +
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message} +
+ )} +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/ssh/OverviewPage/OverviewPage.tsx b/frontend/src/pages/ssh/OverviewPage/OverviewPage.tsx deleted file mode 100644 index 9123bf675..000000000 --- a/frontend/src/pages/ssh/OverviewPage/OverviewPage.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; - -import { SshCaSection, SshCertificatesSection } from "./components"; - -enum TabSections { - SshCa = "ssh-certificate-authorities", - SshCertificates = "ssh-certificates" -} - -export const OverviewPage = () => { - const { t } = useTranslation(); - return ( - <> - - {t("common.head-title", { title: "Certificates" })} - -
-
-
- - - - SSH Certificates - Certificate Authorities - - - - - - - - - - - - - - - - -
-
-
- - ); -}; diff --git a/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx b/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx deleted file mode 100644 index 4ddbacaba..000000000 --- a/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate } from "@tanstack/react-router"; -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 navigate = useNavigate(); - 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 - }); - - navigate({ - to: `/${ProjectType.SSH}/$projectId/ca/$caId` as const, - params: { - projectId, - caId: 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/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx b/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx index 926505230..3d6e8e00a 100644 --- a/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx +++ b/frontend/src/pages/ssh/SshCaByIDPage/SshCaByIDPage.tsx @@ -20,7 +20,7 @@ import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; import { usePopUp } from "@app/hooks/usePopUp"; -import { SshCaModal } from "../OverviewPage/components/SshCaModal"; +import { SshCaModal } from "../SshCasPage/components/SshCaModal"; import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components"; const Page = () => { diff --git a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCaDetailsSection.tsx b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCaDetailsSection.tsx index 9151ebec5..b2768dfe9 100644 --- a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCaDetailsSection.tsx +++ b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCaDetailsSection.tsx @@ -8,7 +8,7 @@ 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 { sshCertKeyAlgorithmToNameMap } from "@app/hooks/api/sshCa/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -93,7 +93,9 @@ export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

Key Algorithm

-

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+

+ {sshCertKeyAlgorithmToNameMap[ca.keyAlgorithm]} +

Public Key

diff --git a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateModal.tsx b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateModal.tsx index b50e01acb..5c4673931 100644 --- a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateModal.tsx +++ b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateModal.tsx @@ -22,9 +22,11 @@ import { 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/sshCa/constants"; +import { + SshCertKeyAlgorithm, + sshCertKeyAlgorithms, + SshCertType +} from "@app/hooks/api/sshCa/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { SshCertificateContent } from "./SshCertificateContent"; @@ -33,10 +35,11 @@ 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 + SshCertKeyAlgorithm.RSA_2048, + SshCertKeyAlgorithm.RSA_4096, + SshCertKeyAlgorithm.ECDSA_P256, + SshCertKeyAlgorithm.ECDSA_P384, + SshCertKeyAlgorithm.ED25519 ]), certType: z.nativeEnum(SshCertType), principals: z.string(), @@ -95,7 +98,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } = useForm({ resolver: zodResolver(schema), defaultValues: { - keyAlgorithm: CertKeyAlgorithm.RSA_2048, + keyAlgorithm: SshCertKeyAlgorithm.ED25519, certType: SshCertType.USER } }); @@ -282,7 +285,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { ( { onValueChange={(e) => onChange(e)} className="w-full" > - {certKeyAlgorithms.map(({ label, value }) => ( + {sshCertKeyAlgorithms.map(({ label, value }) => ( {label} diff --git a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx index 15a8d8c78..9790df292 100644 --- a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx @@ -60,8 +60,8 @@ const schema = z 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", + .refine((data) => ms(data.maxTTL) >= ms(data.ttl), { + message: "Max TLL must be greater than or equal to TTL", path: ["maxTTL"] }); diff --git a/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx b/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx new file mode 100644 index 000000000..669e26404 --- /dev/null +++ b/frontend/src/pages/ssh/SshCasPage/SshCasPage.tsx @@ -0,0 +1,28 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { PageHeader } from "@app/components/v2"; + +import { SshCaSection } from "./components"; + +export const SshCasPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "SSH" })} + +
+
+
+ + +
+
+
+ + ); +}; diff --git a/frontend/src/pages/ssh/SshCasPage/components/SshCaModal.tsx b/frontend/src/pages/ssh/SshCasPage/components/SshCaModal.tsx new file mode 100644 index 000000000..1d9fdb8cf --- /dev/null +++ b/frontend/src/pages/ssh/SshCasPage/components/SshCaModal.tsx @@ -0,0 +1,297 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api"; +import { + SshCaKeySource, + SshCertKeyAlgorithm, + sshCertKeyAlgorithms +} from "@app/hooks/api/sshCa/constants"; +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 sshCaKeySources = [ + { label: "Internal", value: SshCaKeySource.INTERNAL }, + { label: "External", value: SshCaKeySource.EXTERNAL } +]; + +const schema = z + .object({ + friendlyName: z.string(), + keySource: z.nativeEnum(SshCaKeySource), + publicKey: z.string().optional(), + privateKey: z.string().optional(), + keyAlgorithm: z.enum([ + SshCertKeyAlgorithm.RSA_2048, + SshCertKeyAlgorithm.RSA_4096, + SshCertKeyAlgorithm.ECDSA_P256, + SshCertKeyAlgorithm.ECDSA_P384, + SshCertKeyAlgorithm.ED25519 + ]) + }) + .required(); + +export type FormData = z.infer; + +export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { + const navigate = useNavigate(); + 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 }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + friendlyName: "", + keyAlgorithm: SshCertKeyAlgorithm.ED25519, + keySource: SshCaKeySource.INTERNAL, + publicKey: "", + privateKey: "" + } + }); + + const caKeySource = watch("keySource"); + + useEffect(() => { + if (ca) { + reset({ + friendlyName: ca.friendlyName, + keyAlgorithm: ca.keyAlgorithm, + keySource: ca.keySource, + publicKey: ca.publicKey + }); + } else { + reset({ + friendlyName: "", + keyAlgorithm: SshCertKeyAlgorithm.ED25519, + keySource: SshCaKeySource.INTERNAL, + publicKey: "", + privateKey: "" + }); + } + }, [ca]); + + const onFormSubmit = async ({ + friendlyName, + keySource, + keyAlgorithm, + publicKey, + privateKey + }: FormData) => { + try { + if (!projectId) return; + + if (ca) { + await updateMutateAsync({ + caId: ca.id, + friendlyName + }); + } else { + const { id: newCaId } = await createMutateAsync({ + projectId, + friendlyName, + keySource, + keyAlgorithm, + publicKey, + privateKey + }); + + navigate({ + to: `/${ProjectType.SSH}/$projectId/ca/$caId` as const, + params: { + projectId, + caId: newCaId + } + }); + } + + reset(); + handlePopUpToggle("sshCa", false); + + createNotification({ + text: `Successfully ${ca ? "updated" : "created"} SSH CA`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${ca ? "update" : "create"} SSH CA`, + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("sshCa", isOpen); + }} + > + +
+ {ca && ( + + + + )} + ( + + + + )} + /> + {caKeySource && ( + ( + + + + )} + /> + )} + {caKeySource === SshCaKeySource.INTERNAL && ( + ( + + + + )} + /> + )} + {caKeySource === SshCaKeySource.EXTERNAL && !ca && ( + <> + ( + + + + )} + /> + ( + +