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/migrations/20250410203010_add-comment-to-access-request.ts b/backend/src/db/migrations/20250410203010_add-comment-to-access-request.ts
new file mode 100644
index 000000000..0e0b46fd8
--- /dev/null
+++ b/backend/src/db/migrations/20250410203010_add-comment-to-access-request.ts
@@ -0,0 +1,21 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+
+export async function up(knex: Knex): Promise {
+ const hasCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "note");
+ if (!hasCol) {
+ await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
+ t.string("note").nullable();
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ const hasCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "note");
+ if (hasCol) {
+ await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
+ t.dropColumn("note");
+ });
+ }
+}
diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts
index 0b20202f5..bfe990b3a 100644
--- a/backend/src/db/schemas/access-approval-requests.ts
+++ b/backend/src/db/schemas/access-approval-requests.ts
@@ -17,7 +17,8 @@ export const AccessApprovalRequestsSchema = z.object({
permissions: z.unknown(),
createdAt: z.date(),
updatedAt: z.date(),
- requestedByUserId: z.string().uuid()
+ requestedByUserId: z.string().uuid(),
+ note: z.string().nullable().optional()
});
export type TAccessApprovalRequests = z.infer;
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index 5b78cf86f..8543417cf 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -75,6 +75,7 @@ export * from "./project-memberships";
export * from "./project-roles";
export * from "./project-slack-configs";
export * from "./project-split-backfill-ids";
+export * from "./project-ssh-configs";
export * from "./project-templates";
export * from "./project-user-additional-privilege";
export * from "./project-user-membership-roles";
@@ -125,6 +126,9 @@ export * from "./ssh-certificate-authority-secrets";
export * from "./ssh-certificate-bodies";
export * from "./ssh-certificate-templates";
export * from "./ssh-certificates";
+export * from "./ssh-host-login-user-mappings";
+export * from "./ssh-host-login-users";
+export * from "./ssh-hosts";
export * from "./super-admin";
export * from "./totp-configs";
export * from "./trusted-ips";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index e2a0f153f..95561c14a 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -2,6 +2,9 @@ import { z } from "zod";
export enum TableName {
Users = "users",
+ SshHost = "ssh_hosts",
+ SshHostLoginUser = "ssh_host_login_users",
+ SshHostLoginUserMapping = "ssh_host_login_user_mappings",
SshCertificateAuthority = "ssh_certificate_authorities",
SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets",
SshCertificateTemplate = "ssh_certificate_templates",
@@ -38,6 +41,7 @@ export enum TableName {
SuperAdmin = "super_admin",
RateLimit = "rate_limit",
ApiKey = "api_keys",
+ ProjectSshConfig = "project_ssh_configs",
Project = "projects",
ProjectBot = "project_bots",
Environment = "project_environments",
diff --git a/backend/src/db/schemas/project-ssh-configs.ts b/backend/src/db/schemas/project-ssh-configs.ts
new file mode 100644
index 000000000..d0be89ee3
--- /dev/null
+++ b/backend/src/db/schemas/project-ssh-configs.ts
@@ -0,0 +1,21 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const ProjectSshConfigsSchema = z.object({
+ id: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ projectId: z.string(),
+ defaultUserSshCaId: z.string().uuid().nullable().optional(),
+ defaultHostSshCaId: z.string().uuid().nullable().optional()
+});
+
+export type TProjectSshConfigs = z.infer;
+export type TProjectSshConfigsInsert = Omit, TImmutableDBKeys>;
+export type TProjectSshConfigsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts
index 81e789288..75603406f 100644
--- a/backend/src/db/schemas/ssh-certificate-authorities.ts
+++ b/backend/src/db/schemas/ssh-certificate-authorities.ts
@@ -14,7 +14,8 @@ export const SshCertificateAuthoritiesSchema = z.object({
projectId: z.string(),
status: z.string(),
friendlyName: z.string(),
- keyAlgorithm: z.string()
+ keyAlgorithm: z.string(),
+ keySource: z.string()
});
export type TSshCertificateAuthorities = z.infer;
diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts
index 6fe5bc261..1bfd1fe6e 100644
--- a/backend/src/db/schemas/ssh-certificates.ts
+++ b/backend/src/db/schemas/ssh-certificates.ts
@@ -11,14 +11,15 @@ export const SshCertificatesSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
- sshCaId: z.string().uuid(),
+ sshCaId: z.string().uuid().nullable().optional(),
sshCertificateTemplateId: z.string().uuid().nullable().optional(),
serialNumber: z.string(),
certType: z.string(),
principals: z.string().array(),
keyId: z.string(),
notBefore: z.date(),
- notAfter: z.date()
+ notAfter: z.date(),
+ sshHostId: z.string().uuid().nullable().optional()
});
export type TSshCertificates = z.infer;
diff --git a/backend/src/db/schemas/ssh-host-login-user-mappings.ts b/backend/src/db/schemas/ssh-host-login-user-mappings.ts
new file mode 100644
index 000000000..6edb0d5a3
--- /dev/null
+++ b/backend/src/db/schemas/ssh-host-login-user-mappings.ts
@@ -0,0 +1,22 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SshHostLoginUserMappingsSchema = z.object({
+ id: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ sshHostLoginUserId: z.string().uuid(),
+ userId: z.string().uuid().nullable().optional()
+});
+
+export type TSshHostLoginUserMappings = z.infer;
+export type TSshHostLoginUserMappingsInsert = Omit, TImmutableDBKeys>;
+export type TSshHostLoginUserMappingsUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts
new file mode 100644
index 000000000..62454d3c9
--- /dev/null
+++ b/backend/src/db/schemas/ssh-host-login-users.ts
@@ -0,0 +1,20 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SshHostLoginUsersSchema = z.object({
+ id: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ sshHostId: z.string().uuid(),
+ loginUser: z.string()
+});
+
+export type TSshHostLoginUsers = z.infer;
+export type TSshHostLoginUsersInsert = Omit, TImmutableDBKeys>;
+export type TSshHostLoginUsersUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/ssh-hosts.ts b/backend/src/db/schemas/ssh-hosts.ts
new file mode 100644
index 000000000..7577e065b
--- /dev/null
+++ b/backend/src/db/schemas/ssh-hosts.ts
@@ -0,0 +1,24 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SshHostsSchema = z.object({
+ id: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ projectId: z.string(),
+ hostname: z.string(),
+ userCertTtl: z.string(),
+ hostCertTtl: z.string(),
+ userSshCaId: z.string().uuid(),
+ hostSshCaId: z.string().uuid()
+});
+
+export type TSshHosts = z.infer;
+export type TSshHostsInsert = Omit, TImmutableDBKeys>;
+export type TSshHostsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts
index 6a6ec3c07..8a7ccfdef 100644
--- a/backend/src/ee/routes/v1/access-approval-request-router.ts
+++ b/backend/src/ee/routes/v1/access-approval-request-router.ts
@@ -22,7 +22,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
body: z.object({
permissions: z.any().array(),
isTemporary: z.boolean(),
- temporaryRange: z.string().optional()
+ temporaryRange: z.string().optional(),
+ note: z.string().max(255).optional()
}),
querystring: z.object({
projectSlug: z.string().trim()
@@ -43,7 +44,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
actorOrgId: req.permission.orgId,
projectSlug: req.query.projectSlug,
temporaryRange: req.body.temporaryRange,
- isTemporary: req.body.isTemporary
+ isTemporary: req.body.isTemporary,
+ note: req.body.note
});
return { approval: request };
}
diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts
index e793c687d..2bf85e9c4 100644
--- a/backend/src/ee/routes/v1/index.ts
+++ b/backend/src/ee/routes/v1/index.ts
@@ -32,6 +32,7 @@ import { registerSnapshotRouter } from "./snapshot-router";
import { registerSshCaRouter } from "./ssh-certificate-authority-router";
import { registerSshCertRouter } from "./ssh-certificate-router";
import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router";
+import { registerSshHostRouter } from "./ssh-host-router";
import { registerTrustedIpRouter } from "./trusted-ip-router";
import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router";
@@ -82,6 +83,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await sshRouter.register(registerSshCaRouter, { prefix: "/ca" });
await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" });
await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" });
+ await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" });
},
{ prefix: "/ssh" }
);
diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts
index ab80888d7..783cb9b72 100644
--- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts
+++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts
@@ -1,14 +1,15 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { normalizeSshPrivateKey } from "@app/ee/services/ssh/ssh-certificate-authority-fns";
import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema";
-import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types";
+import { SshCaKeySource, SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types";
+import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
-import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
export const registerSshCaRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -20,14 +21,34 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Create SSH CA",
- body: z.object({
- projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId),
- friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName),
- keyAlgorithm: z
- .nativeEnum(CertKeyAlgorithm)
- .default(CertKeyAlgorithm.RSA_2048)
- .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm)
- }),
+ body: z
+ .object({
+ projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId),
+ friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName),
+ keyAlgorithm: z
+ .nativeEnum(SshCertKeyAlgorithm)
+ .default(SshCertKeyAlgorithm.ED25519)
+ .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm),
+ publicKey: z.string().trim().optional().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.publicKey),
+ privateKey: z
+ .string()
+ .trim()
+ .optional()
+ .transform((val) => (val ? normalizeSshPrivateKey(val) : undefined))
+ .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.privateKey),
+ keySource: z
+ .nativeEnum(SshCaKeySource)
+ .default(SshCaKeySource.INTERNAL)
+ .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keySource)
+ })
+ .refine((data) => data.keySource === SshCaKeySource.INTERNAL || (!!data.publicKey && !!data.privateKey), {
+ message: "publicKey and privateKey are required when keySource is external",
+ path: ["publicKey"]
+ })
+ .refine((data) => data.keySource === SshCaKeySource.EXTERNAL || !!data.keyAlgorithm, {
+ message: "keyAlgorithm is required when keySource is internal",
+ path: ["keyAlgorithm"]
+ }),
response: {
200: z.object({
ca: sanitizedSshCa.extend({
diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts
index 249a96b50..eb0fc158a 100644
--- a/backend/src/ee/routes/v1/ssh-certificate-router.ts
+++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts
@@ -2,13 +2,13 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
+import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
-import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerSshCertRouter = async (server: FastifyZodProvider) => {
@@ -108,8 +108,8 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => {
.min(1)
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId),
keyAlgorithm: z
- .nativeEnum(CertKeyAlgorithm)
- .default(CertKeyAlgorithm.RSA_2048)
+ .nativeEnum(SshCertKeyAlgorithm)
+ .default(SshCertKeyAlgorithm.ED25519)
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm),
certType: z
.nativeEnum(SshCertType)
@@ -133,7 +133,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => {
privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey),
publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey),
keyAlgorithm: z
- .nativeEnum(CertKeyAlgorithm)
+ .nativeEnum(SshCertKeyAlgorithm)
.describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm)
})
}
diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts
index a85e6b0ca..a7dc55661 100644
--- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts
+++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts
@@ -92,8 +92,8 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro
allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates),
allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds)
})
- .refine((data) => ms(data.maxTTL) > ms(data.ttl), {
- message: "Max TLL must be greater than TTL",
+ .refine((data) => ms(data.maxTTL) >= ms(data.ttl), {
+ message: "Max TLL must be greater than or equal to TTL",
path: ["maxTTL"]
}),
response: {
diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts
new file mode 100644
index 000000000..1dab5dd2f
--- /dev/null
+++ b/backend/src/ee/routes/v1/ssh-host-router.ts
@@ -0,0 +1,444 @@
+import { z } from "zod";
+
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
+import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
+import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators";
+import { SSH_HOSTS } from "@app/lib/api-docs";
+import { ms } from "@app/lib/ms";
+import { publicSshCaLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
+
+export const registerSshHostRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ method: "GET",
+ url: "/",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ response: {
+ 200: z.array(
+ sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ )
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ const hosts = await server.services.sshHost.listSshHosts({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ return hosts;
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:sshHostId",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ params: z.object({
+ sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId)
+ }),
+ response: {
+ 200: sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const host = await server.services.sshHost.getSshHost({
+ sshHostId: req.params.sshHostId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: host.projectId,
+ event: {
+ type: EventType.GET_SSH_HOST,
+ metadata: {
+ sshHostId: host.id,
+ hostname: host.hostname
+ }
+ }
+ });
+
+ return host;
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ description: "Add an SSH Host",
+ body: z.object({
+ projectId: z.string().describe(SSH_HOSTS.CREATE.projectId),
+ hostname: z
+ .string()
+ .min(1)
+ .refine((v) => isValidHostname(v), {
+ message: "Hostname must be a valid hostname"
+ })
+ .describe(SSH_HOSTS.CREATE.hostname),
+ userCertTtl: z
+ .string()
+ .refine((val) => ms(val) > 0, "TTL must be a positive number")
+ .default("8h")
+ .describe(SSH_HOSTS.CREATE.userCertTtl),
+ hostCertTtl: z
+ .string()
+ .refine((val) => ms(val) > 0, "TTL must be a positive number")
+ .default("1y")
+ .describe(SSH_HOSTS.CREATE.hostCertTtl),
+ loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOSTS.CREATE.loginMappings),
+ userSshCaId: z.string().describe(SSH_HOSTS.CREATE.userSshCaId).optional(),
+ hostSshCaId: z.string().describe(SSH_HOSTS.CREATE.hostSshCaId).optional()
+ }),
+ response: {
+ 200: sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const host = await server.services.sshHost.createSshHost({
+ ...req.body,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: host.projectId,
+ event: {
+ type: EventType.CREATE_SSH_HOST,
+ metadata: {
+ sshHostId: host.id,
+ hostname: host.hostname,
+ userCertTtl: host.userCertTtl,
+ hostCertTtl: host.hostCertTtl,
+ loginMappings: host.loginMappings,
+ userSshCaId: host.userSshCaId,
+ hostSshCaId: host.hostSshCaId
+ }
+ }
+ });
+
+ return host;
+ }
+ });
+
+ server.route({
+ method: "PATCH",
+ url: "/:sshHostId",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ description: "Update SSH Host",
+ params: z.object({
+ sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId)
+ }),
+ body: z.object({
+ hostname: z
+ .string()
+ .min(1)
+ .refine((v) => isValidHostname(v), {
+ message: "Hostname must be a valid hostname"
+ })
+ .optional()
+ .describe(SSH_HOSTS.UPDATE.hostname),
+ userCertTtl: z
+ .string()
+ .refine((val) => ms(val) > 0, "TTL must be a positive number")
+ .optional()
+ .describe(SSH_HOSTS.UPDATE.userCertTtl),
+ hostCertTtl: z
+ .string()
+ .refine((val) => ms(val) > 0, "TTL must be a positive number")
+ .optional()
+ .describe(SSH_HOSTS.UPDATE.hostCertTtl),
+ loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOSTS.UPDATE.loginMappings)
+ }),
+ response: {
+ 200: sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ handler: async (req) => {
+ const host = await server.services.sshHost.updateSshHost({
+ sshHostId: req.params.sshHostId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ ...req.body
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: host.projectId,
+ event: {
+ type: EventType.UPDATE_SSH_HOST,
+ metadata: {
+ sshHostId: host.id,
+ hostname: host.hostname,
+ userCertTtl: host.userCertTtl,
+ hostCertTtl: host.hostCertTtl,
+ loginMappings: host.loginMappings,
+ userSshCaId: host.userSshCaId,
+ hostSshCaId: host.hostSshCaId
+ }
+ }
+ });
+
+ return host;
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: "/:sshHostId",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ params: z.object({
+ sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId)
+ }),
+ response: {
+ 200: sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const host = await server.services.sshHost.deleteSshHost({
+ sshHostId: req.params.sshHostId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: host.projectId,
+ event: {
+ type: EventType.DELETE_SSH_HOST,
+ metadata: {
+ sshHostId: host.id,
+ hostname: host.hostname
+ }
+ }
+ });
+
+ return host;
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/:sshHostId/issue-user-cert",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ schema: {
+ description: "Issue SSH certificate for user",
+ params: z.object({
+ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId)
+ }),
+ body: z.object({
+ loginUser: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.loginUser)
+ }),
+ response: {
+ 200: z.object({
+ serialNumber: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.serialNumber),
+ signedKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.signedKey),
+ privateKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.privateKey),
+ publicKey: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.publicKey),
+ keyAlgorithm: z.nativeEnum(SshCertKeyAlgorithm).describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.keyAlgorithm)
+ })
+ }
+ },
+ handler: async (req) => {
+ const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } =
+ await server.services.sshHost.issueSshHostUserCert({
+ sshHostId: req.params.sshHostId,
+ loginUser: req.body.loginUser,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: req.permission.orgId,
+ event: {
+ type: EventType.ISSUE_SSH_HOST_USER_CERT,
+ metadata: {
+ sshHostId: req.params.sshHostId,
+ hostname: host.hostname,
+ loginUser: req.body.loginUser,
+ principals,
+ ttl: host.userCertTtl
+ }
+ }
+ });
+
+ await server.services.telemetry.sendPostHogEvents({
+ event: PostHogEventTypes.IssueSshHostUserCert,
+ distinctId: getTelemetryDistinctId(req),
+ properties: {
+ sshHostId: req.params.sshHostId,
+ hostname: host.hostname,
+ principals,
+ ...req.auditLogInfo
+ }
+ });
+
+ return {
+ serialNumber,
+ signedKey: signedPublicKey,
+ privateKey,
+ publicKey,
+ keyAlgorithm
+ };
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/:sshHostId/issue-host-cert",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ description: "Issue SSH certificate for host",
+ params: z.object({
+ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId)
+ }),
+ body: z.object({
+ publicKey: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.publicKey)
+ }),
+ response: {
+ 200: z.object({
+ serialNumber: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.serialNumber),
+ signedKey: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.signedKey)
+ })
+ }
+ },
+ handler: async (req) => {
+ const { host, principals, serialNumber, signedPublicKey } = await server.services.sshHost.issueSshHostHostCert({
+ sshHostId: req.params.sshHostId,
+ publicKey: req.body.publicKey,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: req.permission.orgId,
+ event: {
+ type: EventType.ISSUE_SSH_HOST_HOST_CERT,
+ metadata: {
+ sshHostId: req.params.sshHostId,
+ hostname: host.hostname,
+ principals,
+ serialNumber,
+ ttl: host.hostCertTtl
+ }
+ }
+ });
+
+ await server.services.telemetry.sendPostHogEvents({
+ event: PostHogEventTypes.IssueSshHostHostCert,
+ distinctId: getTelemetryDistinctId(req),
+ properties: {
+ sshHostId: req.params.sshHostId,
+ hostname: host.hostname,
+ principals,
+ ...req.auditLogInfo
+ }
+ });
+
+ return {
+ serialNumber,
+ signedKey: signedPublicKey
+ };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:sshHostId/user-ca-public-key",
+ config: {
+ rateLimit: publicSshCaLimit
+ },
+ schema: {
+ description: "Get public key of the user SSH CA linked to the host",
+ params: z.object({
+ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId)
+ }),
+ response: {
+ 200: z.string().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.publicKey)
+ }
+ },
+ handler: async (req) => {
+ const publicKey = await server.services.sshHost.getSshHostUserCaPk(req.params.sshHostId);
+ return publicKey;
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:sshHostId/host-ca-public-key",
+ config: {
+ rateLimit: publicSshCaLimit
+ },
+ schema: {
+ description: "Get public key of the host SSH CA linked to the host",
+ params: z.object({
+ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId)
+ }),
+ response: {
+ 200: z.string().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.publicKey)
+ }
+ },
+ handler: async (req) => {
+ const publicKey = await server.services.sshHost.getSshHostHostCaPk(req.params.sshHostId);
+ return publicKey;
+ }
+ });
+};
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts
index 50670cb49..3606b4bdc 100644
--- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts
@@ -94,7 +94,8 @@ export const accessApprovalRequestServiceFactory = ({
actor,
actorOrgId,
actorAuthMethod,
- projectSlug
+ projectSlug,
+ note
}: TCreateAccessApprovalRequestDTO) => {
const cfg = getConfig();
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
@@ -209,7 +210,8 @@ export const accessApprovalRequestServiceFactory = ({
requestedByUserId: actorId,
temporaryRange: temporaryRange || null,
permissions: JSON.stringify(requestedPermissions),
- isTemporary
+ isTemporary,
+ note: note || null
},
tx
);
@@ -232,7 +234,8 @@ export const accessApprovalRequestServiceFactory = ({
secretPath,
environment: envSlug,
permissions: accessTypes,
- approvalUrl
+ approvalUrl,
+ note
}
}
});
@@ -252,7 +255,8 @@ export const accessApprovalRequestServiceFactory = ({
secretPath,
environment: envSlug,
permissions: accessTypes,
- approvalUrl
+ approvalUrl,
+ note
},
template: SmtpTemplates.AccessApprovalRequest
});
diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts
index e11ca58d5..51a5e0ca2 100644
--- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts
+++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts
@@ -24,6 +24,7 @@ export type TCreateAccessApprovalRequestDTO = {
permissions: unknown;
isTemporary: boolean;
temporaryRange?: string;
+ note?: string;
} & Omit;
export type TListApprovalRequestsDTO = {
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index 2ab46b6ad..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 ced81bcd4..8e390d9a3 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/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts
index 8627dbd4b..5d67c30e4 100644
--- a/backend/src/services/app-connection/app-connection-types.ts
+++ b/backend/src/services/app-connection/app-connection-types.ts
@@ -138,8 +138,8 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateHumanitecConnectionCredentialsSchema
| TValidatePostgresConnectionCredentialsSchema
| TValidateMsSqlConnectionCredentialsSchema
- | TValidateTerraformCloudConnectionCredentialsSchema
| TValidateCamundaConnectionCredentialsSchema
+ | TValidateTerraformCloudConnectionCredentialsSchema
| TValidateVercelConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {
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/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts
index f92f96a24..6c84c0e76 100644
--- a/backend/src/services/slack/slack-fns.ts
+++ b/backend/src/services/slack/slack-fns.ts
@@ -87,7 +87,12 @@ View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId
The following permissions are requested: ${payload.permissions.join(", ")}
-View the request and approve or deny it <${payload.approvalUrl}|here>.`;
+View the request and approve or deny it <${payload.approvalUrl}|here>.${
+ payload.note
+ ? `
+User Note: ${payload.note}`
+ : ""
+ }`;
const payloadBlocks = [
{
diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts
index a92ba4e8b..3e8354adf 100644
--- a/backend/src/services/slack/slack-types.ts
+++ b/backend/src/services/slack/slack-types.ts
@@ -76,5 +76,6 @@ export type TSlackNotification =
projectName: string;
permissions: string[];
approvalUrl: string;
+ note?: string;
};
};
diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars
index ef11957a7..6813c1200 100644
--- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars
+++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars
@@ -40,6 +40,9 @@
{{/each}}
+ {{#if note}}
+
User Note: "{{note}}"
+ {{/if}}
View the request and approve or deny it
diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts
index 45510899a..ab90a71d4 100644
--- a/backend/src/services/telemetry/telemetry-types.ts
+++ b/backend/src/services/telemetry/telemetry-types.ts
@@ -18,6 +18,8 @@ export enum PostHogEventTypes {
SecretRequestDeleted = "Secret Request Deleted",
SignSshKey = "Sign SSH Key",
IssueSshCreds = "Issue SSH Credentials",
+ IssueSshHostUserCert = "Issue SSH Host User Certificate",
+ IssueSshHostHostCert = "Issue SSH Host Host Certificate",
SignCert = "Sign PKI Certificate",
IssueCert = "Issue PKI Certificate"
}
@@ -161,6 +163,26 @@ export type TIssueSshCredsEvent = {
};
};
+export type TIssueSshHostUserCertEvent = {
+ event: PostHogEventTypes.IssueSshHostUserCert;
+ properties: {
+ sshHostId: string;
+ hostname: string;
+ principals: string[];
+ userAgent?: string;
+ };
+};
+
+export type TIssueSshHostHostCertEvent = {
+ event: PostHogEventTypes.IssueSshHostHostCert;
+ properties: {
+ sshHostId: string;
+ hostname: string;
+ principals: string[];
+ userAgent?: string;
+ };
+};
+
export type TSignCertificateEvent = {
event: PostHogEventTypes.SignCert;
properties: {
@@ -195,6 +217,8 @@ export type TPostHogEvent = { distinctId: string } & (
| TSecretRequestDeletedEvent
| TSignSshKeyEvent
| TIssueSshCredsEvent
+ | TIssueSshHostUserCertEvent
+ | TIssueSshHostHostCertEvent
| TSignCertificateEvent
| TIssueCertificateEvent
);
diff --git a/cli/go.mod b/cli/go.mod
index 5f3992e17..c713417e2 100644
--- a/cli/go.mod
+++ b/cli/go.mod
@@ -12,7 +12,7 @@ require (
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.8.0
github.com/h2non/filetype v1.1.3
- github.com/infisical/go-sdk v0.5.1
+ github.com/infisical/go-sdk v0.5.8
github.com/infisical/infisical-kmip v0.3.5
github.com/mattn/go-isatty v0.0.20
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a
diff --git a/cli/go.sum b/cli/go.sum
index da221fd6f..68bce9cd3 100644
--- a/cli/go.sum
+++ b/cli/go.sum
@@ -277,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/infisical/go-sdk v0.5.1 h1:bl0D4A6CmvfL8RwEQTcZh39nsxC6q3HSs76/4J8grWY=
-github.com/infisical/go-sdk v0.5.1/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs=
+github.com/infisical/go-sdk v0.5.8 h1:bCetYLp7HWt8DnU9KPh1n8n3z5pjmunkGDB4bA3lEFs=
+github.com/infisical/go-sdk v0.5.8/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs=
github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE=
github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs=
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go
index a3d10fc91..5b2bb37bb 100644
--- a/cli/packages/cmd/ssh.go
+++ b/cli/packages/cmd/ssh.go
@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"os"
+ "os/exec"
"path/filepath"
"strings"
"time"
@@ -17,6 +18,7 @@ import (
"github.com/Infisical/infisical-merge/packages/util"
infisicalSdk "github.com/infisical/go-sdk"
infisicalSdkUtil "github.com/infisical/go-sdk/packages/util"
+ "github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
@@ -48,6 +50,18 @@ var sshSignKeyCmd = &cobra.Command{
Run: signKey,
}
+var sshConnectCmd = &cobra.Command{
+ Use: "connect",
+ Short: "Connect to an SSH host using issued credentials",
+ Run: sshConnect,
+}
+
+var sshAddHostCmd = &cobra.Command{
+ Use: "add-host",
+ Short: "Register a new SSH host with Infisical",
+ Run: sshAddHost,
+}
+
var algoToFileName = map[infisicalSdkUtil.CertKeyAlgorithm]string{
infisicalSdkUtil.RSA2048: "id_rsa_2048",
infisicalSdkUtil.RSA4096: "id_rsa_4096",
@@ -240,7 +254,7 @@ func issueCredentials(cmd *cobra.Command, args []string) {
util.HandleError(err, "Unable to parse addToAgent flag")
}
- if outFilePath == "" && addToAgent == false {
+ if outFilePath == "" && !addToAgent {
util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command")
}
@@ -595,6 +609,380 @@ func signKey(cmd *cobra.Command, args []string) {
fmt.Println("Successfully wrote SSH certificate to:", signedKeyPath)
}
+func sshConnect(cmd *cobra.Command, args []string) {
+ util.RequireLogin()
+ util.RequireLocalWorkspaceFile()
+
+ loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
+ if err != nil {
+ util.HandleError(err, "Unable to authenticate")
+ }
+
+ if loggedInUserDetails.LoginExpired {
+ util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
+ }
+
+ infisicalToken := loggedInUserDetails.UserCredentials.JTWToken
+
+ writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --writeHostCaToFile flag")
+ }
+
+ customHeaders, err := util.GetInfisicalCustomHeadersMap()
+ if err != nil {
+ util.HandleError(err, "Unable to get custom headers")
+ }
+
+ infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
+ SiteUrl: config.INFISICAL_URL,
+ UserAgent: api.USER_AGENT,
+ AutoTokenRefresh: false,
+ CustomHeaders: customHeaders,
+ })
+ infisicalClient.Auth().SetAccessToken(infisicalToken)
+
+ // Fetch SSH Hosts
+ hosts, err := infisicalClient.Ssh().GetSshHosts(infisicalSdk.GetSshHostsOptions{})
+ if err != nil {
+ util.HandleError(err, "Failed to fetch SSH hosts")
+ }
+ if len(hosts) == 0 {
+ util.PrintErrorMessageAndExit("You do not have access to any SSH hosts")
+ }
+
+ // Prompt to select host
+ hostNames := make([]string, len(hosts))
+ for i, h := range hosts {
+ hostNames[i] = h.Hostname
+ }
+
+ hostPrompt := promptui.Select{
+ Label: "Select an SSH Host",
+ Items: hostNames,
+ Size: 10,
+ }
+ hostIdx, _, err := hostPrompt.Run()
+ if err != nil {
+ util.HandleError(err, "Prompt failed")
+ }
+ selectedHost := hosts[hostIdx]
+
+ // Prompt to select login user
+ if len(selectedHost.LoginMappings) == 0 {
+ util.PrintErrorMessageAndExit("No login users available for selected host")
+ }
+
+ loginUsers := make([]string, len(selectedHost.LoginMappings))
+ for i, m := range selectedHost.LoginMappings {
+ loginUsers[i] = m.LoginUser
+ }
+
+ loginPrompt := promptui.Select{
+ Label: "Select Login User",
+ Items: loginUsers,
+ Size: 5,
+ }
+ loginIdx, _, err := loginPrompt.Run()
+ if err != nil {
+ util.HandleError(err, "Prompt failed")
+ }
+ selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser
+
+ // Issue SSH creds for host
+ creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{
+ LoginUser: selectedLoginUser,
+ })
+ if err != nil {
+ util.HandleError(err, "Failed to issue SSH credentials")
+ }
+
+ // Write Host CA public key to known_hosts if enabled
+ if writeHostCaToFile {
+ hostCaPublicKey, err := infisicalClient.Ssh().GetSshHostHostCaPublicKey(selectedHost.ID)
+ if err != nil {
+ util.HandleError(err, "Failed to fetch Host CA public key")
+ }
+
+ // Build @cert-authority line
+ caLine := fmt.Sprintf("@cert-authority %s %s\n", selectedHost.Hostname, strings.TrimSpace(hostCaPublicKey))
+
+ // Determine known_hosts path
+ sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
+ knownHostsPath := filepath.Join(sshDir, "known_hosts")
+
+ // Ensure ~/.ssh exists
+ if _, err := os.Stat(sshDir); os.IsNotExist(err) {
+ if err := os.MkdirAll(sshDir, 0700); err != nil {
+ util.HandleError(err, "Failed to create ~/.ssh directory")
+ }
+ }
+
+ // Check if CA line already exists
+ knownHostsBytes, _ := os.ReadFile(knownHostsPath)
+ if !strings.Contains(string(knownHostsBytes), caLine) {
+ f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
+ if err != nil {
+ util.HandleError(err, "Failed to open known_hosts file")
+ }
+ defer f.Close()
+
+ if _, err := f.WriteString(caLine); err != nil {
+ util.HandleError(err, "Failed to write Host CA to known_hosts")
+ }
+
+ fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath)
+ }
+ }
+
+ // Load credentials into SSH agent
+ err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey)
+ if err != nil {
+ util.HandleError(err, "Failed to add credentials to SSH agent")
+ }
+ fmt.Println("✔ SSH credentials successfully added to agent")
+
+ // Connect to host using system ssh and agent
+ target := fmt.Sprintf("%s@%s", selectedLoginUser, selectedHost.Hostname)
+ fmt.Printf("Connecting to %s...\n", target)
+
+ sshCmd := exec.Command("ssh", target)
+ sshCmd.Stdin = os.Stdin
+ sshCmd.Stdout = os.Stdout
+ sshCmd.Stderr = os.Stderr
+
+ err = sshCmd.Run()
+ if err != nil {
+ util.HandleError(err, "SSH connection failed")
+ }
+}
+
+func sshAddHost(cmd *cobra.Command, args []string) {
+
+ token, err := util.GetInfisicalToken(cmd)
+ if err != nil {
+ util.HandleError(err, "Unable to parse token")
+ }
+
+ var infisicalToken string
+ if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
+ infisicalToken = token.Token
+ } else {
+ util.RequireLogin()
+ util.RequireLocalWorkspaceFile()
+
+ loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
+ if err != nil {
+ util.HandleError(err, "Unable to authenticate")
+ }
+ if loggedInUserDetails.LoginExpired {
+ util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login]")
+ }
+ infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
+ }
+
+ projectId, err := cmd.Flags().GetString("projectId")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --projectId flag")
+ }
+ if projectId == "" {
+ util.PrintErrorMessageAndExit("You must provide --projectId")
+ }
+
+ hostname, err := cmd.Flags().GetString("hostname")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --hostname flag")
+ }
+ if hostname == "" {
+ util.PrintErrorMessageAndExit("You must provide --hostname")
+ }
+
+ writeUserCaToFile, err := cmd.Flags().GetBool("writeUserCaToFile")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --writeUserCaToFile flag")
+ }
+
+ userCaOutFilePath, err := cmd.Flags().GetString("userCaOutFilePath")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --userCaOutFilePath flag")
+ }
+
+ writeHostCertToFile, err := cmd.Flags().GetBool("writeHostCertToFile")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --writeHostCertToFile flag")
+ }
+
+ configureSshd, err := cmd.Flags().GetBool("configureSshd")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --configureSshd flag")
+ }
+
+ forceOverwrite, err := cmd.Flags().GetBool("force")
+ if err != nil {
+ util.HandleError(err, "Unable to parse --force flag")
+ }
+
+ if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) {
+ util.PrintErrorMessageAndExit("--configureSshd requires both --writeUserCaToFile and --writeHostCertToFile to also be set")
+ }
+
+ // Pre-check for file overwrites before proceeding
+ if writeUserCaToFile {
+ if strings.HasPrefix(userCaOutFilePath, "~") {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ util.HandleError(err, "Unable to resolve ~ in userCaOutFilePath")
+ }
+ userCaOutFilePath = strings.Replace(userCaOutFilePath, "~", homeDir, 1)
+ }
+ if _, err := os.Stat(userCaOutFilePath); err == nil && !forceOverwrite {
+ util.PrintErrorMessageAndExit("File already exists at " + userCaOutFilePath + ". Use --force to overwrite.")
+ }
+ }
+
+ keyTypes := []string{"ed25519", "ecdsa", "rsa"}
+ var hostKeyPath, certOutPath, hostPrivateKeyPath string
+ if writeHostCertToFile {
+ for _, keyType := range keyTypes {
+ pub := fmt.Sprintf("/etc/ssh/ssh_host_%s_key.pub", keyType)
+ cert := fmt.Sprintf("/etc/ssh/ssh_host_%s_key-cert.pub", keyType)
+ priv := fmt.Sprintf("/etc/ssh/ssh_host_%s_key", keyType)
+
+ if _, err := os.Stat(pub); err == nil {
+ hostKeyPath = pub
+ certOutPath = cert
+ hostPrivateKeyPath = priv
+ break
+ }
+ }
+
+ if hostKeyPath == "" {
+ util.PrintErrorMessageAndExit("No supported SSH host public key found at /etc/ssh")
+ }
+
+ if _, err := os.Stat(certOutPath); err == nil && !forceOverwrite {
+ util.PrintErrorMessageAndExit("File already exists at " + certOutPath + ". Use --force to overwrite.")
+ }
+ }
+
+ if configureSshd {
+ sshdConfig := "/etc/ssh/sshd_config"
+ existing, err := os.ReadFile(sshdConfig)
+ if err != nil {
+ util.HandleError(err, "Failed to read sshd_config")
+ }
+ configLines := []string{
+ "TrustedUserCAKeys " + userCaOutFilePath,
+ "HostKey " + hostPrivateKeyPath,
+ "HostCertificate " + certOutPath,
+ }
+ for _, line := range configLines {
+ for _, existingLine := range strings.Split(string(existing), "\n") {
+ trimmed := strings.TrimSpace(existingLine)
+ if trimmed == line && !strings.HasPrefix(trimmed, "#") && !forceOverwrite {
+ util.PrintErrorMessageAndExit("sshd_config already contains: " + line + ". Use --force to overwrite.")
+ }
+ }
+ }
+ }
+
+ customHeaders, err := util.GetInfisicalCustomHeadersMap()
+ if err != nil {
+ util.HandleError(err, "Unable to get custom headers")
+ }
+
+ client := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
+ SiteUrl: config.INFISICAL_URL,
+ UserAgent: api.USER_AGENT,
+ AutoTokenRefresh: false,
+ CustomHeaders: customHeaders,
+ })
+ client.Auth().SetAccessToken(infisicalToken)
+
+ host, err := client.Ssh().AddSshHost(infisicalSdk.AddSshHostOptions{
+ ProjectID: projectId,
+ Hostname: hostname,
+ })
+ if err != nil {
+ util.HandleError(err, "Failed to register SSH host")
+ }
+
+ fmt.Println("✅ Successfully registered host:", host.Hostname)
+
+ if writeUserCaToFile {
+ publicKey, err := client.Ssh().GetSshHostUserCaPublicKey(host.ID)
+ if err != nil {
+ util.HandleError(err, "Failed to fetch associated User CA public key")
+ }
+
+ if err := writeToFile(userCaOutFilePath, publicKey, 0644); err != nil {
+ util.HandleError(err, "Failed to write User CA public key to file")
+ }
+
+ fmt.Println("📁 Wrote User CA public key to:", userCaOutFilePath)
+ }
+
+ if writeHostCertToFile {
+ pubKeyBytes, err := os.ReadFile(hostKeyPath)
+ if err != nil {
+ util.HandleError(err, "Failed to read SSH host public key")
+ }
+ res, err := client.Ssh().IssueSshHostHostCert(host.ID, infisicalSdk.IssueSshHostHostCertOptions{
+ PublicKey: string(pubKeyBytes),
+ })
+ if err != nil {
+ util.HandleError(err, "Failed to issue SSH host certificate")
+ }
+ if err := writeToFile(certOutPath, res.SignedKey, 0644); err != nil {
+ util.HandleError(err, "Failed to write SSH host certificate to file")
+ }
+ fmt.Println("📁 Wrote host certificate to:", certOutPath)
+ }
+
+ if configureSshd {
+ sshdConfig := "/etc/ssh/sshd_config"
+ contentBytes, err := os.ReadFile(sshdConfig)
+ if err != nil {
+ util.HandleError(err, "Failed to read sshd_config")
+ }
+ lines := strings.Split(string(contentBytes), "\n")
+
+ configMap := map[string]string{
+ "TrustedUserCAKeys": userCaOutFilePath,
+ "HostKey": hostPrivateKeyPath,
+ "HostCertificate": certOutPath,
+ }
+
+ seenKeys := map[string]bool{}
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ for key, value := range configMap {
+ if strings.HasPrefix(trimmed, key+" ") {
+ seenKeys[key] = true
+ if strings.HasPrefix(trimmed, "#") || forceOverwrite {
+ lines[i] = fmt.Sprintf("%s %s", key, value)
+ } else {
+ util.PrintErrorMessageAndExit("sshd_config already contains: " + trimmed + ". Use --force to overwrite.")
+ }
+ }
+ }
+ }
+
+ // Append missing lines
+ for key, value := range configMap {
+ if !seenKeys[key] {
+ lines = append(lines, fmt.Sprintf("%s %s", key, value))
+ }
+ }
+
+ // Write back to file
+ if err := os.WriteFile(sshdConfig, []byte(strings.Join(lines, "\n")), 0644); err != nil {
+ util.HandleError(err, "Failed to update sshd_config")
+ }
+ fmt.Println("📄 Updated sshd_config entries")
+ }
+}
+
func init() {
sshSignKeyCmd.Flags().String("token", "", "Issue SSH certificate using machine identity access token")
sshSignKeyCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue the SSH certificate for")
@@ -617,5 +1005,20 @@ func init() {
sshIssueCredentialsCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be saved to the current working directory")
sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent")
sshCmd.AddCommand(sshIssueCredentialsCmd)
+
+ sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist")
+ sshCmd.AddCommand(sshConnectCmd)
+
+ sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token")
+ sshAddHostCmd.Flags().String("projectId", "", "Project ID the host belongs to (required)")
+ sshAddHostCmd.Flags().String("hostname", "", "Hostname of the SSH host (required)")
+ sshAddHostCmd.Flags().Bool("writeUserCaToFile", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub")
+ sshAddHostCmd.Flags().String("userCaOutFilePath", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key")
+ sshAddHostCmd.Flags().Bool("writeHostCertToFile", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub")
+ sshAddHostCmd.Flags().Bool("configureSshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file")
+ sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile")
+
+ sshCmd.AddCommand(sshAddHostCmd)
+
rootCmd.AddCommand(sshCmd)
}
diff --git a/docs/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.
+
+ 
+
+ 1.2. Next, create an SSH CA in the **Certificate Authorities** tab of the
+ project; this CA will be used for client key signing.
+
+ 
+
+ 
+
+ 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.
+
+ 
+
+ 
+
+ 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.
+
+ 
+
+
+ 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.
+
+ 
+
+ 
+
+ 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.
+
+ 
+
+ 
+
+ 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.
+
+ 
+
+ 
+
+
+ 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.
+
+
+ 
+
+ 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.
+
+ 
+
+
+ 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.
- 
+ 
- 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.
- 
+ 
- 
-
- Here's some guidance on each field:
+ 
+
+
+ 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.
-
- 
-
- 
-
- Here's some guidance on each field:
-
- - SSH Template Name: A name for the certificate template; this must be a valid slug.
- - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username.
- - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname.
- - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request.
- - Max TTL: The maximum TTL for certificates issued under this template.
- - Allow User Certificates: Whether or not to allow issuance of user certificates.
- - Allow Host Certificates: Whether or not to allow issuance of host certificates.
- - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request.
-
- 1.4. Finally, add the user(s) you wish to be able to request a SSH certificate to the SSH project through the **Access Control** tab.
+ 
-
-
- 2.1. Begin by downloading the CA's public key from the CA's details section.
+
+ 3.1. Follow the instructions [here](/cli/overview) to install the Infisical CLI onto the remote host.
- 
-
-
- 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.
+
+ 
+
+
+
+ 4.1. Add the user(s) you wish to grant access to the remote host to the Infisical SSH project under Access Control > Users.
+
+ 
+
+ 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.
+
+ 
+
+
+ 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/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts
index 40da0207a..e2d620fe2 100644
--- a/frontend/src/hooks/api/accessApproval/types.ts
+++ b/frontend/src/hooks/api/accessApproval/types.ts
@@ -79,6 +79,8 @@ export type TAccessApprovalRequest = {
member: string;
status: string;
}[];
+
+ note?: string;
};
export type TAccessApproval = {
@@ -119,6 +121,7 @@ export type TProjectUserPrivilege = {
export type TCreateAccessRequestDTO = {
projectSlug: string;
+ note?: string;
} & Omit;
export type TGetAccessApprovalRequestsDTO = {
diff --git a/frontend/src/hooks/api/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 }) => (
-
- )}
-
+ <>
+
+ {({ isActive }) => (
+
+ )}
+
+ {/*
+ {({ isActive }) => (
+
+ )}
+ */}
+ {/*
+ {({ isActive }) => (
+
+ )}
+ */}
+ >
)}
{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/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx
index 06641be7a..e9c9d15fc 100644
--- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx
+++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx
@@ -67,7 +67,8 @@ const secretPermissionSchema = z.object({
z.object({
isTemporary: z.literal(false)
})
- ])
+ ]),
+ note: z.string().optional()
});
type TSecretPermissionForm = z.infer;
export const SpecificPrivilegeSecretForm = ({
@@ -231,7 +232,8 @@ export const SpecificPrivilegeSecretForm = ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
- }))
+ })),
+ note: data.note
});
createNotification({
@@ -541,6 +543,18 @@ export const SpecificPrivilegeSecretForm = ({
)}
+