diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bb358ee0..5db874e75 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /app RUN apk --update add \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 3eda2ad03..0d1c16574 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -17,7 +17,8 @@ RUN apk --update add \ openssl-dev \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index 0028381bd..99e3999e1 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -22,8 +22,10 @@ export const mockQueue = (): TQueueServiceFactory => { listen: (name, event) => { events[name] = event; }, + getRepeatableJobs: async () => [], clearQueue: async () => {}, stopJobById: async () => {}, - stopRepeatableJobByJobId: async () => true + stopRepeatableJobByJobId: async () => true, + stopRepeatableJobByKey: async () => true }; }; diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c6efcaf89..02159fa9c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -31,6 +31,8 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -178,6 +180,8 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; + sshCertificateTemplate: TSshCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index bc941f430..05dff5a73 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -317,6 +317,21 @@ import { TSlackIntegrations, TSlackIntegrationsInsert, TSlackIntegrationsUpdate, + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate, + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate, + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate, + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate, + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -379,6 +394,31 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate + >; + [TableName.SshCertificateAuthoritySecret]: KnexOriginal.CompositeTableType< + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate + >; + [TableName.SshCertificateTemplate]: KnexOriginal.CompositeTableType< + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate + >; + [TableName.SshCertificate]: KnexOriginal.CompositeTableType< + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate + >; + [TableName.SshCertificateBody]: KnexOriginal.CompositeTableType< + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate + >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, TCertificateAuthoritiesInsert, diff --git a/backend/src/db/migrations/20241216013357_ssh-mgmt.ts b/backend/src/db/migrations/20241216013357_ssh-mgmt.ts new file mode 100644 index 000000000..92831d382 --- /dev/null +++ b/backend/src/db/migrations/20241216013357_ssh-mgmt.ts @@ -0,0 +1,99 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthority))) { + await knex.schema.createTable(TableName.SshCertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled + t.string("friendlyName").notNullable(); + t.string("keyAlgorithm").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthority); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.SshCertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable().unique(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateTemplate))) { + await knex.schema.createTable(TableName.SshCertificateTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled + t.string("name").notNullable(); + t.string("ttl").notNullable(); + t.string("maxTTL").notNullable(); + t.specificType("allowedUsers", "text[]").notNullable(); + t.specificType("allowedHosts", "text[]").notNullable(); + t.boolean("allowUserCertificates").notNullable(); + t.boolean("allowHostCertificates").notNullable(); + t.boolean("allowCustomKeyIds").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificate))) { + await knex.schema.createTable(TableName.SshCertificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.uuid("sshCertificateTemplateId"); + t.foreign("sshCertificateTemplateId") + .references("id") + .inTable(TableName.SshCertificateTemplate) + .onDelete("SET NULL"); + t.string("serialNumber").notNullable().unique(); + t.string("certType").notNullable(); // user or host + t.specificType("principals", "text[]").notNullable(); + t.string("keyId").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificate); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateBody))) { + await knex.schema.createTable(TableName.SshCertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCertId").notNullable().unique(); + t.foreign("sshCertId").references("id").inTable(TableName.SshCertificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.SshCertificateBody); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SshCertificateBody); + await dropOnUpdateTrigger(knex, TableName.SshCertificateBody); + + await knex.schema.dropTableIfExists(TableName.SshCertificate); + await dropOnUpdateTrigger(knex, TableName.SshCertificate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateTemplate); + await dropOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthority); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 02d6c404a..19f45eb33 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -107,6 +107,11 @@ export * from "./secrets"; export * from "./secrets-v2"; export * from "./service-tokens"; export * from "./slack-integrations"; +export * from "./ssh-certificate-authorities"; +export * from "./ssh-certificate-authority-secrets"; +export * from "./ssh-certificate-bodies"; +export * from "./ssh-certificate-templates"; +export * from "./ssh-certificates"; export * from "./super-admin"; export * from "./totp-configs"; export * from "./trusted-ips"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 1dd075af1..620c526a5 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,11 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshCertificateAuthority = "ssh_certificate_authorities", + SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", + SshCertificateTemplate = "ssh_certificate_templates", + SshCertificate = "ssh_certificates", + SshCertificateBody = "ssh_certificate_bodies", CertificateAuthority = "certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", @@ -206,5 +211,6 @@ export enum IdentityAuthMethod { export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", - KMS = "kms" + KMS = "kms", + SSH = "ssh" } diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts new file mode 100644 index 000000000..81e789288 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + status: z.string(), + friendlyName: z.string(), + keyAlgorithm: z.string() +}); + +export type TSshCertificateAuthorities = z.infer; +export type TSshCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-authority-secrets.ts b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts new file mode 100644 index 000000000..934c10ab2 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritySecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TSshCertificateAuthoritySecrets = z.infer; +export type TSshCertificateAuthoritySecretsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSshCertificateAuthoritySecretsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-bodies.ts b/backend/src/db/schemas/ssh-certificate-bodies.ts new file mode 100644 index 000000000..baafb773b --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-bodies.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCertId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TSshCertificateBodies = z.infer; +export type TSshCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-certificate-templates.ts b/backend/src/db/schemas/ssh-certificate-templates.ts new file mode 100644 index 000000000..6c16c3942 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-templates.ts @@ -0,0 +1,30 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateTemplatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + status: z.string(), + name: z.string(), + ttl: z.string(), + maxTTL: z.string(), + allowedUsers: z.string().array(), + allowedHosts: z.string().array(), + allowUserCertificates: z.boolean(), + allowHostCertificates: z.boolean(), + allowCustomKeyIds: z.boolean() +}); + +export type TSshCertificateTemplates = z.infer; +export type TSshCertificateTemplatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateTemplatesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts new file mode 100644 index 000000000..6fe5bc261 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificates.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + sshCertificateTemplateId: z.string().uuid().nullable().optional(), + serialNumber: z.string(), + certType: z.string(), + principals: z.string().array(), + keyId: z.string(), + notBefore: z.date(), + notAfter: z.date() +}); + +export type TSshCertificates = z.infer; +export type TSshCertificatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 5e3a0eafe..c1d30b36f 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -25,6 +25,9 @@ import { registerSecretRotationRouter } from "./secret-rotation-router"; import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; +import { registerSshCaRouter } from "./ssh-certificate-authority-router"; +import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; +import { registerSshRouter } from "./ssh-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -68,6 +71,15 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/pki" } ); + await server.register( + async (sshRouter) => { + await sshRouter.register(registerSshRouter, { prefix: "/" }); + await sshRouter.register(registerSshCaRouter, { prefix: "/ca" }); + await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/ssh" } + ); + await server.register( async (ssoRouter) => { await ssoRouter.register(registerSamlRouter); diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts new file mode 100644 index 000000000..ab80888d7 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -0,0 +1,279 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create SSH CA", + body: z.object({ + projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), + friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.createSshCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.getSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get public key of SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_PUBLIC_KEY.sshCaId) + }), + response: { + 200: z.string() + } + }, + handler: async (req) => { + const publicKey = await server.services.sshCertificateAuthority.getSshCaPublicKey({ + caId: req.params.sshCaId + }); + + return publicKey; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId) + }), + body: z.object({ + friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), + status: z.nativeEnum(SshCaStatus).optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.updateSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.UPDATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName, + status: ca.status as SshCaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.DELETE.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.deleteSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/certificate-templates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get list of certificate templates for the SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_CERTIFICATE_TEMPLATES.sshCaId) + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedSshCertificateTemplate.array() + }) + } + }, + handler: async (req) => { + const { certificateTemplates, ca } = await server.services.sshCertificateAuthority.getSshCaCertificateTemplates({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + certificateTemplates + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts new file mode 100644 index 000000000..f94b9d50a --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -0,0 +1,258 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; +import { + isValidHostPattern, + isValidUserPattern +} from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-validators"; +import { SSH_CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSshCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.getSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z + .object({ + sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Name must be a valid slug" + }) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("1h") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .default("30d") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedHosts), + allowUserCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowUserCertificates), + allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds) + }) + .refine((data) => ms(data.maxTTL) > ms(data.ttl), { + message: "Max TLL must be greater than TTL", + path: ["maxTTL"] + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, ca } = await server.services.sshCertificateTemplate.createSshCertTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + sshCaId: ca.id, + name: certificateTemplate.name, + ttl: certificateTemplate.ttl, + maxTTL: certificateTemplate.maxTTL, + allowedUsers: certificateTemplate.allowedUsers, + allowedHosts: certificateTemplate.allowedHosts, + allowUserCertificates: certificateTemplate.allowUserCertificates, + allowHostCertificates: certificateTemplate.allowHostCertificates, + allowCustomKeyIds: certificateTemplate.allowCustomKeyIds + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + status: z.nativeEnum(SshCertTemplateStatus).optional(), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedHosts), + allowUserCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowUserCertificates), + allowHostCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowCustomKeyIds) + }), + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, projectId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + status: certificateTemplate.status as SshCertTemplateStatus, + certificateTemplateId: certificateTemplate.id, + sshCaId: certificateTemplate.sshCaId, + name: certificateTemplate.name, + ttl: certificateTemplate.ttl, + maxTTL: certificateTemplate.maxTTL, + allowedUsers: certificateTemplate.allowedUsers, + allowedHosts: certificateTemplate.allowedHosts, + allowUserCertificates: certificateTemplate.allowUserCertificates, + allowHostCertificates: certificateTemplate.allowHostCertificates, + allowCustomKeyIds: certificateTemplate.allowCustomKeyIds + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.deleteSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-router.ts b/backend/src/ee/routes/v1/ssh-router.ts new file mode 100644 index 000000000..a33ccc328 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-router.ts @@ -0,0 +1,164 @@ +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/sign", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign SSH public key", + body: z.object({ + certificateTemplateId: z + .string() + .trim() + .min(1) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certificateTemplateId), + publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.principals), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.ttl), + keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.keyId) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.signedKey) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.signSshKey({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.SIGN_SSH_KEY, + metadata: { + certificateTemplateId: certificateTemplate.id, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey + }; + } + }); + + server.route({ + method: "POST", + url: "/issue", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue SSH credentials (certificate + key)", + body: z.object({ + certificateTemplateId: z + .string() + .trim() + .min(1) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certificateTemplateId), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.principals), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.ttl), + keyId: z.string().trim().max(50).optional().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyId) + }), + response: { + 200: z.object({ + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.signedKey), + privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey), + publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, privateKey, publicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.issueSshCreds({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_CREDS, + metadata: { + certificateTemplateId: certificateTemplate.id, + keyAlgorithm: req.body.keyAlgorithm, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm: req.body.keyAlgorithm + }; + } + }); +}; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 368b1cd6c..6b4b5d20e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,10 +2,13 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { AppConnection, TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/lib/app-connections"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; @@ -144,6 +147,17 @@ export enum EventType { SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + SIGN_SSH_KEY = "sign-ssh-key", + ISSUE_SSH_CREDS = "issue-ssh-creds", + CREATE_SSH_CA = "create-ssh-certificate-authority", + GET_SSH_CA = "get-ssh-certificate-authority", + UPDATE_SSH_CA = "update-ssh-certificate-authority", + DELETE_SSH_CA = "delete-ssh-certificate-authority", + GET_SSH_CA_CERTIFICATE_TEMPLATES = "get-ssh-certificate-authority-certificate-templates", + CREATE_SSH_CERTIFICATE_TEMPLATE = "create-ssh-certificate-template", + UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", + DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", + GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -1212,6 +1226,117 @@ interface SecretApprovalRequest { }; } +interface SignSshKey { + type: EventType.SIGN_SSH_KEY; + metadata: { + certificateTemplateId: string; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface IssueSshCreds { + type: EventType.ISSUE_SSH_CREDS; + metadata: { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface CreateSshCa { + type: EventType.CREATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCa { + type: EventType.GET_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface UpdateSshCa { + type: EventType.UPDATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + status: SshCaStatus; + }; +} + +interface DeleteSshCa { + type: EventType.DELETE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCaCertificateTemplates { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface CreateSshCertificateTemplate { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface GetSshCertificateTemplate { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateSshCertificateTemplate { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + status: SshCertTemplateStatus; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface DeleteSshCertificateTemplate { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -1874,6 +1999,17 @@ export type Event = | SecretApprovalClosed | SecretApprovalRequest | SecretApprovalReopened + | SignSshKey + | IssueSshCreds + | CreateSshCa + | GetSshCa + | UpdateSshCa + | DeleteSshCa + | GetSshCaCertificateTemplates + | CreateSshCertificateTemplate + | UpdateSshCertificateTemplate + | GetSshCertificateTemplate + | DeleteSshCertificateTemplate | CreateCa | GetCa | UpdateCa diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index f6d7f715f..47142054e 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -54,6 +54,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificates = "ssh-certificates", + SshCertificateTemplates = "ssh-certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -132,6 +135,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] @@ -338,6 +344,28 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateAuthorities) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshCertificates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateTemplates) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -480,7 +508,10 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates, ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, - ProjectPermissionSub.PkiCollections + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates ].forEach((el) => { can( [ @@ -665,6 +696,11 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); + can( [ ProjectPermissionCmekActions.Create, @@ -707,6 +743,9 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); return rules; }; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts new file mode 100644 index 000000000..b8afa0df2 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts @@ -0,0 +1,66 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSshCertificateTemplateDALFactory = ReturnType; + +export const sshCertificateTemplateDALFactory = (db: TDbClient) => { + const sshCertificateTemplateOrm = ormify(db, TableName.SshCertificateTemplate); + + const getById = async (id: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) + .where(`${TableName.SshCertificateTemplate}.id`, "=", id) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by ID" }); + } + }; + + /** + * Returns the SSH certificate template named [name] within project with id [projectId] + */ + const getByName = async (name: string, projectId: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) + .where(`${TableName.SshCertificateTemplate}.name`, "=", name) + .where(`${TableName.Project}.id`, "=", projectId) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by name" }); + } + }; + + return { ...sshCertificateTemplateOrm, getById, getByName }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts new file mode 100644 index 000000000..fb7a95203 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts @@ -0,0 +1,15 @@ +import { SshCertificateTemplatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({ + id: true, + sshCaId: true, + status: true, + name: true, + ttl: true, + maxTTL: true, + allowedUsers: true, + allowedHosts: true, + allowCustomKeyIds: true, + allowUserCertificates: true, + allowHostCertificates: true +}); diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts new file mode 100644 index 000000000..a31b9b858 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts @@ -0,0 +1,249 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; +import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal"; +import { + SshCertTemplateStatus, + TCreateSshCertTemplateDTO, + TDeleteSshCertTemplateDTO, + TGetSshCertTemplateDTO, + TUpdateSshCertTemplateDTO +} from "./ssh-certificate-template-types"; + +type TSshCertificateTemplateServiceFactoryDep = { + sshCertificateTemplateDAL: Pick< + TSshCertificateTemplateDALFactory, + "transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById" + >; + sshCertificateAuthorityDAL: Pick; + permissionService: Pick; +}; + +export type TSshCertificateTemplateServiceFactory = ReturnType; + +export const sshCertificateTemplateServiceFactory = ({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService +}: TSshCertificateTemplateServiceFactoryDep) => { + const createSshCertTemplate = async ({ + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCertTemplateDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(sshCaId); + if (!ca) { + throw new NotFoundError({ + message: `SSH CA with ID ${sshCaId} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateTemplates + ); + + if (ms(ttl) > ms(maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.projectId, tx); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.create( + { + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + status: SshCertTemplateStatus.ACTIVE + }, + tx + ); + + return certificateTemplate; + }); + + return { certificateTemplate: newCertificateTemplate, ca }; + }; + + const updateSshCertTemplate = async ({ + id, + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateTemplates + ); + + const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + if (name) { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, certTemplate.projectId, tx); + if (existingTemplate && existingTemplate.id !== id) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + } + + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.updateById( + id, + { + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }, + tx + ); + + return certificateTemplate; + }); + + return { + certificateTemplate: updatedCertificateTemplate, + projectId: certTemplate.projectId + }; + }; + + const deleteSshCertTemplate = async ({ + id, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteSshCertTemplateDTO) => { + const certificateTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateTemplates + ); + + await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); + + return certificateTemplate; + }; + + const getSshCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + return certTemplate; + }; + + return { + createSshCertTemplate, + updateSshCertTemplate, + deleteSshCertTemplate, + getSshCertTemplate + }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts new file mode 100644 index 000000000..64de1bf0c --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts @@ -0,0 +1,39 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TCreateSshCertTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowedUsers: string[]; + allowedHosts: string[]; + allowCustomKeyIds: boolean; +} & Omit; + +export type TUpdateSshCertTemplateDTO = { + id: string; + status?: SshCertTemplateStatus; + name?: string; + ttl?: string; + maxTTL?: string; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowCustomKeyIds?: boolean; +} & Omit; + +export type TGetSshCertTemplateDTO = { + id: string; +} & Omit; + +export type TDeleteSshCertTemplateDTO = { + id: string; +} & Omit; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts new file mode 100644 index 000000000..373bbc640 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts @@ -0,0 +1,14 @@ +// Validates usernames or wildcard (*) +export const isValidUserPattern = (value: string): boolean => { + // Matches valid Linux usernames or a wildcard (*) + const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + return userRegex.test(value); +}; + +// Validates hostnames, wildcard domains, or IP addresses +export const isValidHostPattern = (value: string): boolean => { + // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses + const hostRegex = + /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; + return hostRegex.test(value); +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts new file mode 100644 index 000000000..c3d16a39e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateBodyDALFactory = ReturnType; + +export const sshCertificateBodyDALFactory = (db: TDbClient) => { + const sshCertificateBodyOrm = ormify(db, TableName.SshCertificateBody); + return sshCertificateBodyOrm; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts new file mode 100644 index 000000000..9c5bd1d3e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts @@ -0,0 +1,38 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateDALFactory = ReturnType; + +export const sshCertificateDALFactory = (db: TDbClient) => { + const sshCertificateOrm = ormify(db, TableName.SshCertificate); + + const countSshCertificatesInProject = async (projectId: string) => { + try { + interface CountResult { + count: string; + } + + const query = db + .replicaNode()(TableName.SshCertificate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificate}.sshCaId`, + `${TableName.SshCertificateAuthority}.id` + ) + .join(TableName.Project, `${TableName.SshCertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId); + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all SSH certificates in project" }); + } + }; + return { + ...sshCertificateOrm, + countSshCertificatesInProject + }; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts new file mode 100644 index 000000000..9c3b7a392 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts @@ -0,0 +1,14 @@ +import { SshCertificatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificate = SshCertificatesSchema.pick({ + id: true, + sshCaId: true, + sshCertificateTemplateId: true, + serialNumber: true, + certType: true, + publicKey: true, + principals: true, + keyId: true, + notBefore: true, + notAfter: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts new file mode 100644 index 000000000..c906efa91 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthorityDALFactory = ReturnType; + +export const sshCertificateAuthorityDALFactory = (db: TDbClient) => { + const sshCaOrm = ormify(db, TableName.SshCertificateAuthority); + return sshCaOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts new file mode 100644 index 000000000..f59a5d5bf --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -0,0 +1,376 @@ +import { execFile } from "child_process"; +import crypto from "crypto"; +import { promises as fs } from "fs"; +import ms from "ms"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; + +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { + isValidHostPattern, + isValidUserPattern +} from "../ssh-certificate-template/ssh-certificate-template-validators"; +import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; + +const execFileAsync = promisify(execFile); + +/* eslint-disable no-bitwise */ +export const createSshCertSerialNumber = () => { + const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits + randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range) + return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal +}; + +/** + * Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm]. + * We use this function because the key format generated by `ssh-keygen` is unique. + */ +export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); + const publicKeyFile = `${privateKeyFile}.pub`; + + let keyType: string; + let keyBits: string; + + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_2048: + keyType = "rsa"; + keyBits = "2048"; + break; + case CertKeyAlgorithm.RSA_4096: + keyType = "rsa"; + keyBits = "4096"; + break; + case CertKeyAlgorithm.ECDSA_P256: + keyType = "ecdsa"; + keyBits = "256"; + break; + case CertKeyAlgorithm.ECDSA_P384: + keyType = "ecdsa"; + keyBits = "384"; + break; + default: + throw new BadRequestError({ + message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm" + }); + } + + try { + // Generate the SSH key pair + // The "-N ''" sets an empty passphrase + // The keys are created in the temporary directory + await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""]); + + // Read the generated keys + const publicKey = await fs.readFile(publicKeyFile, "utf8"); + const privateKey = await fs.readFile(privateKeyFile, "utf8"); + + return { publicKey, privateKey }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Return the SSH public key for the given SSH private key. + */ +export const getSshPublicKey = async (privateKey: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); + try { + await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); + + // Run ssh-keygen to extract the public key + const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" }); + return stdout.trim(); + } finally { + // Ensure that files and the temporary directory are cleaned up + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Validate the requested SSH certificate type based on the SSH certificate template configuration. + */ +export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => { + if (!template.allowUserCertificates && certType === SshCertType.USER) { + throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" }); + } + + if (!template.allowHostCertificates && certType === SshCertType.HOST) { + throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" }); + } +}; + +/** + * Validate the requested SSH certificate principals based on the SSH certificate template configuration. + */ +export const validateSshCertificatePrincipals = ( + certType: SshCertType, + template: TSshCertificateTemplates, + principals: string[] +) => { + /** + * Validate and sanitize a principal string + */ + const validatePrincipal = (principal: string) => { + const sanitized = principal.trim(); + + // basic checks for empty or control characters + if (sanitized.length === 0) { + throw new BadRequestError({ + message: "Principal cannot be an empty string." + }); + } + + if (/\r|\n|\t|\0/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' contains invalid whitespace or control characters.` + }); + } + + // disallow whitespace anywhere + if (/\s/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' cannot contain whitespace.` + }); + } + + // restrict allowed characters to letters, digits, dot, underscore, and hyphen + if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) { + throw new BadRequestError({ + message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.` + }); + } + + // disallow leading hyphen to avoid potential argument-like inputs + if (sanitized.startsWith("-")) { + throw new BadRequestError({ + message: `Principal '${sanitized}' cannot start with a hyphen.` + }); + } + + // length restriction (adjust as needed) + if (sanitized.length > 64) { + throw new BadRequestError({ + message: `Principal '${sanitized}' is too long.` + }); + } + + return sanitized; + }; + + // Sanitize and validate all principals using the helper + const sanitizedPrincipals = principals.map(validatePrincipal); + + switch (certType) { + case SshCertType.USER: { + if (template.allowedUsers.length === 0) { + throw new BadRequestError({ + message: "No allowed users are configured in the SSH certificate template." + }); + } + + const allowsAllUsers = template.allowedUsers.includes("*") ?? false; + + sanitizedPrincipals.forEach((principal) => { + if (principal === "*") { + throw new BadRequestError({ + message: `Principal '*' is not allowed for user certificates.` + }); + } + if (allowsAllUsers && !isValidUserPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid user pattern.` + }); + } + if (!allowsAllUsers && !template.allowedUsers.includes(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed users.` + }); + } + }); + break; + } + case SshCertType.HOST: { + if (template.allowedHosts.length === 0) { + throw new BadRequestError({ + message: "No allowed hosts are configured in the SSH certificate template." + }); + } + + const allowsAllHosts = template.allowedHosts.includes("*") ?? false; + + sanitizedPrincipals.forEach((principal) => { + if (principal.includes("*")) { + throw new BadRequestError({ + message: `Principal '${principal}' with wildcards is not allowed for host certificates.` + }); + } + if (allowsAllHosts && !isValidHostPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid host pattern.` + }); + } + + if ( + !allowsAllHosts && + !template.allowedHosts.some((allowedHost) => { + if (allowedHost.startsWith("*.")) { + const baseDomain = allowedHost.slice(2); // Remove the leading "*." + return principal.endsWith(`.${baseDomain}`); + } + return principal === allowedHost; + }) + ) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed hosts or domains.` + }); + } + }); + break; + } + default: + throw new BadRequestError({ + message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type" + }); + } +}; + +/** + * Validate the requested SSH certificate TTL based on the SSH certificate template configuration. + */ +export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => { + if (!ttl) { + // use default template ttl + return Math.ceil(ms(template.ttl) / 1000); + } + + if (ms(ttl) > ms(template.maxTTL)) { + throw new BadRequestError({ + message: "Failed TTL validation due to TTL being greater than configured max TTL on template" + }); + } + + return Math.ceil(ms(ttl) / 1000); +}; + +/** + * Validate the requested SSH certificate key ID to ensure + * that it only contains alphanumeric characters with no spaces. + */ +export const validateSshCertificateKeyId = (keyId: string) => { + const regex = /^[A-Za-z0-9-]+$/; + if (!regex.test(keyId)) { + throw new BadRequestError({ + message: + "Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces." + }); + } + + if (keyId.length > 50) { + throw new BadRequestError({ + message: "keyId can only be up to 50 characters long." + }); + } +}; + +/** + * Validate the format of the SSH public key + */ +const validateSshPublicKey = async (publicKey: string) => { + const validPrefixes = ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]; + const startsWithValidPrefix = validPrefixes.some((prefix) => publicKey.startsWith(`${prefix} `)); + if (!startsWithValidPrefix) { + throw new BadRequestError({ message: "Failed to validate SSH public key format: unsupported key type." }); + } + + // write the key to a temp file and run `ssh-keygen -l -f` + // check to see if OpenSSH can read/interpret the public key + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-pubkey-")); + const pubKeyFile = path.join(tempDir, "key.pub"); + + try { + await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 }); + await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]); + } catch (error) { + throw new BadRequestError({ + message: "Failed to validate SSH public key format: could not be parsed." + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +/** + * Create an SSH certificate for a user or host. + */ +export const createSshCert = async ({ + template, + caPrivateKey, + clientPublicKey, + keyId, + principals, + requestedTtl, + certType +}: TCreateSshCertDTO) => { + // validate if the requested [certType] is allowed under the template configuration + validateSshCertificateType(template, certType); + + // validate if the requested [principals] are valid for the given [certType] under the template configuration + validateSshCertificatePrincipals(certType, template, principals); + + // validate if the requested TTL is valid under the template configuration + const ttl = validateSshCertificateTtl(template, requestedTtl); + + validateSshCertificateKeyId(keyId); + await validateSshPublicKey(clientPublicKey); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-")); + + const publicKeyFile = path.join(tempDir, "user_key.pub"); + const privateKeyFile = path.join(tempDir, "ca_key"); + const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub"); + + const serialNumber = createSshCertSerialNumber(); + + // Build `ssh-keygen` arguments for signing + // Using an array avoids shell injection issues + const sshKeygenArgs = [ + certType === "host" ? "-h" : null, // host certificate if needed + "-s", + privateKeyFile, // path to SSH CA private key + "-I", + keyId, // identity (key ID) + "-n", + principals.join(","), // principals + "-V", + `+${ttl}s`, // validity (TTL in seconds) + "-z", + serialNumber, // serial number + publicKeyFile // public key file to sign + ].filter(Boolean) as string[]; + + try { + // Write public and private keys to the temp directory + await fs.writeFile(publicKeyFile, clientPublicKey, { mode: 0o600 }); + await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 }); + + // Execute the signing process + await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" }); + + // Read the signed public key from the generated cert file + const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); + + return { serialNumber, signedPublicKey, ttl }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts new file mode 100644 index 000000000..9ff76efbc --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -0,0 +1,9 @@ +import { SshCertificateAuthoritiesSchema } from "@app/db/schemas"; + +export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ + id: true, + projectId: true, + friendlyName: true, + status: true, + keyAlgorithm: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts new file mode 100644 index 000000000..9423a0ff2 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthoritySecretDALFactory = ReturnType; + +export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => { + const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret); + return sshCaSecretOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts new file mode 100644 index 000000000..0b408c9dd --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -0,0 +1,523 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; +import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; +import { + SshCaStatus, + TCreateSshCaDTO, + TDeleteSshCaDTO, + TGetSshCaCertificateTemplatesDTO, + TGetSshCaDTO, + TGetSshCaPublicKeyDTO, + TIssueSshCredsDTO, + TSignSshKeyDTO, + TUpdateSshCaDTO +} from "./ssh-certificate-authority-types"; + +type TSshCertificateAuthorityServiceFactoryDep = { + sshCertificateAuthorityDAL: Pick< + TSshCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + sshCertificateAuthoritySecretDAL: Pick; + sshCertificateTemplateDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateBodyDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId" | "createCipherPairWithDataKey" + >; + permissionService: Pick; +}; + +export type TSshCertificateAuthorityServiceFactory = ReturnType; + +export const sshCertificateAuthorityServiceFactory = ({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + kmsService, + permissionService +}: TSshCertificateAuthorityServiceFactoryDep) => { + /** + * Generates a new SSH CA + */ + const createSshCa = async ({ + projectId, + friendlyName, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCaDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { + const ca = await sshCertificateAuthorityDAL.create( + { + projectId, + friendlyName, + status: SshCaStatus.ACTIVE, + keyAlgorithm + }, + tx + ); + + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + await sshCertificateAuthoritySecretDAL.create( + { + sshCaId: ca.id, + encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob + }, + tx + ); + + return { ...ca, publicKey }; + }); + + return newCa; + }; + + /** + * Return SSH CA with id [caId] + */ + const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...ca, publicKey }; + }; + + /** + * Return public key of SSH CA with id [caId] + */ + const getSshCaPublicKey = async ({ caId }: TGetSshCaPublicKeyDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + + /** + * Update SSH CA with id [caId] + * Note: Used to enable/disable CA + */ + const updateSshCaById = async ({ + caId, + friendlyName, + status, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...updatedCa, publicKey }; + }; + + /** + * Delete SSH CA with id [caId] + */ + const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * Return SSH certificate and corresponding new SSH public-private key pair where + * SSH public key is signed using CA behind SSH certificate with name [templateName]. + */ + const issueSshCreds = async ({ + certificateTemplateId, + keyAlgorithm, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshCredsDTO) => { + const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + sshCertificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } + + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + // create user key pair + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + template: sshCertificateTemplate, + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl, + certType + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { + serialNumber, + signedPublicKey, + privateKey, + publicKey, + certificateTemplate: sshCertificateTemplate, + ttl, + keyId + }; + }; + + /** + * Return SSH certificate by signing SSH public key [publicKey] + * using CA behind SSH certificate template with name [templateName] + */ + const signSshKey = async ({ + certificateTemplateId, + publicKey, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignSshKeyDTO) => { + const sshCertificateTemplate = await sshCertificateTemplateDAL.getById(certificateTemplateId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + sshCertificateTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } + + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const decryptedCaPrivateKey = secretManagerDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const { serialNumber, signedPublicKey, ttl } = await createSshCert({ + template: sshCertificateTemplate, + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + clientPublicKey: publicKey, + keyId, + principals, + requestedTtl, + certType + }); + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); + }); + + return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId }; + }; + + const getSshCaCertificateTemplates = async ({ + caId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSshCaCertificateTemplatesDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId }); + + return { + certificateTemplates, + ca + }; + }; + + return { + issueSshCreds, + signSshKey, + createSshCa, + getSshCaById, + getSshCaPublicKey, + updateSshCaById, + deleteSshCaById, + getSshCaCertificateTemplates + }; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts new file mode 100644 index 000000000..3f202ebf0 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -0,0 +1,68 @@ +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export type TCreateSshCaDTO = { + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; +} & TProjectPermission; + +export type TGetSshCaDTO = { + caId: string; +} & Omit; + +export type TGetSshCaPublicKeyDTO = { + caId: string; +}; + +export type TUpdateSshCaDTO = { + caId: string; + friendlyName?: string; + status?: SshCaStatus; +} & Omit; + +export type TDeleteSshCaDTO = { + caId: string; +} & Omit; + +export type TIssueSshCredsDTO = { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TSignSshKeyDTO = { + certificateTemplateId: string; + publicKey: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TGetSshCaCertificateTemplatesDTO = { + caId: string; +} & Omit; + +export type TCreateSshCertDTO = { + template: TSshCertificateTemplates; + caPrivateKey: string; + clientPublicKey: string; + keyId: string; + principals: string[]; + requestedTtl?: string; + certType: SshCertType; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 79af7a80e..5beb7fd46 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -495,6 +495,17 @@ export const PROJECTS = { LIST_INTEGRATION_AUTHORIZATION: { workspaceId: "The ID of the project to list integration auths for." }, + LIST_SSH_CAS: { + projectId: "The ID of the project to list SSH CAs for." + }, + LIST_SSH_CERTIFICATES: { + projectId: "The ID of the project to list SSH certificates for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", + limit: "The number of SSH certificates to return." + }, + LIST_SSH_CERTIFICATE_TEMPLATES: { + projectId: "The ID of the project to list SSH certificate templates for." + }, LIST_CAS: { slug: "The slug of the project to list CAs for.", status: "The status of the CA to filter by.", @@ -1129,6 +1140,7 @@ export const INTEGRATION = { shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", + azureLabel: "Define which label to assign to secrets created in Azure App Configuration.", githubVisibility: "Define where the secrets from the Github Integration should be visible. Option 'selected' lets you directly define which repositories to sync secrets to.", githubVisibilityRepoIds: @@ -1189,6 +1201,84 @@ export const AUDIT_LOG_STREAMS = { } }; +export const SSH_CERTIFICATE_AUTHORITIES = { + CREATE: { + projectId: "The ID of the project to create the SSH CA in.", + friendlyName: "A friendly name for the SSH CA.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA." + }, + GET: { + sshCaId: "The ID of the SSH CA to get." + }, + GET_PUBLIC_KEY: { + sshCaId: "The ID of the SSH CA to get the public key for." + }, + UPDATE: { + sshCaId: "The ID of the SSH CA to update.", + friendlyName: "A friendly name for the SSH CA to update to.", + status: "The status of the SSH CA to update to. This can be one of active or disabled." + }, + DELETE: { + sshCaId: "The ID of the SSH CA to delete." + }, + GET_CERTIFICATE_TEMPLATES: { + sshCaId: "The ID of the SSH CA to get the certificate templates for." + }, + SIGN_SSH_KEY: { + certificateTemplateId: "The ID of the SSH certificate template to sign the SSH public key with.", + publicKey: "The SSH public key to sign.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + ISSUE_SSH_CREDENTIALS: { + certificateTemplateId: "The ID of the SSH certificate template to issue the SSH credentials with.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key.", + privateKey: "The private key corresponding to the issued SSH certificate.", + publicKey: "The public key of the issued SSH certificate." + } +}; + +export const SSH_CERTIFICATE_TEMPLATES = { + GET: { + certificateTemplateId: "The ID of the SSH certificate template to get." + }, + CREATE: { + sshCaId: "The ID of the SSH CA to associate the certificate template with.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + UPDATE: { + certificateTemplateId: "The ID of the SSH certificate template to update.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + DELETE: { + certificateTemplateId: "The ID of the SSH certificate template to delete." + } +}; + export const CERTIFICATE_AUTHORITIES = { CREATE: { projectSlug: "Slug of the project to create the CA in.", diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index f55d8e6e6..0022ee8ea 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -20,11 +20,12 @@ export const withTransaction = (db: Knex, dal: K) => ({ export type TFindFilter = Partial & { $in?: Partial<{ [k in keyof R]: R[k][] }>; + $notNull?: Array; $search?: Partial<{ [k in keyof R]: R[k] }>; $complex?: TKnexDynamicOperator; }; export const buildFindFilter = - ({ $in, $search, $complex, ...filter }: TFindFilter) => + ({ $in, $notNull, $search, $complex, ...filter }: TFindFilter) => (bd: Knex.QueryBuilder) => { void bd.where(filter); if ($in) { @@ -34,6 +35,13 @@ export const buildFindFilter = } }); } + + if ($notNull?.length) { + $notNull.forEach((key) => { + void bd.whereNotNull(key as never); + }); + } + if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 051fe9cbd..330193052 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -317,6 +317,13 @@ export const queueServiceFactory = ( } }; + const getRepeatableJobs = (name: QueueName, startOffset?: number, endOffset?: number) => { + const q = queueContainer[name]; + if (!q) throw new Error(`Queue '${name}' not initialized`); + + return q.getRepeatableJobs(startOffset, endOffset); + }; + const stopRepeatableJobByJobId = async (name: T, jobId: string) => { const q = queueContainer[name]; const job = await q.getJob(jobId); @@ -326,6 +333,11 @@ export const queueServiceFactory = ( return q.removeRepeatableByKey(job.repeatJobKey); }; + const stopRepeatableJobByKey = async (name: T, repeatJobKey: string) => { + const q = queueContainer[name]; + return q.removeRepeatableByKey(repeatJobKey); + }; + const stopJobById = async (name: T, jobId: string) => { const q = queueContainer[name]; const job = await q.getJob(jobId); @@ -349,8 +361,10 @@ export const queueServiceFactory = ( shutdown, stopRepeatableJob, stopRepeatableJobByJobId, + stopRepeatableJobByKey, clearQueue, stopJobById, + getRepeatableJobs, startPg, queuePg }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f2a7ab0f5..eafd3467f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -75,6 +75,13 @@ import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-da import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal"; +import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -348,6 +355,12 @@ export const registerRoutes = async ( const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const sshCertificateDAL = sshCertificateDALFactory(db); + const sshCertificateBodyDAL = sshCertificateBodyDALFactory(db); + const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); + const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); + const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); + const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); @@ -541,7 +554,11 @@ export const registerRoutes = async ( const orgService = orgServiceFactory({ userAliasDAL, + queueService, identityMetadataDAL, + secretDAL, + secretV2BridgeDAL, + folderDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -562,6 +579,7 @@ export const registerRoutes = async ( groupDAL, orgBotDAL, oidcConfigDAL, + loginService, projectBotService }); const signupService = authSignupServiceFactory({ @@ -710,6 +728,22 @@ export const registerRoutes = async ( queueService }); + const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + sshCertificateDAL, + sshCertificateBodyDAL, + kmsService, + permissionService + }); + + const sshCertificateTemplateService = sshCertificateTemplateServiceFactory({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -779,10 +813,58 @@ export const registerRoutes = async ( projectTemplateDAL }); + const integrationAuthService = integrationAuthServiceFactory({ + integrationAuthDAL, + integrationDAL, + permissionService, + projectBotService, + kmsService + }); + + const secretQueueService = secretQueueFactory({ + keyStore, + queueService, + secretDAL, + folderDAL, + integrationAuthService, + projectBotService, + integrationDAL, + secretImportDAL, + projectEnvDAL, + webhookDAL, + orgDAL, + auditLogService, + userDAL, + projectMembershipDAL, + smtpService, + projectDAL, + projectBotDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + kmsService, + secretVersionV2BridgeDAL, + secretV2BridgeDAL, + secretVersionTagV2BridgeDAL, + secretRotationDAL, + integrationAuthDAL, + snapshotDAL, + snapshotSecretV2BridgeDAL, + secretApprovalRequestDAL, + projectKeyDAL, + projectUserMembershipRoleDAL, + orgService + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, + secretDAL, + secretV2BridgeDAL, + queueService, projectQueue: projectQueueService, + projectBotService, identityProjectDAL, identityOrgMembershipDAL, projectKeyDAL, @@ -798,6 +880,9 @@ export const registerRoutes = async ( certificateDAL, pkiAlertDAL, pkiCollectionDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -862,48 +947,6 @@ export const registerRoutes = async ( projectDAL }); - const integrationAuthService = integrationAuthServiceFactory({ - integrationAuthDAL, - integrationDAL, - permissionService, - projectBotService, - kmsService - }); - const secretQueueService = secretQueueFactory({ - keyStore, - queueService, - secretDAL, - folderDAL, - integrationAuthService, - projectBotService, - integrationDAL, - secretImportDAL, - projectEnvDAL, - webhookDAL, - orgDAL, - auditLogService, - userDAL, - projectMembershipDAL, - smtpService, - projectDAL, - projectBotDAL, - secretVersionDAL, - secretBlindIndexDAL, - secretTagDAL, - secretVersionTagDAL, - kmsService, - secretVersionV2BridgeDAL, - secretV2BridgeDAL, - secretVersionTagV2BridgeDAL, - secretRotationDAL, - integrationAuthDAL, - snapshotDAL, - snapshotSecretV2BridgeDAL, - secretApprovalRequestDAL, - projectKeyDAL, - projectUserMembershipRoleDAL, - orgService - }); const secretImportService = secretImportServiceFactory({ licenseService, projectBotService, @@ -1232,6 +1275,7 @@ export const registerRoutes = async ( auditLogDAL, queueService, secretVersionDAL, + secretDAL, secretFolderVersionDAL: folderVersionDAL, snapshotDAL, identityAccessTokenDAL, @@ -1386,6 +1430,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + sshCertificateAuthority: sshCertificateAuthorityService, + sshCertificateTemplate: sshCertificateTemplateService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 1327faeb1..104898099 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -16,6 +16,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; +import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -29,9 +30,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - organizations: OrganizationsSchema.extend({ - orgAuthMethod: z.string() - }).array() + organizations: sanitizedOrganizationSchema + .extend({ + orgAuthMethod: z.string() + }) + .array() }) } }, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 761020d74..68d13842c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -137,7 +137,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .enum(["true", "false"]) .default("false") .transform((value) => value === "true"), - type: z.enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, "all"]).optional() + type: z + .enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"]) + .optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index cb630b143..8ca105ad4 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -10,6 +10,7 @@ import { UsersSchema } from "@app/db/schemas"; import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -363,21 +364,35 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: OrganizationsSchema + organization: OrganizationsSchema, + accessToken: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), - handler: async (req) => { + handler: async (req, res) => { if (req.auth.actor !== ActorType.USER) return; - const organization = await server.services.org.deleteOrganizationById( - req.permission.id, - req.params.organizationId, - req.permission.authMethod, - req.permission.orgId - ); - return { organization }; + const cfg = getConfig(); + + const { organization, tokens } = await server.services.org.deleteOrganizationById({ + userId: req.permission.id, + orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + authorizationHeader: req.headers.authorization, + userAgentHeader: req.headers["user-agent"], + ipAddress: req.realIp + }); + + void res.setCookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED + }); + + return { organization, accessToken: tokens.accessToken }; } }); }; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e9da8d17..84d2ee6cd 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -10,6 +10,9 @@ import { } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -500,4 +503,101 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { certificateTemplates }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + querystring: z.object({ + offset: z.coerce.number().default(0).describe(PROJECTS.LIST_SSH_CERTIFICATES.offset), + limit: z.coerce.number().default(25).describe(PROJECTS.LIST_SSH_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(sanitizedSshCertificate), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectSshCertificates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId, + offset: req.query.offset, + limit: req.query.limit + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CERTIFICATE_TEMPLATES.projectId) + }), + response: { + 200: z.object({ + certificateTemplates: z.array(sanitizedSshCertificateTemplate) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectSshCertificateTemplates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { certificateTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + response: { + 200: z.object({ + cas: z.array(sanitizedSshCa) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectSshCas({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { cas }; + } + }); }; diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index a52a45fa9..851d9c4ff 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -1,10 +1,11 @@ import { z } from "zod"; -import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; +import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ @@ -134,7 +135,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { description: "Return organizations that current user is part of", response: { 200: z.object({ - organizations: OrganizationsSchema.array() + organizations: sanitizedOrganizationSchema.array() }) } }, diff --git a/backend/src/services/auth-token/auth-token-dal.ts b/backend/src/services/auth-token/auth-token-dal.ts index c058c13e8..221b691cf 100644 --- a/backend/src/services/auth-token/auth-token-dal.ts +++ b/backend/src/services/auth-token/auth-token-dal.ts @@ -12,9 +12,12 @@ export type TTokenDALFactory = ReturnType; export const tokenDALFactory = (db: TDbClient) => { const authOrm = ormify(db, TableName.AuthTokens); - const findOneTokenSession = async (filter: Partial): Promise => { + const findOneTokenSession = async ( + filter: Partial, + tx?: Knex + ): Promise => { try { - const doc = await db.replicaNode()(TableName.AuthTokenSession).where(filter).first(); + const doc = await (tx || db.replicaNode())(TableName.AuthTokenSession).where(filter).first(); return doc; } catch (error) { throw new DatabaseError({ error, name: "FindOneTokenSession" }); @@ -54,10 +57,11 @@ export const tokenDALFactory = (db: TDbClient) => { const insertTokenSession = async ( userId: string, ip: string, - userAgent: string + userAgent: string, + tx?: Knex ): Promise => { try { - const [session] = await db(TableName.AuthTokenSession) + const [session] = await (tx || db)(TableName.AuthTokenSession) .insert({ userId, ip, diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 321abb5b3..c0bb7dc17 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import bcrypt from "bcrypt"; +import { Knex } from "knex"; import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; @@ -123,14 +124,13 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu return deletedToken?.[0]; }; - const getUserTokenSession = async ({ - userId, - ip, - userAgent - }: TIssueAuthTokenDTO): Promise => { - let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent }); + const getUserTokenSession = async ( + { userId, ip, userAgent }: TIssueAuthTokenDTO, + tx?: Knex + ): Promise => { + let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent }, tx); if (!session) { - session = await tokenDAL.insertTokenSession(userId, ip, userAgent); + session = await tokenDAL.insertTokenSession(userId, ip, userAgent, tx); } return session; }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index dea41e60b..8dfe69643 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -1,5 +1,6 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; +import { Knex } from "knex"; import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; @@ -50,13 +51,13 @@ export const authLoginServiceFactory = ({ * Not exported. This is to update user device list * If new device is found. Will be saved and a mail will be send */ - const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string) => { + const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string, tx?: Knex) => { const devices = await UserDeviceSchema.parseAsync(user.devices || []); const isDeviceSeen = devices.some((device) => device.ip === ip && device.userAgent === userAgent); if (!isDeviceSeen) { const newDeviceList = devices.concat([{ ip, userAgent }]); - await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }); + await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }, tx); if (user.email) { await smtpService.sendMail({ template: SmtpTemplates.NewDeviceJoin, @@ -97,30 +98,36 @@ export const authLoginServiceFactory = ({ * Check user device and send mail if new device * generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled */ - const generateUserTokens = async ({ - user, - ip, - userAgent, - organizationId, - authMethod, - isMfaVerified, - mfaMethod - }: { - user: TUsers; - ip: string; - userAgent: string; - organizationId?: string; - authMethod: AuthMethod; - isMfaVerified?: boolean; - mfaMethod?: MfaMethod; - }) => { - const cfg = getConfig(); - await updateUserDeviceSession(user, ip, userAgent); - const tokenSession = await tokenService.getUserTokenSession({ - userAgent, + const generateUserTokens = async ( + { + user, ip, - userId: user.id - }); + userAgent, + organizationId, + authMethod, + isMfaVerified, + mfaMethod + }: { + user: TUsers; + ip: string; + userAgent: string; + organizationId?: string; + authMethod: AuthMethod; + isMfaVerified?: boolean; + mfaMethod?: MfaMethod; + }, + tx?: Knex + ) => { + const cfg = getConfig(); + await updateUserDeviceSession(user, ip, userAgent, tx); + const tokenSession = await tokenService.getUserTokenSession( + { + userAgent, + ip, + userId: user.id + }, + tx + ); if (!tokenSession) throw new Error("Failed to create token"); const accessToken = jwt.sign( diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index efb582d88..d2c87e772 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -15,7 +15,7 @@ import { /* eslint-disable no-bitwise */ export const createSerialNumber = () => { - const randomBytes = crypto.randomBytes(20); + const randomBytes = crypto.randomBytes(20); // 20 bytes = 160 bits randomBytes[0] &= 0x7f; // ensure the first bit is 0 return randomBytes.toString("hex"); }; diff --git a/backend/src/services/integration-auth/integration-sync-secret-fns.ts b/backend/src/services/integration-auth/integration-sync-secret-fns.ts new file mode 100644 index 000000000..df8b990af --- /dev/null +++ b/backend/src/services/integration-auth/integration-sync-secret-fns.ts @@ -0,0 +1,35 @@ +export const isAzureKeyVaultReference = (uri: string) => { + const tryJsonDecode = () => { + try { + return (JSON.parse(uri) as { uri: string }).uri || uri; + } catch { + return uri; + } + }; + + const cleanUri = tryJsonDecode(); + + if (!cleanUri.startsWith("https://")) { + return false; + } + + if (!cleanUri.includes(".vault.azure.net/secrets/")) { + return false; + } + + // 3. Check for non-empty string between https:// and .vault.azure.net/secrets/ + const parts = cleanUri.split(".vault.azure.net/secrets/"); + const vaultName = parts[0].replace("https://", ""); + if (!vaultName) { + return false; + } + + // 4. Check for non-empty secret name + const secretParts = parts[1].split("/"); + const secretName = secretParts[0]; + if (!secretName) { + return false; + } + + return true; +}; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index cd8b8baea..4fd139608 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -51,6 +51,7 @@ import { Integrations, IntegrationUrls } from "./integration-list"; +import { isAzureKeyVaultReference } from "./integration-sync-secret-fns"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -325,11 +326,12 @@ const syncSecretsAzureAppConfig = async ({ }; const metadata = IntegrationMetadataSchema.parse(integration.metadata); - const azureAppConfigSecrets = ( - await getCompleteAzureAppConfigValues( - `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix || ""}*` - ) - ).reduce( + + const azureAppConfigValuesUrl = `${integration.app}/kv?api-version=2023-11-01&key=${metadata.secretPrefix}*${ + metadata.azureLabel ? `&label=${metadata.azureLabel}` : "" + }`; + + const azureAppConfigSecrets = (await getCompleteAzureAppConfigValues(azureAppConfigValuesUrl)).reduce( (accum, entry) => { accum[entry.key] = entry.value; @@ -410,14 +412,24 @@ const syncSecretsAzureAppConfig = async ({ } // create or update secrets on Azure App Config + for await (const key of Object.keys(secrets)) { if (!(key in azureAppConfigSecrets) || secrets[key]?.value !== azureAppConfigSecrets[key]) { await request.put( `${integration.app}/kv/${key}?api-version=2023-11-01`, { - value: secrets[key]?.value + value: secrets[key]?.value, + ...(isAzureKeyVaultReference(secrets[key]?.value || "") && { + content_type: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" + }) }, { + ...(metadata.azureLabel && { + params: { + label: metadata.azureLabel + } + }), + headers: { Authorization: `Bearer ${accessToken}` }, @@ -437,6 +449,11 @@ const syncSecretsAzureAppConfig = async ({ headers: { Authorization: `Bearer ${accessToken}` }, + ...(metadata.azureLabel && { + params: { + label: metadata.azureLabel + } + }), // we force IPV4 because docker setup fails with ipv6 httpsAgent: new https.Agent({ family: 4 diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts index d047a0c11..de4790188 100644 --- a/backend/src/services/integration/integration-schema.ts +++ b/backend/src/services/integration/integration-schema.ts @@ -35,6 +35,8 @@ export const IntegrationMetadataSchema = z.object({ .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + azureLabel: z.string().optional().describe(INTEGRATION.CREATE.metadata.azureLabel), + githubVisibility: z .union([z.literal("selected"), z.literal("private"), z.literal("all")]) .optional() diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts new file mode 100644 index 000000000..8f5b85403 --- /dev/null +++ b/backend/src/services/org/org-schema.ts @@ -0,0 +1,16 @@ +import { OrganizationsSchema } from "@app/db/schemas"; + +export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ + id: true, + name: true, + customerId: true, + slug: true, + createdAt: true, + updatedAt: true, + authEnforced: true, + scimEnabled: true, + kmsDefaultKeyId: true, + defaultMembershipRole: true, + enforceMfa: true, + selectedMfaMethod: true +}); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 33931bf26..73b8c04e5 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -31,11 +31,13 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedErro import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TQueueServiceFactory } from "@app/queue"; import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; -import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { TAuthLoginFactory } from "../auth/auth-login-service"; +import { ActorAuthMethod, ActorType, AuthMethod, AuthModeJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; @@ -47,6 +49,10 @@ import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -69,6 +75,9 @@ import { type TOrgServiceFactoryDep = { userAliasDAL: Pick; + secretDAL: Pick; + secretV2BridgeDAL: Pick; + folderDAL: Pick; orgDAL: TOrgDALFactory; orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; @@ -97,6 +106,8 @@ type TOrgServiceFactoryDep = { projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; + queueService: Pick; + loginService: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -104,6 +115,9 @@ export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ userAliasDAL, orgDAL, + secretDAL, + secretV2BridgeDAL, + folderDAL, userDAL, groupDAL, orgRoleDAL, @@ -124,7 +138,9 @@ export const orgServiceFactory = ({ projectBotDAL, projectUserMembershipRoleDAL, identityMetadataDAL, - projectBotService + projectBotService, + queueService, + loginService }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -419,24 +435,88 @@ export const orgServiceFactory = ({ /* * Delete organization by id * */ - const deleteOrganizationById = async ( - userId: string, - orgId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const deleteOrganizationById = async ({ + userId, + authorizationHeader, + userAgentHeader, + ipAddress, + orgId, + actorAuthMethod, + actorOrgId + }: { + userId: string; + authorizationHeader?: string; + userAgentHeader?: string; + ipAddress: string; + orgId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }) => { const { membership } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) + if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) { throw new ForbiddenRequestError({ name: "DeleteOrganizationById", message: "Insufficient privileges" }); - - const organization = await orgDAL.deleteById(orgId); - if (organization.customerId) { - await licenseService.removeOrgCustomer(organization.customerId); } - return organization; + + if (!authorizationHeader) { + throw new UnauthorizedError({ name: "Authorization header not set on request." }); + } + + if (!userAgentHeader) { + throw new BadRequestError({ name: "User agent not set on request." }); + } + + const cfg = getConfig(); + const authToken = authorizationHeader.replace("Bearer ", ""); + + const decodedToken = jwt.verify(authToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload; + if (!decodedToken.authMethod) throw new UnauthorizedError({ name: "Auth method not found on existing token" }); + + const response = await orgDAL.transaction(async (tx) => { + const projects = await projectDAL.find({ orgId }, { tx }); + + for await (const project of projects) { + await fnDeleteProjectSecretReminders(project.id, { + secretDAL, + secretV2BridgeDAL, + queueService, + projectBotService, + folderDAL + }); + } + + const deletedOrg = await orgDAL.deleteById(orgId, tx); + + if (deletedOrg.customerId) { + await licenseService.removeOrgCustomer(deletedOrg.customerId); + } + + // Generate new tokens without the organization ID present + const user = await userDAL.findById(userId, tx); + const { access: accessToken, refresh: refreshToken } = await loginService.generateUserTokens( + { + user, + authMethod: decodedToken.authMethod, + ip: ipAddress, + userAgent: userAgentHeader, + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod + }, + tx + ); + + return { + organization: deletedOrg, + tokens: { + accessToken, + refreshToken + } + }; + }); + + return response; }; /* * Org membership management diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index c8a86f499..fc302c4c8 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -8,12 +8,16 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; +import { TQueueServiceFactory } from "@app/queue"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; @@ -28,13 +32,17 @@ import { TOrgServiceFactory } from "../org/org-service"; import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal"; import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { getPredefinedRoles } from "../project-role/project-role-fns"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -52,6 +60,9 @@ import { TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, TListProjectsDTO, + TListProjectSshCasDTO, + TListProjectSshCertificatesDTO, + TListProjectSshCertificateTemplatesDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -74,7 +85,10 @@ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; - folderDAL: TSecretFolderDALFactory; + projectBotService: Pick; + folderDAL: Pick; + secretDAL: Pick; + secretV2BridgeDAL: Pick; projectEnvDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: TIdentityProjectDALFactory; @@ -89,9 +103,14 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateTemplateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; + queueService: Pick; + orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; @@ -112,9 +131,13 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + secretDAL, + secretV2BridgeDAL, projectQueue, projectKeyDAL, permissionService, + queueService, + projectBotService, orgDAL, userDAL, folderDAL, @@ -132,6 +155,9 @@ export const projectServiceFactory = ({ certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, keyStore, kmsService, projectBotDAL, @@ -424,6 +450,14 @@ export const projectServiceFactory = ({ await userDAL.deleteById(projectGhostUser.id, tx); } + await fnDeleteProjectSecretReminders(project.id, { + secretDAL, + secretV2BridgeDAL, + queueService, + projectBotService, + folderDAL + }); + return delProject; }); @@ -441,7 +475,12 @@ export const projectServiceFactory = ({ const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type); if (includeRoles) { - const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod); + const { permission } = await permissionService.getUserOrgPermission( + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -896,6 +935,118 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of SSH CAs for project + */ + const listProjectSshCas = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCasDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const cas = await sshCertificateAuthorityDAL.find( + { + projectId + }, + { sort: [["updatedAt", "desc"]] } + ); + + return cas; + }; + + /** + * Return list of SSH certificates for project + */ + const listProjectSshCertificates = async ({ + limit = 25, + offset = 0, + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificatesDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificates = await sshCertificateDAL.find( + { + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await sshCertificateDAL.countSshCertificatesInProject(projectId); + + return { certificates, totalCount: count }; + }; + + /** + * Return list of SSH certificate templates for project + */ + const listProjectSshCertificateTemplates = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificateTemplatesDTO) => { + const { permission, ForbidOnInvalidProjectType } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbidOnInvalidProjectType(ProjectType.SSH); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }); + + return { certificateTemplates }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -1129,6 +1280,9 @@ export const projectServiceFactory = ({ listProjectAlerts, listProjectPkiCollections, listProjectCertificateTemplates, + listProjectSshCas, + listProjectSshCertificates, + listProjectSshCertificateTemplates, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 29261da4f..2c6b8e2da 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -132,6 +132,13 @@ export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCasDTO = TProjectPermission; +export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCertificatesDTO = { + offset: number; + limit: number; +} & TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index dab70806f..aa1ed9d25 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -5,6 +5,7 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; +import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; @@ -16,6 +17,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { identityUniversalAuthClientSecretDAL: Pick; secretVersionDAL: Pick; secretVersionV2DAL: Pick; + secretDAL: Pick; secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; @@ -30,6 +32,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ snapshotDAL, secretVersionDAL, secretFolderVersionDAL, + secretDAL, identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL, @@ -37,6 +40,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); + await secretDAL.pruneSecretReminders(queueService); await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index 0d4ae0cda..cbaf7ddcd 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -5,6 +5,8 @@ import { TDbClient } from "@app/db"; import { SecretsSchema, SecretType, TableName, TSecrets, TSecretsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName, TQueueServiceFactory } from "@app/queue"; export type TSecretDALFactory = ReturnType; @@ -339,6 +341,94 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const pruneSecretReminders = async (queueService: TQueueServiceFactory) => { + const REMINDER_PRUNE_BATCH_SIZE = 5_000; + const MAX_RETRY_ON_FAILURE = 3; + let numberOfRetryOnFailure = 0; + let deletedReminderCount = 0; + + logger.info(`${QueueName.DailyResourceCleanUp}: secret reminders started`); + + try { + const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder); + const reminderJobs = repeatableJobs + .map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key })) + .filter(Boolean); + + if (reminderJobs.length === 0) { + logger.info(`${QueueName.DailyResourceCleanUp}: no reminder jobs found`); + return; + } + + for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) { + try { + const batchIds = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE).map((r) => r.secretId); + + const payload = { + $in: { + id: batchIds + } + }; + + const opts = { + limit: REMINDER_PRUNE_BATCH_SIZE + }; + + // Find existing secrets with pagination + // eslint-disable-next-line no-await-in-loop + const [secrets, secretsV2] = await Promise.all([ + ormify(db, TableName.Secret).find(payload, opts), + ormify(db, TableName.SecretV2).find(payload, opts) + ]); + + const foundSecretIds = new Set([ + ...secrets.map((secret) => secret.id), + ...secretsV2.map((secret) => secret.id) + ]); + + // Find IDs that don't exist in either table + const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId)); + + // Delete reminders for non-existent secrets + for (const secretId of secretIdsNotFound) { + const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey; + + if (jobKey) { + // eslint-disable-next-line no-await-in-loop + await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey); + deletedReminderCount += 1; + } + } + + numberOfRetryOnFailure = 0; + } catch (error) { + numberOfRetryOnFailure += 1; + logger.error(error, `Failed to process batch at offset ${offset}`); + + if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) { + break; + } + + // Retry the current batch + offset -= REMINDER_PRUNE_BATCH_SIZE; + + // eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure)); + } + + // Small delay between batches + // eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } catch (error) { + logger.error(error, "Failed to complete secret reminder pruning"); + } finally { + logger.info( + `${QueueName.DailyResourceCleanUp}: secret reminders completed. Deleted ${deletedReminderCount} reminders` + ); + } + }; + return { ...secretOrm, update, @@ -352,6 +442,7 @@ export const secretDALFactory = (db: TDbClient) => { findByBlindIndexes, upsertSecretReferences, findReferencedSecretReferences, - findAllProjectSecretValues + findAllProjectSecretValues, + pruneSecretReminders }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 65691fcbb..6336c479d 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -19,9 +19,11 @@ import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { fnSecretBulkInsert as fnSecretV2BridgeBulkInsert, fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate, @@ -31,8 +33,10 @@ import { import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { KmsDataKey } from "../kms/kms-types"; import { getBotKeyFnFactory } from "../project-bot/project-bot-fns"; +import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretDALFactory } from "./secret-dal"; import { TCreateManySecretsRawFn, @@ -1138,3 +1142,49 @@ export const decryptSecretWithBot = ( secretComment }; }; + +type TFnDeleteProjectSecretReminders = { + secretDAL: Pick; + secretV2BridgeDAL: Pick; + queueService: Pick; + projectBotService: Pick; + folderDAL: Pick; +}; + +export const fnDeleteProjectSecretReminders = async ( + projectId: string, + { secretDAL, secretV2BridgeDAL, queueService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders +) => { + const projectFolders = await folderDAL.findByProjectId(projectId); + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId, false); + + const projectSecrets = shouldUseSecretV2Bridge + ? await secretV2BridgeDAL.find({ + $in: { folderId: projectFolders.map((folder) => folder.id) }, + $notNull: ["reminderRepeatDays"] + }) + : await secretDAL.find({ + $in: { folderId: projectFolders.map((folder) => folder.id) }, + $notNull: ["secretReminderRepeatDays"] + }); + + const appCfg = getConfig(); + for await (const secret of projectSecrets) { + const repeatDays = shouldUseSecretV2Bridge + ? (secret as { reminderRepeatDays: number }).reminderRepeatDays + : (secret as { secretReminderRepeatDays: number }).secretReminderRepeatDays; + + // We're using the queue service directly to get around conflicting imports. + if (repeatDays) { + await queueService.stopRepeatableJob( + QueueName.SecretReminder, + QueueJobs.SecretReminder, + { + // on prod it this will be in days, in development this will be second + every: appCfg.NODE_ENV === "development" ? secondsToMillis(repeatDays) : daysToMillisecond(repeatDays) + }, + `reminder-${secret.id}` + ); + } + } +}; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 84a8584ee..57d32ab11 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -248,7 +248,9 @@ export const secretQueueFactory = ({ ? secondsToMillis(newSecret.secretReminderRepeatDays) : daysToMillisecond(newSecret.secretReminderRepeatDays), immediately: true - } + }, + removeOnComplete: true, + removeOnFail: true } ); } catch (err) { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 6f058e023..fbf90a7f8 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -491,8 +491,8 @@ export const secretServiceFactory = ({ secretDAL }); - const deletedSecret = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ + const deletedSecret = await secretDAL.transaction(async (tx) => { + const secrets = await fnSecretBulkDelete({ projectId, folderId, actorId, @@ -505,8 +505,19 @@ export const secretServiceFactory = ({ } ], tx - }) - ); + }); + + for await (const secret of secrets) { + if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { + await secretQueueService.removeSecretReminder({ + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }); + } + } + + return secrets; + }); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); @@ -971,8 +982,8 @@ export const secretServiceFactory = ({ secretDAL }); - const secretsDeleted = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ + const secretsDeleted = await secretDAL.transaction(async (tx) => { + const secrets = await fnSecretBulkDelete({ secretDAL, secretQueueService, inputSecrets: inputSecrets.map(({ type, secretName }) => ({ @@ -983,8 +994,19 @@ export const secretServiceFactory = ({ folderId, actorId, tx - }) - ); + }); + + for await (const secret of secrets) { + if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { + await secretQueueService.removeSecretReminder({ + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }); + } + } + + return secrets; + }); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx new file mode 100644 index 000000000..92321fb3c --- /dev/null +++ b/docs/documentation/platform/ssh.mdx @@ -0,0 +1,242 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Infisical SSH" +description: "Learn how to generate SSH credentials to provide secure and centralized SSH access control for your infrastructure." +--- + +## Concept + +Infisical can be used to issue SSH certificates to clients to provide short-lived, secure SSH access to infrastructure; +this improves on many limitations of traditional SSH key-based authentication via mitigation of private key compromise, static key management, +unauthorized access, and SSH key sprawl. + +The following concepts are useful to know when working with Infisical SSH: + +- SSH Certificate Authority (CA): A trusted authority that issues SSH certificates. +- Certificate Template: A set of policies bound to a SSH CA for certificates issued under that template; a CA can possess multiple templates, each with different policies for a different purpose (e.g. for admin versus developer access). +- SSH Certificate: A short-lived, credential issued by the SSH CA granting time-bound access to infrastructure. + +
+ +```mermaid +graph TD + A[SSH CA] + A --> B[Certificate Template A] + A --> C[Certificate Template N] + B --> D[SSH Certificate A] + C --> E[SSH Certificate N] + +``` + +
+ +When using Infisical SSH to provision client access to a remote host, an operator must create a SSH CA in Infisical; a certificate template under it, +specifying policies such as allowed users that can be requested under that template by a client; and configure the host to trust certificates issued by the Infisical SSH CA. + +When a client needs access to a host, they authenticate with Infisical and request a SSH certificate (and optionally key pair) +to be used to access the host for a time-bound session as part of the SSH operation. + +## Client Workflow + +The following sequence diagram illustrates the client workflow for accessing a remote host using an SSH certificate (and optionally key pair) +supplied by Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infisical as Infisical (SSH CA) + participant Host as Remote Host + + Note over Client,Client: Step 1: Client Authentication with Infisical + Client->>Infisical: Send credential(s) to authenticate with Infisical + + Infisical-->>Client: Return access token + + Note over Client,Infisical: Step 2: SSH Certificate Request + Client->>Infisical: Make authenticated request for SSH certificate via either /api/v1/ssh/issue or /api/v1/ssh/sign + + Infisical-->>Client: Return signed SSH certificate (and optionally key pair) + + Note over Client,Client: Step 3: SSH Operation + Client->>Host: SSH into Host using the SSH certificate + + Host-->>Client: Grant access to the host +``` + +At a high-level, Infisical issues a signed SSH certificate to a client that can be used to access a remote host. + +To be more specific: + +1. The client authenticates with Infisical; this can be done using a machine identity [authentication method](/documentation/platform/identities/machine-identities) or a user [authentication method](/documentation/platform/identities/user-identities). +2. The client makes an authenticated request for an SSH certificate via either the `/api/v1/ssh/issue` or `/api/v1/ssh/sign` endpoints. Note that if the client wishes to use an existing SSH key pair, it can use the `/api/v1/ssh/sign` endpoint; otherwise, it can use the `/api/v1/ssh/issue` endpoint to have Infisical issue a new SSH key pair in conjunction with the certificate. +3. The client uses the issued SSH certificate (and potentially SSH key pair) to temporarily access the host. + + + Note that the workflow above requires an operator to perform additional + configuration on the remote host to trust SSH certificates issued by + Infisical. + + +## Guide to Configuring Infisical SSH + +In the following steps, we explore how to configure Infisical SSH to start issuing SSH certificates to clients as well as a remote host to trust these certificates +as part of the SSH operation. + + + + 1.1. Start by creating a SSH project in the SSH tab of your organization. + + ![ssh project create](/images/platform/ssh/ssh-project.png) + + 1.2. Next, create a CA in the **Certificate Authorities** tab of the + project. + + ![ssh create ca](/images/platform/ssh/ssh-create-ca-1.png) + + ![ssh create ca popup](/images/platform/ssh/ssh-create-ca-2.png) + + Here's some guidance on each field: + + - Friendly Name: A friendly name for the CA; this is only for display. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair for the CA. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + + 1.3. Next, create a certificate template in the **Certificate Templates** section of the newly-created CA. + + A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA. + + With certificate templates, you can specify, for example, that certificates issued under a template are only allowed for users with a specific username like `ec2-user` or perhaps that the max TTL requested cannot exceed 1 year. + + ![ssh create template](/images/platform/ssh/ssh-create-template-1.png) + + ![ssh create template popup](/images/platform/ssh/ssh-create-template-2.png) + + Here's some guidance on each field: + + - SSH Template Name: A name for the certificate template; this must be a valid slug. + - Allowed Users: A comma-separated list of valid usernames (e.g. `ec2-user`) on the remote host for which a client can request a certificate for. If you wish to allow a client to request a certificate for any username, set this to `*`; alternatively, if left blank, the template will not allow issuance of certificates under any username. + - Allowed Hosts: A comma-separated list of valid hostnames/domains on the remote host for which a client can request a certificate for. Each item in the list can be either a wildcard hostname (e.g. `*.acme.com`), a specific hostname (e.g. `example.com`), an IPv4 address (e.g. `192.168.1.1`), or an IPv6 address. If left empty, the template will not allow any hostnames; if set to `*`, the template will allow any hostname. + - Default TTL: The default Time-to-Live (TTL) for certificates issued under this template when a client does not explicitly specify a TTL in the certificate request. + - Max TTL: The maximum TTL for certificates issued under this template. + - Allow User Certificates: Whether or not to allow issuance of user certificates. + - Allow Host Certificates: Whether or not to allow issuance of host certificates. + - Allow Custom Key IDs: Whether or not to allow clients to specify a custom key ID to be included on the certificate as part of the certificate request. + + 1.4. Finally, add the user(s) you wish to be able to request a SSH certificate to the SSH project through the **Access Control** tab. + + + + + 2.1. Begin by downloading the CA's public key from the CA's details section. + + ![ssh ca public key](/images/platform/ssh/ssh-ca-public-key.png) + + + The CA's public key can also be retrieved programmatically via API by making a `GET` request to the `/ssh/ca//public-key` endpoint. + + + 2.2. Next, create a file containing this public key in the SSH folder of the remote host; we'll call the file `ca.pub`. + + This would result in the file at the path `/etc/ssh/ca.pub`. + + 2.3. Next, add the following lines to the `/etc/ssh/sshd_config` file on the remote host. + + ```bash + TrustedUserCAKeys /etc/ssh/ca.pub + + PubkeyAcceptedKeyTypes=+ssh-rsa,ssh-rsa-cert-v01@openssh.com + ``` + + 2.4. Finally, reload the SSH daemon on the remote host to apply the changes. + + ```bash + sudo systemctl reload sshd + ``` + + At this point, the remote host is configured to trust SSH certificates issued by the Infisical SSH CA. + + + + +## Guide to Using Infisical SSH to Access a Host + +We show how to obtain a SSH certificate (and optionally a new SSH key pair) for a client to access a host via CLI: + + + + +```bash +infisical login +``` + + + + Depending on the use-case, a client may either request a SSH certificate along with a new SSH key pair or obtain a SSH certificate for an existing SSH key pair to access a host. + + + + If you wish to obtain a new SSH key pair in conjunction with the SSH certificate, then you can use the `infisical ssh issue-credentials` command. + + ```bash + infisical ssh issue-credentials --certificateTemplateId= --principals= + ``` + + The following flags may be relevant: + + - `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. + - `outFilePath` (optional): The path to the file to write the SSH certificate to. + + + If `outFilePath` is not specified, the SSH certificate will be written to the current working directory where the command is run. + + + + + If you have an existing SSH key pair, then you can use the `infisical ssh sign-key` command with either + the `--publicKey` flag or the `--publicKeyFilePath` flag to obtain a SSH certificate corresponding to + the existing credential. + + ```bash + infisical ssh sign-key --publicKeyFilePath= --certificateTemplateId= --principals= + ``` + + The following flags may be relevant: + + - `publicKey`: The public key to sign. + - `publicKeyFilePath`: The path to the public key file to sign. + - `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. + - `outFilePath` (optional): The path to the file to write the SSH certificate to. + + + If `outFilePath` is not specified but `publicKeyFilePath` is then the SSH certificate will be written to the directory of the public key file; if the public key file is called `id_rsa.pub`, then the file containing the SSH certificate will be called `id_rsa-cert.pub`. + + Otherwise, if `outFilePath` is not specified, the SSH certificate will be written to the current working directory where the command is run. + + + + + + + + Once you have obtained the SSH certificate, you can use it to SSH into the desired host. + + ```bash + ssh -i /path/to/private_key.pem \ + -o CertificateFile=/path/to/ssh-cert.pub \ + username@hostname + ``` + + + We recommend setting up aliases so you can more easily SSH into the desired host. + + For example, you may set up an SSH alias using the SSH client configuration file (usually `~/.ssh/config`), defining a host alias including the file path to the issued SSH credential(s). + + + + + + + Note that the above workflow can be executed via API or other client methods + such as SDK. + diff --git a/docs/images/platform/ssh/ssh-ca-public-key.png b/docs/images/platform/ssh/ssh-ca-public-key.png new file mode 100644 index 000000000..42653df4a Binary files /dev/null and b/docs/images/platform/ssh/ssh-ca-public-key.png differ diff --git a/docs/images/platform/ssh/ssh-create-ca-1.png b/docs/images/platform/ssh/ssh-create-ca-1.png new file mode 100644 index 000000000..e9a5b7f06 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-ca-1.png differ diff --git a/docs/images/platform/ssh/ssh-create-ca-2.png b/docs/images/platform/ssh/ssh-create-ca-2.png new file mode 100644 index 000000000..63025025f Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-ca-2.png differ diff --git a/docs/images/platform/ssh/ssh-create-template-1.png b/docs/images/platform/ssh/ssh-create-template-1.png new file mode 100644 index 000000000..9d9420948 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-template-1.png differ diff --git a/docs/images/platform/ssh/ssh-create-template-2.png b/docs/images/platform/ssh/ssh-create-template-2.png new file mode 100644 index 000000000..7b93e6d80 Binary files /dev/null and b/docs/images/platform/ssh/ssh-create-template-2.png differ diff --git a/docs/images/platform/ssh/ssh-project.png b/docs/images/platform/ssh/ssh-project.png new file mode 100644 index 000000000..0b57f9245 Binary files /dev/null and b/docs/images/platform/ssh/ssh-project.png differ diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 6defea119..9d1ba5145 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -43,13 +43,28 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git **Namespace-scoped Installation** - The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. + The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. This is useful for: + + - **Enhanced Security**: Limit the operator's permissions to only specific namespaces instead of cluster-wide access + - **Multi-tenant Clusters**: Run separate operator instances for different teams or applications + - **Resource Isolation**: Ensure operators in different namespaces don't interfere with each other + - **Development & Testing**: Run development and production operators side by side in isolated namespaces + + **Note**: For multiple namespace-scoped installations, only the first installation should install CRDs. Subsequent installations should set `installCRDs: false` to avoid conflicts. ```bash - helm install operator infisical-helm-charts/secrets-operator \ - --namespace your-namespace \ - --set scopedNamespace=your-namespace \ + # First namespace installation (with CRDs) + helm install operator-namespace1 infisical-helm-charts/secrets-operator \ + --namespace first-namespace \ + --set scopedNamespace=first-namespace \ --set scopedRBAC=true + + # Subsequent namespace installations + helm install operator-namespace2 infisical-helm-charts/secrets-operator \ + --namespace another-namespace \ + --set scopedNamespace=another-namespace \ + --set scopedRBAC=true \ + --set installCRDs=false ``` When scoped to a namespace, the operator will: @@ -61,14 +76,19 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git The default configuration gives cluster-wide access: ```yaml + installCRDs: true # Install CRDs (set to false for additional namespace installations) scopedNamespace: "" # Empty for cluster-wide access scopedRBAC: false # Cluster-wide permissions ``` + If you want to install operators in multiple namespaces simultaneously: + - Make sure to set `installCRDs: false` for all but one of the installations to avoid conflicts, as CRDs are cluster-wide resources. + - Use unique release names for each installation (e.g., operator-namespace1, operator-namespace2). + - - For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. - Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags). + + For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. + Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags). The command below will install the most recent version of the Kubernetes operator. However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version. @@ -714,6 +734,7 @@ Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets retrieved from the specified scope. Secrets are structured as follows: + ```golang type TemplateSecret struct { Value string `json:"value"` @@ -722,6 +743,7 @@ type TemplateSecret struct { ``` #### Example template configuration: + ```golang managedSecretReference: secretName: managed-secret @@ -733,19 +755,23 @@ type TemplateSecret struct { ``` When you run the following command: + ```bash kubectl get secret managed-secret -o jsonpath='{.data}' ``` You'll receive Kubernetes secrets output that includes the NEW_KEY: + ```bash {... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="} ``` When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be: + ```bash {"NEW_KEY":"LyBoZWxsbw=="} ``` + Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. @@ -805,9 +831,9 @@ type: Opaque -### Apply the Infisical CRD to your cluster +### Apply the InfisicalSecret CRD to your cluster -Once you have configured the Infisical CRD with the required fields, you can apply it to your cluster. +Once you have configured the InfisicalSecret CRD with the required fields, you can apply it to your cluster. After applying, you should notice that the managed secret has been created in the desired namespace your specified. ``` @@ -1006,12 +1032,12 @@ stringData: -----END CERTIFICATE----- ``` -## Auto redeployment +### Auto redeployment Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed. To address this, we added functionality to automatically redeploy your deployment when its managed secret updates. -### Enabling auto redeploy +#### Enabling auto redeploy To enable auto redeployment you simply have to add the following annotation to the deployment that consumes a managed secret @@ -1054,6 +1080,419 @@ spec: When a secret change occurs, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. Then, for each deployment that has this annotation present, a rolling update will be triggered. + +## Push Secrets to Infisical + + +### Example usage + +Below is a sample InfisicalPushSecret CRD that pushes secrets defined in a Kubernetes secret to Infisical. + +After filling out the fields in the InfisicalPushSecret CRD, you can apply it directly to your cluster. + +Before applying the InfisicalPushSecret CRD, you need to create a Kubernetes secret containing the secrets you want to push to Infisical. An example can be seen below the InfisicalPushSecret CRD. + +```bash + kubectl apply -f source-secret.yaml +``` + +After applying the soruce-secret.yaml file, you are ready to apply the InfisicalPushSecret CRD. + +```bash + kubectl apply -f infisical-push-secret.yaml +``` + +After applying the InfisicalPushSecret CRD, you should notice that the secrets you have defined in your source-secret.yaml file have been pushed to your specified destination in Infisical. + +```yaml infisical-push-secret.yaml + apiVersion: secrets.infisical.com/v1alpha1 + kind: InfisicalPushSecret + metadata: + name: infisical-push-secret-demo + spec: + resyncInterval: 1m + hostAPI: https://app.infisical.com/api + + # Optional, defaults to no replacement. + updatePolicy: Replace # If set to replace, existing secrets inside Infisical will be replaced by the value of the PushSecret on sync. + + # Optional, defaults to no deletion. + deletionPolicy: Delete # If set to delete, the secret(s) inside Infisical managed by the operator, will be deleted if the InfisicalPushSecret CRD is deleted. + + destination: + projectId: + environmentSlug: + secretsPath: + + push: + secret: + secretName: push-secret-demo # Secret CRD + secretNamespace: default + + # Only have one authentication method defined or you are likely to run into authentication issues. + # Remove all except one authentication method. + authentication: + awsIamAuth: + identityId: + azureAuth: + identityId: + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: + gcpIdTokenAuth: + identityId: + kubernetesAuth: + identityId: + serviceAccountRef: + name: + namespace: + universalAuth: + credentialsRef: + secretName: # universal-auth-credentials + secretNamespace: # default +``` + +```yaml source-secret.yaml + apiVersion: v1 + kind: Secret + metadata: + name: push-secret-demo + namespace: default + stringData: # can also be "data", but needs to be base64 encoded + API_KEY: some-api-key + DATABASE_URL: postgres://127.0.0.1:5432 + ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab +``` + +### InfisicalPushSecret CRD properties + + + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + ` https://your-self-hosted-instace.com/api` + + When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. + + + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + To achieve this, use the following address for the hostAPI field: + + ``` bash + http://..svc.cluster.local:4000/api + ``` + + Make sure to replace `` and `` with the appropriate values for your backend service and namespace. + + + + + + + The `resyncInterval` is a string-formatted duration that defines the time between each resync. + + The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. + + The following units are supported: + - `s` for seconds (must be at least 5 seconds) + - `m` for minutes + - `h` for hours + - `d` for days + - `w` for weeks + + The default value is `1m` (1 minute). + + Valid intervals examples: + ```yaml + resyncInterval: 5s # 10 seconds + resyncInterval: 10s # 10 seconds + resyncInterval: 5m # 5 minutes + resyncInterval: 1h # 1 hour + resyncInterval: 1d # 1 day + ``` + + + + + The field is optional and will default to `None` if not defined. + + The update policy defines how the operator should handle conflicting secrets when pushing secrets to Infisical. + + Valid values are `None` and `Replace`. + + Behavior of each policy: + - `None`: The operator will not override existing secrets in Infisical. If a secret with the same key already exists, the operator will skip pushing that secret, and the secret will not be managed by the operator. + - `Replace`: The operator will replace existing secrets in Infisical with the new secrets. If a secret with the same key already exists, the operator will update the secret with the new value. + + ```yaml + spec: + updatePolicy: Replace + ``` + + + + + This field is optional and will default to `None` if not defined. + + The deletion policy defines what the operator should do in case the InfisicalPushSecret CRD is deleted. + + Valid values are `None` and `Delete`. + + Behavior of each policy: + - `None`: The operator will not delete the secrets in Infisical when the InfisicalPushSecret CRD is deleted. + - `Delete`: The operator will delete the secrets in Infisical that are managed by the operator when the InfisicalPushSecret CRD is deleted. + + ```yaml + spec: + deletionPolicy: Delete + ``` + + + + The `destination` field is used to specify where you want to create the secrets in Infisical. The required fields are `projectId`, `environmentSlug`, and `secretsPath`. + + ```yaml + spec: + destination: + projectId: + environmentSlug: + secretsPath: + ``` + + + The project ID where you want to create the secrets in Infisical. + + + + The environment slug where you want to create the secrets in Infisical. + + + + The path where you want to create the secrets in Infisical. The root path is `/`. + + + + + + The `push` field is used to define what you want to push to Infisical. Currently the operator only supports pushing Kubernetes secrets to Infisical. An example of the `push` field is shown below. + + + + + The `secret` field is used to define the Kubernetes secret you want to push to Infisical. The required fields are `secretName` and `secretNamespace`. + + + + Example usage of the `push.secret` field: + + ```yaml infisical-push-secret.yaml + push: + secret: + secretName: push-secret-demo + secretNamespace: default + ``` + + ```yaml push-secret-demo.yaml + apiVersion: v1 + kind: Secret + metadata: + name: push-secret-demo + namespace: default + # Pass in the secrets you wish to push to Infisical + stringData: + API_KEY: some-api-key + DATABASE_URL: postgres://127.0.0.1:5432 + ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab + ``` + + + + + + + The `authentication` field dictates which authentication method to use when pushing secrets to Infisical. + The available authentication methods are `universalAuth`, `kubernetesAuth`, `awsIamAuth`, `azureAuth`, `gcpIdTokenAuth`, and `gcpIamAuth`. + + + + The universal authentication method is one of the easiest ways to get started with Infisical. Universal Auth works anywhere and is not tied to any specific cloud provider. + [Read more about Universal Auth](/documentation/platform/identities/universal-auth). + + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `credentialsRef`: The name and namespace of the Kubernetes secret that stores the service token. + - `credentialsRef.secretName`: The name of the Kubernetes secret. + - `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret. + + Example: + + ```yaml + # infisical-push-secret.yaml + spec: + universalAuth: + credentialsRef: + secretName: + secretNamespace: + ``` + + ```yaml + # machine-identity-credentials.yaml + apiVersion: v1 + kind: Secret + metadata: + name: universal-auth-credentials + type: Opaque + stringData: + clientId: + clientSecret: + ``` + + + + The Kubernetes machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within a Kubernetes environment. + [Read more about Kubernetes Auth](/documentation/platform/identities/kubernetes-auth). + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `serviceAccountRef`: The name and namespace of the service account that will be used to authenticate with Infisical. + - `serviceAccountRef.name`: The name of the service account. + - `serviceAccountRef.namespace`: The namespace of the service account. + + Example: + + ```yaml + spec: + kubernetesAuth: + identityId: + serviceAccountRef: + name: + namespace: + ``` + + + + The AWS IAM machine identity authentication method is used to authenticate with Infisical. + [Read more about AWS IAM Auth](/documentation/platform/identities/aws-auth). + + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + + Example: + + ```yaml + spec: + authentication: + awsIamAuth: + identityId: + ``` + + + + The AWS IAM machine identity authentication method is used to authenticate with Infisical. Azure Auth can only be used from within an Azure environment. + [Read more about Azure Auth](/documentation/platform/identities/azure-auth). + + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + + Example: + + ```yaml + spec: + authentication: + azureAuth: + identityId: + ``` + + + The GCP IAM machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used both within and outside GCP environments. + [Read more about Azure Auth](/documentation/platform/identities/gcp-auth). + + + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `serviceAccountKeyFilePath`: The path to the GCP service account key file. + + Example: + + ```yaml + spec: + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: + ``` + + + The GCP ID Token machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within GCP environments. + [Read more about Azure Auth](/documentation/platform/identities/gcp-auth). + + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + + Example: + + ```yaml + spec: + gcpIdTokenAuth: + identityId: + ``` + + + + + + + This block defines the TLS settings to use for connecting to the Infisical + instance. + + Fields: + + This block defines the reference to the CA certificate to use for connecting to the Infisical instance with SSL/TLS. + + Valid fields: + - `secretName`: The name of the Kubernetes secret containing the CA certificate to use for connecting to the Infisical instance with SSL/TLS. + - `secretNamespace`: The namespace of the Kubernetes secret containing the CA certificate to use for connecting to the Infisical instance with SSL/TLS. + - `key`: The name of the key in the Kubernetes secret which contains the value of the CA certificate to use for connecting to the Infisical instance with SSL/TLS. + + Example: + + ```yaml + tls: + caRef: + secretName: custom-ca-certificate + secretNamespace: default + key: ca.crt + ``` + + + + + +### Applying the InfisicalPushSecret CRD to your cluster + +Once you have configured the `InfisicalPushSecret` CRD with the required fields, you can apply it to your cluster. +After applying, you should notice that the secrets have been pushed to Infisical. + +```bash + kubectl apply -f source-push-secret.yaml # The secret that you're referencing in the InfisicalPushSecret CRD push.secret field + kubectl apply -f example-infisical-push-secret-crd.yaml # The InfisicalPushSecret CRD itself +``` + +### Connecting to instances with private/self-signed certificate + +To connect to Infisical instances behind a private/self-signed certificate, you can configure the TLS settings in the `InfisicalPushSecret` CRD +to point to a CA certificate stored in a Kubernetes secret resource. + +```yaml +spec: + hostAPI: https://app.infisical.com/api + resyncInterval: 30s + tls: + caRef: + secretName: custom-ca-certificate + secretNamespace: default + key: ca.crt + authentication: + # ... +``` + + ## Global configuration To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. diff --git a/docs/mint.json b/docs/mint.json index fc31c610e..3a23635c8 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -114,6 +114,7 @@ "documentation/platform/pki/alerting" ] }, + "documentation/platform/ssh", { "group": "Key Management (KMS)", "pages": [ diff --git a/frontend/public/lotties/system-regular-126-verified-hover-verified.json b/frontend/public/lotties/system-regular-126-verified-hover-verified.json new file mode 100644 index 000000000..ce8777c0a --- /dev/null +++ b/frontend/public/lotties/system-regular-126-verified-hover-verified.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-126-verified","ddd":0,"assets":[{"id":"comp_1","nm":"hover-verified","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[6.109,-6.116],[-6.115,-6.108],[0,0],[-4.002,0],[-3.055,3.051],[0,0],[6.107,6.115],[6.115,-6.107],[0,0]],"o":[[-6.115,-6.107],[-6.107,6.116],[0,0],[3.056,3.052],[4.002,0],[0,0],[6.115,-6.108],[-6.109,-6.116],[0,0],[0,0]],"v":[[-69.803,-8.539],[-91.936,-8.526],[-91.922,13.607],[-39.704,65.762],[-28.644,70.339],[-17.584,65.762],[91.922,-43.616],[91.936,-65.749],[69.803,-65.762],[-28.644,32.57]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[263.158,242.187],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[73.521,-47.165],[0,0],[0,0],[11.506,86.895],[0,0],[-39.84,20.453],[-33.146,-7.159]],"o":[[-11.51,86.919],[0,0],[0,0],[-73.503,-47.153],[0,0],[33.146,-7.159],[39.84,20.453],[0,0]],"v":[[149.277,-67.68],[15.49,143.298],[0,153.266],[-15.509,143.287],[-149.274,-67.655],[-154.479,-107.449],[0,-153.936],[154.479,-107.449]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[8.073,1.615],[32.173,20.107],[5.074,-3.172],[35.445,-7.092],[-1.068,-8.164],[0,0],[-81.252,-52.124],[0,0],[-2.944,0],[-2.579,1.66],[0,0],[-12.724,96.093],[0,0]],"o":[[-35.445,-7.092],[-5.074,-3.172],[-32.173,20.107],[-8.073,1.615],[0,0],[12.72,96.068],[0,0],[2.579,1.66],[2.944,0],[0,0],[81.27,-52.136],[0,0],[1.068,-8.164]],"v":[[174.946,-135.138],[8.294,-185.147],[-8.294,-185.147],[-174.946,-135.138],[-187.394,-117.763],[-180.307,-63.572],[-32.428,169.62],[-8.469,185.036],[0,187.526],[8.469,185.036],[32.409,169.632],[180.31,-63.596],[187.394,-117.763]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":1,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[6.109,-6.116],[-6.115,-6.108],[0,0],[-4.002,0],[-3.055,3.051],[0,0],[6.107,6.115],[6.115,-6.107],[0,0]],"o":[[-6.115,-6.107],[-6.107,6.116],[0,0],[3.056,3.052],[4.002,0],[0,0],[6.115,-6.108],[-6.109,-6.116],[0,0],[0,0]],"v":[[-69.803,-8.539],[-91.936,-8.526],[-91.922,13.607],[-39.704,65.762],[-28.644,70.339],[-17.584,65.762],[91.922,-43.616],[91.936,-65.749],[69.803,-65.762],[-28.644,32.57]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[263.158,242.187],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[73.521,-47.165],[0,0],[0,0],[11.506,86.895],[0,0],[-39.84,20.453],[-33.146,-7.159]],"o":[[-11.51,86.919],[0,0],[0,0],[-73.503,-47.153],[0,0],[33.146,-7.159],[39.84,20.453],[0,0]],"v":[[149.277,-67.68],[15.49,143.298],[0,153.266],[-15.509,143.287],[-149.274,-67.655],[-154.479,-107.449],[0,-153.936],[154.479,-107.449]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[8.073,1.615],[32.173,20.107],[5.074,-3.172],[35.445,-7.092],[-1.068,-8.164],[0,0],[-81.252,-52.124],[0,0],[-2.944,0],[-2.579,1.66],[0,0],[-12.724,96.093],[0,0]],"o":[[-35.445,-7.092],[-5.074,-3.172],[-32.173,20.107],[-8.073,1.615],[0,0],[12.72,96.068],[0,0],[2.579,1.66],[2.944,0],[0,0],[81.27,-52.136],[0,0],[1.068,-8.164]],"v":[[174.946,-135.138],[8.294,-185.147],[-8.294,-185.147],[-174.946,-135.138],[-187.394,-117.763],[-180.307,-63.572],[-32.428,169.62],[-8.469,185.036],[0,187.526],[8.469,185.036],[32.409,169.632],[180.31,-63.596],[187.394,-117.763]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.131],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.628],"y":[0]},"t":30,"s":[27]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-11]},{"t":60,"s":[0]}],"ix":10},"p":{"a":0,"k":[249.998,250.004,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.131,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0],[77.333,-49.716],[0,0],[0,0],[0,0],[11.993,91.145],[0,0],[-41.666,26.039]],"o":[[0,0],[-11.99,91.149],[0,0],[0,0],[0,0],[-77.33,-49.714],[0,0],[0,0],[41.667,26.039]],"v":[[171.875,-119.795],[164.746,-65.6],[23.905,156.51],[0.007,171.873],[0,171.875],[-23.904,156.508],[-164.743,-65.592],[-171.875,-119.795],[0,-171.875]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.628,"y":0},"t":21,"s":[{"i":[[0,0],[0,0],[99.436,-63.925],[0,0],[0,0],[0,0],[15.421,117.196],[0,0],[-53.575,33.481]],"o":[[0,0],[-15.417,117.201],[0,0],[0,0],[0,0],[-99.432,-63.922],[0,0],[0,0],[53.576,33.481]],"v":[[191.412,-122.573],[182.245,-52.889],[1.15,232.704],[-29.579,252.458],[-29.588,252.46],[-60.324,232.701],[-241.417,-52.879],[-250.588,-122.573],[-29.588,-189.539]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":41.801,"s":[{"i":[[0,0],[0,0],[56.69,-36.445],[0,0],[0,0],[0,0],[8.792,66.816],[0,0],[-30.544,19.088]],"o":[[0,0],[-8.79,66.819],[0,0],[0,0],[0,0],[-56.688,-36.443],[0,0],[0,0],[30.545,19.088]],"v":[[125.996,-87.818],[120.77,-48.09],[17.524,114.733],[0.005,125.995],[0,125.996],[-17.523,114.731],[-120.768,-48.083],[-125.996,-87.818],[0,-125.996]],"c":true}]},{"t":60,"s":[{"i":[[0,0],[0,0],[77.333,-49.716],[0,0],[0,0],[0,0],[11.993,91.145],[0,0],[-41.666,26.039]],"o":[[0,0],[-11.99,91.149],[0,0],[0,0],[0,0],[-77.33,-49.714],[0,0],[0,0],[41.667,26.039]],"v":[[171.875,-119.795],[164.746,-65.6],[23.905,156.51],[0.007,171.873],[0,171.875],[-23.904,156.508],[-164.743,-65.592],[-171.875,-119.795],[0,-171.875]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":-239,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","parent":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.006,-0.001,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.157,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[153.363,-119.689],[-14.644,46.689],[-66.863,-5.466]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.6,"y":0},"t":21,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[167.916,-123.188],[-50.252,92.864],[-118.061,25.138]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":42,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[114.144,-89.081],[-10.899,34.749],[-49.764,-4.068]],"c":false}]},{"t":60,"s":[{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[153.363,-119.689],[-14.644,46.689],[-66.863,-5.466]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-126-verified').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[100]},{"t":20,"s":[100],"h":1},{"i":{"x":[0.1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0]},{"t":60,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[26.2]},{"t":20,"s":[100],"h":1},{"i":{"x":[0.1],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0]},{"t":60,"s":[26.2]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-verified","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-verified","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 973feeb73..76a60d082 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge"; import { useOrganization, useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { createNotification } from "../notifications"; import { IconButton, Select, SelectItem, Tooltip } from "../v2"; @@ -69,7 +70,11 @@ export default function NavHeader({
{currentOrg?.name?.charAt(0)}
- + {currentOrg?.name} @@ -93,7 +98,10 @@ export default function NavHeader({ {pageName} @@ -130,7 +138,7 @@ export default function NavHeader({ passHref legacyBehavior href={{ - pathname: "/project/[id]/secrets/[env]", + pathname: `/${ProjectType.SecretManager}/[id]/secrets/[env]`, query: { id: router.query.id, env: router.query.env } }} > @@ -199,7 +207,10 @@ export default function NavHeader({ & { isOpen?: boolean }; export const Modal = ({ isOpen, ...props }: ModalProps) => ( - + ); export const ModalTrigger = DialogPrimitive.Trigger; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 673b2b41a..b0c3aa463 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -85,6 +85,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificateTemplates = "ssh-certificate-templates", + SshCertificates = "ssh-certificates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -165,6 +168,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 8898f00c9..0734335e7 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -69,14 +69,19 @@ export const getProjectHomePage = (workspace: Workspace) => { return `/${workspace.type}/${workspace.id}/certificates`; } - return `/${workspace.type}/${workspace.id}/kms`; + if (workspace.type === ProjectType.KMS) { + return `/${workspace.type}/${workspace.id}/kms`; + } + + return `/${workspace.type}/${workspace.id}/ssh`; }; export const getProjectTitle = (type: ProjectType) => { const titleConvert = { [ProjectType.SecretManager]: "Secret Management", [ProjectType.KMS]: "Key Management", - [ProjectType.CertificateManager]: "Cert Management" + [ProjectType.CertificateManager]: "Cert Management", + [ProjectType.SSH]: "SSH" }; return titleConvert[type]; }; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index de3c60d46..0c3dbe0d9 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -77,11 +77,16 @@ export const selectOrganization = async (data: { export const useSelectOrganization = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (details: { organizationId: string; userAgent?: UserAgentType }) => { + mutationFn: async (details: { + organizationId: string; + userAgent?: UserAgentType; + forceSetCredentials?: boolean; + }) => { const data = await selectOrganization(details); // If a custom user agent is set, then this session is meant for another consuming application, not the web application. - if (!details.userAgent && !data.isMfaEnabled) { + if ((!details.userAgent && !data.isMfaEnabled) || details.forceSetCredentials) { + localStorage.setItem("orgData.id", details.organizationId); SecurityClient.setToken(data.token); SecurityClient.setProviderAuthToken(""); } diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 9bb7b89d5..bbc75f90d 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,3 +1,6 @@ +import { SshCaStatus } from "@app/hooks/api/ssh-ca"; +import { SshCertTemplateStatus } from "@app/hooks/api/sshCertificateTemplates"; + import { CaStatus, CaType } from "./enums"; export const caTypeToNameMap: { [K in CaType]: string } = { @@ -11,7 +14,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; -export const getCaStatusBadgeVariant = (status: CaStatus) => { +export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: return "success"; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 551822f09..19a6fcc06 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,6 +38,8 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; +export * from "./ssh-ca"; +export * from "./sshCertificateTemplates"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 11d42631a..5c059ae98 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -80,6 +80,7 @@ export const useCreateIntegration = () => { key: string; value: string; }[]; + azureLabel?: string; githubVisibility?: string; githubVisibilityRepoIds?: string[]; kmsKeyId?: string; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 0346b065a..7054befc7 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -41,6 +41,7 @@ export type TIntegration = { key: string; value: string; }[]; + azureLabel?: string; kmsKeyId?: string; secretSuffix?: string; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 4923177ba..82894d988 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query"; +import SecurityClient from "@app/components/utilities/SecurityClient"; import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -67,7 +68,7 @@ export const useCreateOrg = (options: { invalidate: boolean } = { invalidate: tr mutationFn: async ({ name }: { name: string }) => { const { data: { organization } - } = await apiRequest.post("/api/v2/organizations", { + } = await apiRequest.post<{ organization: { id: string } }>("/api/v2/organizations", { name }); @@ -437,10 +438,13 @@ export const useDeleteOrgById = () => { return useMutation({ mutationFn: async ({ organizationId }: { organizationId: string }) => { const { - data: { organization } - } = await apiRequest.delete<{ organization: Organization }>( + data: { organization, accessToken } + } = await apiRequest.delete<{ organization: Organization; accessToken: string }>( `/api/v2/organizations/${organizationId}` ); + SecurityClient.setToken(accessToken); + localStorage.removeItem("orgData.id"); + return organization; }, onSuccess(_, dto) { diff --git a/frontend/src/hooks/api/ssh-ca/constants.tsx b/frontend/src/hooks/api/ssh-ca/constants.tsx new file mode 100644 index 000000000..2742a7bfa --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/constants.tsx @@ -0,0 +1,14 @@ +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { + [SshCertType.USER]: "User", + [SshCertType.HOST]: "Host" +}; diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx new file mode 100644 index 000000000..8fc57654b --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -0,0 +1,9 @@ +export { SshCaStatus } from "./constants"; +export { + useCreateSshCa, + useDeleteSshCa, + useIssueSshCreds, + useSignSshKey, + useUpdateSshCa +} from "./mutations"; +export { useGetSshCaById, useGetSshCaCertTemplates } from "./queries"; diff --git a/frontend/src/hooks/api/ssh-ca/mutations.tsx b/frontend/src/hooks/api/ssh-ca/mutations.tsx new file mode 100644 index 000000000..e8c5731b6 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -0,0 +1,91 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { + TCreateSshCaDTO, + TDeleteSshCaDTO, + TIssueSshCredsDTO, + TIssueSshCredsResponse, + TSignSshKeyDTO, + TSignSshKeyResponse, + TSshCertificateAuthority, + TUpdateSshCaDTO +} from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"] +}; + +export const useCreateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TSshCertificateAuthority }>("/api/v1/ssh/ca/", body); + return ca; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + } + }); +}; + +export const useUpdateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`, body); + return ca; + }, + onSuccess: ({ projectId }, { caId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + queryClient.invalidateQueries(sshCaKeys.getSshCaById(caId)); + } + }); +}; + +export const useDeleteSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); + } + }); +}; + +export const useSignSshKey = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post("/api/v1/ssh/sign", body); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); + } + }); +}; + +export const useIssueSshCreds = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post("/api/v1/ssh/issue", body); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); + } + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/queries.tsx b/frontend/src/hooks/api/ssh-ca/queries.tsx new file mode 100644 index 000000000..b85bbcb35 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; +import { TSshCertificateAuthority } from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"], + getSshCaCertTemplates: (caId: string) => [{ caId }, "ssh-ca-cert-templates"] +}; + +export const useGetSshCaById = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetSshCaCertTemplates = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaCertTemplates(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TSshCertificateTemplate[]; + }>(`/api/v1/ssh/ca/${caId}/certificate-templates`); + return data; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts new file mode 100644 index 000000000..6e5f02c4d --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -0,0 +1,74 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { SshCaStatus, SshCertType } from "./constants"; + +export type TSshCertificate = { + id: string; + sshCaId: string; + sshCertificateTemplateId: string; + serialNumber: string; + certType: SshCertType; + principals: string[]; + keyId: string; + notBefore: string; + notAfter: string; +}; + +export type TSshCertificateAuthority = { + id: string; + projectId: string; + status: SshCaStatus; + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; + publicKey: string; +}; + +export type TCreateSshCaDTO = { + projectId: string; + friendlyName?: string; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateSshCaDTO = { + caId: string; + friendlyName?: string; + status?: SshCaStatus; +}; + +export type TDeleteSshCaDTO = { + caId: string; +}; + +export type TSignSshKeyDTO = { + projectId: string; + certificateTemplateId: string; + publicKey?: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TSignSshKeyResponse = { + serialNumber: string; + signedKey: string; +}; + +export type TIssueSshCredsDTO = { + projectId: string; + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TIssueSshCredsResponse = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; + keyAlgorithm: CertKeyAlgorithm; +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/index.tsx b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx new file mode 100644 index 000000000..9efe99c81 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateSshCertTemplate, + useDeleteSshCertTemplate, + useUpdateSshCertTemplate +} from "./mutations"; +export { useGetSshCertTemplate } from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx new file mode 100644 index 000000000..91fb5eb53 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { sshCaKeys } from "../ssh-ca/queries"; +import { + TCreateSshCertificateTemplateDTO, + TDeleteSshCertificateTemplateDTO, + TSshCertificateTemplate, + TUpdateSshCertificateTemplateDTO +} from "./types"; + +export const useCreateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.post( + "/api/v1/ssh/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useUpdateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.patch( + `/api/v1/ssh/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useDeleteSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.delete( + `/api/v1/ssh/certificate-templates/${data.id}` + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx new file mode 100644 index 000000000..b9b535916 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getSshCertTemplateById: (id: string) => [{ id }, "ssh-cert-template"] +}; + +export const useGetSshCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getSshCertTemplateById(id), + queryFn: async () => { + const { data: certificateTemplate } = await apiRequest.get( + `/api/v1/ssh/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/types.ts b/frontend/src/hooks/api/sshCertificateTemplates/types.ts new file mode 100644 index 000000000..4099b4b8a --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/types.ts @@ -0,0 +1,47 @@ +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export type TSshCertificateTemplate = { + id: string; + sshCaId: string; + status: SshCertTemplateStatus; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TCreateSshCertificateTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TUpdateSshCertificateTemplateDTO = { + id: string; + status?: SshCertTemplateStatus; + name?: string; + ttl?: string; + maxTTL?: string; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowCustomKeyIds?: boolean; +}; + +export type TDeleteSshCertificateTemplateDTO = { + id: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index cd652970a..665462e7d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -32,6 +32,9 @@ export { useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, + useListWorkspaceSshCas, + useListWorkspaceSshCertificates, + useListWorkspaceSshCertificateTemplates, useNameWorkspaceSecrets, useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 9fdce5b67..45524bdb3 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -15,6 +15,8 @@ import { TIntegration } from "../integrations/types"; import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; +import { TSshCertificate, TSshCertificateAuthority } from "../ssh-ca/types"; +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -731,6 +733,67 @@ export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspac }); }; +export const useListWorkspaceSshCertificates = ({ + offset, + limit, + projectId +}: { + offset: number; + limit: number; + projectId: string; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceSshCertificates({ + offset, + limit, + projectId + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { data } = await apiRequest.get<{ + certificates: TSshCertificate[]; + totalCount: number; + }>(`/api/v2/workspace/${projectId}/ssh-certificates`, { + params + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCas = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCas(projectId), + queryFn: async () => { + const { + data: { cas } + } = await apiRequest.get<{ cas: Omit[] }>( + `/api/v2/workspace/${projectId}/ssh-cas` + ); + return cas; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>( + `/api/v2/workspace/${projectId}/ssh-certificate-templates` + ); + return data; + }, + enabled: Boolean(projectId) + }); +}; + export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index a1c5770e9..1005fe8bc 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -53,5 +53,19 @@ export const workspaceKeys = { getWorkspaceCertificateTemplates: (workspaceId: string) => [{ workspaceId }, "workspace-certificate-templates"] as const, getWorkspaceSlackConfig: (workspaceId: string) => - [{ workspaceId }, "workspace-slack-config"] as const + [{ workspaceId }, "workspace-slack-config"] as const, + getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, + allWorkspaceSshCertificates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificates"] as const, + specificWorkspaceSshCertificates: ({ + offset, + limit, + projectId + }: { + offset: number; + limit: number; + projectId: string; + }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, + getWorkspaceSshCertificateTemplates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificate-templates"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 558982d80..0510bdbe7 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -11,7 +11,8 @@ export enum ProjectVersion { export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", - KMS = "kms" + KMS = "kms", + SSH = "ssh" } export enum ProjectUserMembershipTemporaryMode { diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 04a1b3038..693f6e342 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -383,6 +383,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + SSH + + + { const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager; const isCertManager = currentWorkspace?.type === ProjectType.CertificateManager; const isCmek = currentWorkspace?.type === ProjectType.KMS; + const isSsh = currentWorkspace?.type === ProjectType.SSH; return ( @@ -83,6 +84,18 @@ export const ProjectSidebarItem = () => { )} + {isSsh && ( + + + + Overview + + + + )} ; @@ -60,6 +65,7 @@ export default function AzureAppConfigurationCreateIntegration() { const router = useRouter(); const { control, + watch, setValue, handleSubmit, formState: { isSubmitting } @@ -85,16 +91,28 @@ export default function AzureAppConfigurationCreateIntegration() { } }, [workspace]); + const shouldUseLabels = watch("useLabels"); + const handleIntegrationSubmit = async ({ secretPath, + useLabels, sourceEnvironment, baseUrl, initialSyncBehavior, - secretPrefix + secretPrefix, + azureLabel }: TFormSchema) => { try { if (!integrationAuth?.id) return; + if (useLabels && !azureLabel) { + createNotification({ + type: "error", + text: "Label must be provided when 'Use Labels' is enabled" + }); + return; + } + await mutateAsync({ integrationAuthId: integrationAuth?.id, isActive: true, @@ -103,7 +121,8 @@ export default function AzureAppConfigurationCreateIntegration() { secretPath, metadata: { initialSyncBehavior, - secretPrefix + secretPrefix, + ...(useLabels && { azureLabel }) } }); @@ -155,35 +174,70 @@ export default function AzureAppConfigurationCreateIntegration() {
- ( - - + + )} + /> + +
+ ( + onChange(isChecked)} + isChecked={value} + > + + + )} + /> + + {shouldUseLabels && ( + ( + - {sourceEnvironment.name} - - ))} - - - )} - /> + + + )} + /> + )} +
+
{ if (type === ProjectType.SecretManager) return "Secret Management"; if (type === ProjectType.CertificateManager) return "Cert Management"; - return "Key Management"; + if (type === ProjectType.KMS) return "Key Management"; + return "SSH"; }; const formatDescription = (type: ProjectType) => { @@ -70,7 +71,9 @@ const formatDescription = (type: ProjectType) => { return "Securely store, manage, and rotate various application secrets, such as database credentials, API keys, etc."; if (type === ProjectType.CertificateManager) return "Manage your PKI infrastructure and issue digital certificates for services, applications, and devices."; - return "Centralize the management of keys for cryptographic operations, such as encryption and decryption."; + 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."; }; type Props = { diff --git a/frontend/src/pages/org/[id]/ssh/overview.tsx b/frontend/src/pages/org/[id]/ssh/overview.tsx new file mode 100644 index 000000000..2ab3bb6cc --- /dev/null +++ b/frontend/src/pages/org/[id]/ssh/overview.tsx @@ -0,0 +1,9 @@ +import { ProjectType } from "@app/hooks/api/workspace/types"; + +import { ProductOverview } from "../secret-manager/overview"; + +const SshManagerOverviewPage = () => ; + +Object.assign(SshManagerOverviewPage, { requireAuth: true }); + +export default SshManagerOverviewPage; diff --git a/frontend/src/pages/ssh/[id]/allowlist/index.tsx b/frontend/src/pages/ssh/[id]/allowlist/index.tsx new file mode 100644 index 000000000..73c25d802 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/allowlist/index.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IPAllowlistPage } from "@app/views/Project/IPAllowListPage"; + +const ProjectAllowlist = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export default ProjectAllowlist; + +ProjectAllowlist.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/ca/[caId]/index.tsx b/frontend/src/pages/ssh/[id]/ca/[caId]/index.tsx new file mode 100644 index 000000000..cdaa4d0b2 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/ca/[caId]/index.tsx @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from "next/head"; + +import { SshCaPage } from "@app/views/Project/SshCaPage"; + +export default function SshCa() { + return ( + <> + + SSH Certificate Authority + + + + + ); +} + +SshCa.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/identities/[identityId]/index.tsx b/frontend/src/pages/ssh/[id]/identities/[identityId]/index.tsx new file mode 100644 index 000000000..ae8b2716a --- /dev/null +++ b/frontend/src/pages/ssh/[id]/identities/[identityId]/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IdentityDetailsPage } from "@app/views/Project/IdentityDetailsPage"; + +export default function ProjectIdentityDetailsPage() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +ProjectIdentityDetailsPage.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/members/[membershipId]/index.tsx b/frontend/src/pages/ssh/[id]/members/[membershipId]/index.tsx new file mode 100644 index 000000000..033aa2b15 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/members/[membershipId]/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { MemberDetailsPage } from "@app/views/Project/MemberDetailsPage"; + +export default function Page() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +Page.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/members/index.tsx b/frontend/src/pages/ssh/[id]/members/index.tsx new file mode 100644 index 000000000..4bcbb833e --- /dev/null +++ b/frontend/src/pages/ssh/[id]/members/index.tsx @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { MembersPage } from "@app/views/Project/MembersPage"; + +export default function WorkspaceMemberSettings() { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +} + +WorkspaceMemberSettings.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/roles/[roleSlug]/index.tsx b/frontend/src/pages/ssh/[id]/roles/[roleSlug]/index.tsx new file mode 100644 index 000000000..17c854cee --- /dev/null +++ b/frontend/src/pages/ssh/[id]/roles/[roleSlug]/index.tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { RolePage } from "@app/views/Project/RolePage"; + +export default function Role() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "Project Settings" })} + + + + + ); +} + +Role.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/settings/index.tsx b/frontend/src/pages/ssh/[id]/settings/index.tsx new file mode 100644 index 000000000..331ba8cc5 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/settings/index.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { ProjectSettingsPage } from "@app/views/Settings/ProjectSettingsPage"; + +const ProjectSettings = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export default ProjectSettings; + +ProjectSettings.requireAuth = true; diff --git a/frontend/src/pages/ssh/[id]/ssh/index.tsx b/frontend/src/pages/ssh/[id]/ssh/index.tsx new file mode 100644 index 000000000..f574566c1 --- /dev/null +++ b/frontend/src/pages/ssh/[id]/ssh/index.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { SshPage } from "@app/views/Project/SshPage"; + +const Ssh = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Certificates" })} + + + + +
+ ); +}; + +export default Ssh; + +Ssh.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx index f537baec6..0b8174c18 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx @@ -14,6 +14,7 @@ const metadataMappings: Record { Object.entries(integration.metadata).map(([key, value]) => (

- {metadataMappings[key as keyof typeof metadataMappings]} + {!!value && metadataMappings[key as keyof typeof metadataMappings]}

{renderValue(key as MetadataKey, value)}

diff --git a/frontend/src/views/Login/Login.utils.tsx b/frontend/src/views/Login/Login.utils.tsx index dc714e4b0..0562d383a 100644 --- a/frontend/src/views/Login/Login.utils.tsx +++ b/frontend/src/views/Login/Login.utils.tsx @@ -7,7 +7,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types"; import { queryClient } from "@app/reactQuery"; export const navigateUserToOrg = async (router: NextRouter, organizationId?: string) => { - const userOrgs = await fetchOrganizations(); + const userOrgs = await fetchOrganizations().catch(() => []); const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced); diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx index da2ad89c5..567349dab 100644 --- a/frontend/src/views/Org/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -71,12 +71,6 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ name, description, slug }: FormData) => { try { - console.log("onFormSubmit args: ", { - name, - description, - slug - }); - if (!orgId) return; if (role) { diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index bfb5678ea..3a50dc976 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -97,7 +97,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal const selectedPermissionCategory = useMemo(() => { const actions = Object.keys(rule || {}) as Array; - const totalActions = PERMISSIONS.length; + const totalActions = getPermissionList(formName).length; const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; diff --git a/frontend/src/views/Org/components/CreateOrgModal.tsx b/frontend/src/views/Org/components/CreateOrgModal.tsx index d1baf2c9c..faa02eccd 100644 --- a/frontend/src/views/Org/components/CreateOrgModal.tsx +++ b/frontend/src/views/Org/components/CreateOrgModal.tsx @@ -6,7 +6,7 @@ import z from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; -import { useCreateOrg, useSelectOrganization } from "@app/hooks/api"; +import { useCreateOrg, useGetOrganizations, useSelectOrganization } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; const schema = z @@ -23,9 +23,10 @@ interface CreateOrgModalProps { } export const CreateOrgModal: FC = ({ isOpen, onClose }) => { - const router = useRouter(); + const { refetch: refetchOrganizations } = useGetOrganizations(); + const { control, handleSubmit, @@ -50,19 +51,21 @@ export const CreateOrgModal: FC = ({ isOpen, onClose }) => }); await selectOrg({ - organizationId: organization.id + organizationId: organization.id, + forceSetCredentials: true }); + await refetchOrganizations(); + createNotification({ text: "Successfully created organization", type: "success" }); - if (router.isReady) router.push(`/org/${organization.id}/${ProjectType.SecretManager}/overview`); + if (router.isReady) + router.push(`/org/${organization.id}/${ProjectType.SecretManager}/overview`); else window.location.href = `/org/${organization.id}/${ProjectType.SecretManager}/overview`; - localStorage.setItem("orgData.id", organization.id); - reset(); onClose(); } catch (err) { diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx index 1241ff1c9..c27d4dc39 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx @@ -17,7 +17,7 @@ import { // DatePicker } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { CaType, useCreateCa, useGetCaById,useUpdateCa } from "@app/hooks/api/ca"; +import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -72,7 +72,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); - + const { mutateAsync: createMutateAsync } = useCreateCa(); const { mutateAsync: updateMutateAsync } = useUpdateCa(); @@ -151,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { }: FormData) => { try { if (!currentWorkspace?.slug) return; - + if (ca) { // update await updateMutateAsync({ diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx index ec535e44b..94f12cb29 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -1,10 +1,5 @@ import { useRouter } from "next/router"; -import { - faBan, - faCertificate, - faEllipsis, - faTrash -} from "@fortawesome/free-solid-svg-icons"; +import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx index b1cce918f..ad71c575d 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx @@ -1,7 +1,3 @@ -/** - * TODO (dangtony98): Reevaluate if this component should be in main - * CertificateTab or under CA page in the future. - */ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -18,7 +14,7 @@ import { CertificateTemplatesTable } from "./CertificateTemplatesTable"; type Props = { caId: string; -} +}; export const CertificateTemplatesSection = ({ caId }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx index e7f4fc91a..d345736f0 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx @@ -126,6 +126,11 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default( + [] + ), + [ProjectPermissionSub.SshCertificates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SshCertificateTemplates]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), @@ -210,6 +215,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.CertificateTemplates, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Tags, ProjectPermissionSub.SecretRotation, @@ -596,6 +604,33 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Remove", value: "delete" } ] }, + [ProjectPermissionSub.SshCertificateAuthorities]: { + title: "SSH Certificate Authorities", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SshCertificates]: { + title: "SSH Certificates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SshCertificateTemplates]: { + title: "SSH Certificate Templates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, [ProjectPermissionSub.PkiCollections]: { title: "PKI Collections", actions: [ diff --git a/frontend/src/views/Project/SshCaPage/SshCaPage.tsx b/frontend/src/views/Project/SshCaPage/SshCaPage.tsx new file mode 100644 index 000000000..f0b785a8d --- /dev/null +++ b/frontend/src/views/Project/SshCaPage/SshCaPage.tsx @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshCaModal } from "../SshPage/components/SshCaModal"; +import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components"; + +export const SshCaPage = withProjectPermission( + () => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const router = useRouter(); + const caId = router.query.caId as string; + const { data } = useGetSshCaById(caId); + + const { mutateAsync: deleteSshCa } = useDeleteSshCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshCa", + "deleteSshCa" + ] as const); + + const onRemoveCaSubmit = async (caIdToDelete: string) => { + try { + if (!projectId) return; + + await deleteSshCa({ caId: caIdToDelete }); + + await createNotification({ + text: "Successfully deleted SSH CA", + type: "success" + }); + + handlePopUpClose("deleteSshCa"); + router.push(`/project/${projectId}/ssh`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH CA", + type: "error" + }); + } + }; + + return ( +
+ {data && ( +
+ +
+

{data.friendlyName}

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

SSH CA Details

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

SSH CA ID

+
+

{ca.id}

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

Friendly Name

+

{ca.friendlyName}

+
+
+

Status

+

{caStatusToNameMap[ca.status]}

+
+
+

Key Algorithm

+

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+
+
+

Public Key

+
+

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

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

Serial Number

+
+

{serialNumber}

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

SSH Certificate / Signed Key

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

{signedKey}

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

Private Key

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

{privateKey}

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

Public Key

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

{publicKey}

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

Allow User Certificates

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

Allow Host Certificates

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

Allow Custom Key IDs

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

Certificate Templates

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

SSH

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

Certificate Authorities

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

Certificates

+ + {(isAllowed) => ( + + )} + +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx new file mode 100644 index 000000000..c8310cc71 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { faCertificate } from "@fortawesome/free-solid-svg-icons"; +import { format } from "date-fns"; + +import { + Badge, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useListWorkspaceSshCertificates } from "@app/hooks/api"; + +import { getSshCertStatusBadgeDetails } from "./SshCertificatesTable.utils"; + +const PER_PAGE_INIT = 25; + +export const SshCertificatesTable = () => { + const { currentWorkspace } = useWorkspace(); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE_INIT); + + const { data, isLoading } = useListWorkspaceSshCertificates({ + projectId: currentWorkspace?.id || "", + offset: (page - 1) * perPage, + limit: perPage + }); + + return ( + + + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates?.map((certificate) => { + const { variant, label } = getSshCertStatusBadgeDetails(certificate.notAfter); + return ( + + + + + + + ); + })} + +
PrincipalsStatusNot BeforeNot After
{certificate.principals.join(", ")} + {label} + + {certificate.notBefore + ? format(new Date(certificate.notBefore), "yyyy-MM-dd") + : "-"} + + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} +
+ {!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.certificates?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts new file mode 100644 index 000000000..12b75aa08 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts @@ -0,0 +1,17 @@ +export const getSshCertStatusBadgeDetails = (notAfter: string) => { + const currentDate = new Date().getTime(); + const notAfterDate = new Date(notAfter).getTime(); + + let variant: "success" | "primary" | "danger" = "success"; + let label = "Active"; + + if (notAfterDate > currentDate) { + variant = "success"; + label = "Active"; + } else { + variant = "danger"; + label = "Expired"; + } + + return { variant, label }; +}; diff --git a/frontend/src/views/Project/SshPage/components/index.tsx b/frontend/src/views/Project/SshPage/components/index.tsx new file mode 100644 index 000000000..efb307725 --- /dev/null +++ b/frontend/src/views/Project/SshPage/components/index.tsx @@ -0,0 +1,2 @@ +export { SshCaSection } from "./SshCaSection"; +export { SshCertificatesSection } from "./SshCertificatesSection"; diff --git a/frontend/src/views/Project/SshPage/index.tsx b/frontend/src/views/Project/SshPage/index.tsx new file mode 100644 index 000000000..080bae38d --- /dev/null +++ b/frontend/src/views/Project/SshPage/index.tsx @@ -0,0 +1 @@ +export { SshPage } from "./SshPage"; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 1162485cf..eee9adfee 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1125,6 +1125,7 @@ export const SecretOverviewPage = () => { bodyClassName="overflow-visible" title="Create Secrets" subTitle="Create a secret across multiple environments" + onPointerDownOutside={(e) => e.preventDefault()} > + environmentSlug: + secretsPath: + + push: + secret: + secretName: push-secret-demo # Secret CRD + secretNamespace: default + + # Only have one authentication method defined or you are likely to run into authentication issues. + # Remove all except one authentication method. + authentication: + awsIamAuth: + identityId: + azureAuth: + identityId: + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: + gcpIdTokenAuth: + identityId: + kubernetesAuth: + identityId: + serviceAccountRef: + name: + namespace: + universalAuth: + credentialsRef: + secretName: # universal-auth-credentials + secretNamespace: # default diff --git a/k8-operator/config/samples/crd/pushsecret/sourceSecret.yaml b/k8-operator/config/samples/crd/pushsecret/sourceSecret.yaml new file mode 100644 index 000000000..6a3703a95 --- /dev/null +++ b/k8-operator/config/samples/crd/pushsecret/sourceSecret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: push-secret-demo + namespace: default +stringData: # can also be "data", but needs to be base64 encoded + API_KEY: some-api-key + DATABASE_URL: postgres://127.0.0.1:5432 + ENCRYPTION_KEY: fabcc12-a22-facbaa4-11aa568aab diff --git a/k8-operator/controllers/infisicalpushsecret/conditions.go b/k8-operator/controllers/infisicalpushsecret/conditions.go new file mode 100644 index 000000000..dd17bc913 --- /dev/null +++ b/k8-operator/controllers/infisicalpushsecret/conditions.go @@ -0,0 +1,156 @@ +package controllers + +import ( + "context" + "fmt" + + "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (r *InfisicalPushSecretReconciler) SetReconcileStatusCondition(ctx context.Context, infisicalPushSecret *v1alpha1.InfisicalPushSecret, err error) error { + + if infisicalPushSecret.Status.Conditions == nil { + infisicalPushSecret.Status.Conditions = []metav1.Condition{} + } + + if err != nil { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/Reconcile", + Status: metav1.ConditionTrue, + Reason: "Error", + Message: fmt.Sprintf("Reconcile failed, secrets were not pushed to Infisical. Error: %s", err.Error()), + }) + } else { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/Reconcile", + Status: metav1.ConditionFalse, + Reason: "OK", + Message: "Reconcile succeeded, secrets were pushed to Infisical", + }) + } + + return r.Client.Status().Update(ctx, infisicalPushSecret) + +} + +func (r *InfisicalPushSecretReconciler) SetFailedToReplaceSecretsStatusCondition(ctx context.Context, infisicalPushSecret *v1alpha1.InfisicalPushSecret, failMessage string) error { + if infisicalPushSecret.Status.Conditions == nil { + infisicalPushSecret.Status.Conditions = []metav1.Condition{} + } + + if failMessage != "" { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToReplaceSecrets", + Status: metav1.ConditionTrue, + Reason: "Error", + Message: failMessage, + }) + } else { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToReplaceSecrets", + Status: metav1.ConditionFalse, + Reason: "OK", + Message: "No errors, no secrets failed to be replaced in Infisical", + }) + } + + return r.Client.Status().Update(ctx, infisicalPushSecret) +} + +func (r *InfisicalPushSecretReconciler) SetFailedToCreateSecretsStatusCondition(ctx context.Context, infisicalPushSecret *v1alpha1.InfisicalPushSecret, failMessage string) error { + if infisicalPushSecret.Status.Conditions == nil { + infisicalPushSecret.Status.Conditions = []metav1.Condition{} + } + + if failMessage != "" { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToCreateSecrets", + Status: metav1.ConditionTrue, + Reason: "Error", + Message: failMessage, + }) + } else { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToCreateSecrets", + Status: metav1.ConditionFalse, + Reason: "OK", + Message: "No errors encountered, no secrets failed to be created in Infisical", + }) + } + + return r.Client.Status().Update(ctx, infisicalPushSecret) +} + +func (r *InfisicalPushSecretReconciler) SetFailedToUpdateSecretsStatusCondition(ctx context.Context, infisicalPushSecret *v1alpha1.InfisicalPushSecret, failMessage string) error { + if infisicalPushSecret.Status.Conditions == nil { + infisicalPushSecret.Status.Conditions = []metav1.Condition{} + } + + if failMessage != "" { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToUpdateSecrets", + Status: metav1.ConditionTrue, + Reason: "Error", + Message: failMessage, + }) + } else { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToUpdateSecrets", + Status: metav1.ConditionFalse, + Reason: "OK", + Message: "No errors encountered, no secrets failed to be updated in Infisical", + }) + } + + return r.Client.Status().Update(ctx, infisicalPushSecret) +} + +func (r *InfisicalPushSecretReconciler) SetFailedToDeleteSecretsStatusCondition(ctx context.Context, infisicalPushSecret *v1alpha1.InfisicalPushSecret, failMessage string) error { + if infisicalPushSecret.Status.Conditions == nil { + infisicalPushSecret.Status.Conditions = []metav1.Condition{} + } + + if failMessage != "" { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToDeleteSecrets", + Status: metav1.ConditionTrue, + Reason: "Error", + Message: failMessage, + }) + } else { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/FailedToDeleteSecrets", + Status: metav1.ConditionFalse, + Reason: "OK", + Message: "No errors encountered, no secrets failed to be deleted", + }) + } + + return r.Client.Status().Update(ctx, infisicalPushSecret) +} + +func (r *InfisicalPushSecretReconciler) SetAuthenticatedStatusCondition(ctx context.Context, infisicalPushSecret *v1alpha1.InfisicalPushSecret, errorToConditionOn error) error { + if infisicalPushSecret.Status.Conditions == nil { + infisicalPushSecret.Status.Conditions = []metav1.Condition{} + } + + if errorToConditionOn != nil { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/Authenticated", + Status: metav1.ConditionFalse, + Reason: "Error", + Message: "Failed to authenticate with Infisical API. This can be caused by invalid service token or an invalid API host that is set. Check operator logs for more info", + }) + } else { + meta.SetStatusCondition(&infisicalPushSecret.Status.Conditions, metav1.Condition{ + Type: "secrets.infisical.com/Authenticated", + Status: metav1.ConditionTrue, + Reason: "OK", + Message: "Successfully authenticated with Infisical API", + }) + } + + return r.Client.Status().Update(ctx, infisicalPushSecret) +} diff --git a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go new file mode 100644 index 000000000..f2125485e --- /dev/null +++ b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go @@ -0,0 +1,249 @@ +package controllers + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + secretsv1alpha1 "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/api" + "github.com/Infisical/infisical/k8-operator/packages/constants" + controllerhelpers "github.com/Infisical/infisical/k8-operator/packages/controllerutil" + "github.com/Infisical/infisical/k8-operator/packages/util" + "github.com/go-logr/logr" +) + +// InfisicalSecretReconciler reconciles a InfisicalSecret object +type InfisicalPushSecretReconciler struct { + client.Client + + BaseLogger logr.Logger + Scheme *runtime.Scheme +} + +var infisicalPushSecretResourceVariablesMap map[string]util.ResourceVariables = make(map[string]util.ResourceVariables) + +func (r *InfisicalPushSecretReconciler) GetLogger(req ctrl.Request) logr.Logger { + return r.BaseLogger.WithValues("infisicalpushsecret", req.NamespacedName) +} + +//+kubebuilder:rbac:groups=secrets.infisical.com,resources=infisicalpushsecrets,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=secrets.infisical.com,resources=infisicalpushsecrets/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=secrets.infisical.com,resources=infisicalpushsecrets/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;delete +//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;delete +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;get;update +//+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.1/pkg/reconcile + +func (r *InfisicalPushSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + + logger := r.GetLogger(req) + + var infisicalPushSecretCRD secretsv1alpha1.InfisicalPushSecret + requeueTime := time.Minute // seconds + + err := r.Get(ctx, req.NamespacedName, &infisicalPushSecretCRD) + if err != nil { + if errors.IsNotFound(err) { + logger.Info("Infisical Push Secret CRD not found") + return ctrl.Result{ + Requeue: false, + }, nil + } else { + logger.Error(err, "Unable to fetch Infisical Secret CRD from cluster") + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil + } + } + + // Add finalizer if it doesn't exist + if !controllerutil.ContainsFinalizer(&infisicalPushSecretCRD, constants.INFISICAL_PUSH_SECRET_FINALIZER_NAME) { + controllerutil.AddFinalizer(&infisicalPushSecretCRD, constants.INFISICAL_PUSH_SECRET_FINALIZER_NAME) + if err := r.Update(ctx, &infisicalPushSecretCRD); err != nil { + return ctrl.Result{}, err + } + } + + // Check if it's being deleted + if !infisicalPushSecretCRD.DeletionTimestamp.IsZero() { + logger.Info("Handling deletion of InfisicalPushSecret") + if controllerutil.ContainsFinalizer(&infisicalPushSecretCRD, constants.INFISICAL_PUSH_SECRET_FINALIZER_NAME) { + // We remove finalizers before running deletion logic to be completely safe from stuck resources + infisicalPushSecretCRD.ObjectMeta.Finalizers = []string{} + if err := r.Update(ctx, &infisicalPushSecretCRD); err != nil { + logger.Error(err, fmt.Sprintf("Error removing finalizers from InfisicalPushSecret %s", infisicalPushSecretCRD.Name)) + return ctrl.Result{}, err + } + + if err := r.DeleteManagedSecrets(ctx, logger, infisicalPushSecretCRD); err != nil { + return ctrl.Result{}, err // Even if this fails, we still want to delete the CRD + } + + } + return ctrl.Result{}, nil + } + + if infisicalPushSecretCRD.Spec.ResyncInterval != "" { + + duration, err := util.ConvertResyncIntervalToDuration(infisicalPushSecretCRD.Spec.ResyncInterval) + + if err != nil { + logger.Error(err, fmt.Sprintf("unable to convert resync interval to duration. Will requeue after [requeueTime=%v]", requeueTime)) + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil + } + + requeueTime = duration + + logger.Info(fmt.Sprintf("Manual re-sync interval set. Interval: %v", requeueTime)) + + } else { + logger.Info(fmt.Sprintf("Re-sync interval set. Interval: %v", requeueTime)) + } + + // Check if the resource is already marked for deletion + if infisicalPushSecretCRD.GetDeletionTimestamp() != nil { + return ctrl.Result{ + Requeue: false, + }, nil + } + + // Get modified/default config + infisicalConfig, err := controllerhelpers.GetInfisicalConfigMap(ctx, r.Client) + if err != nil { + logger.Error(err, fmt.Sprintf("unable to fetch infisical-config. Will requeue after [requeueTime=%v]", requeueTime)) + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil + } + + if infisicalPushSecretCRD.Spec.HostAPI == "" { + api.API_HOST_URL = infisicalConfig["hostAPI"] + } else { + api.API_HOST_URL = infisicalPushSecretCRD.Spec.HostAPI + } + + if infisicalPushSecretCRD.Spec.TLS.CaRef.SecretName != "" { + api.API_CA_CERTIFICATE, err = r.getInfisicalCaCertificateFromKubeSecret(ctx, infisicalPushSecretCRD) + if err != nil { + logger.Error(err, fmt.Sprintf("unable to fetch CA certificate. Will requeue after [requeueTime=%v]", requeueTime)) + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil + } + + logger.Info("Using custom CA certificate...") + } else { + api.API_CA_CERTIFICATE = "" + } + + err = r.ReconcileInfisicalPushSecret(ctx, logger, infisicalPushSecretCRD) + r.SetReconcileStatusCondition(ctx, &infisicalPushSecretCRD, err) + + if err != nil { + logger.Error(err, fmt.Sprintf("unable to reconcile Infisical Push Secret. Will requeue after [requeueTime=%v]", requeueTime)) + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil + } + + // Sync again after the specified time + logger.Info(fmt.Sprintf("Operator will requeue after [%v]", requeueTime)) + return ctrl.Result{ + RequeueAfter: requeueTime, + }, nil +} + +func (r *InfisicalPushSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { + + // Custom predicate that allows both spec changes and deletions + specChangeOrDelete := predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + // Only reconcile if spec/generation changed + + isSpecOrGenerationChange := e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() + + if isSpecOrGenerationChange { + if infisicalPushSecretResourceVariablesMap != nil { + if rv, ok := infisicalPushSecretResourceVariablesMap[string(e.ObjectNew.GetUID())]; ok { + rv.CancelCtx() + delete(infisicalPushSecretResourceVariablesMap, string(e.ObjectNew.GetUID())) + } + } + } + + return isSpecOrGenerationChange + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // Always reconcile on deletion + + if infisicalPushSecretResourceVariablesMap != nil { + if rv, ok := infisicalPushSecretResourceVariablesMap[string(e.Object.GetUID())]; ok { + rv.CancelCtx() + delete(infisicalPushSecretResourceVariablesMap, string(e.Object.GetUID())) + } + } + + return true + }, + CreateFunc: func(e event.CreateEvent) bool { + // Reconcile on creation + return true + }, + GenericFunc: func(e event.GenericEvent) bool { + // Ignore generic events + return false + }, + } + + return ctrl.NewControllerManagedBy(mgr). + For(&secretsv1alpha1.InfisicalPushSecret{}, builder.WithPredicates( + specChangeOrDelete, + )). + Watches( + &source.Kind{Type: &corev1.Secret{}}, + handler.EnqueueRequestsFromMapFunc(func(o client.Object) []reconcile.Request { + ctx := context.Background() + pushSecrets := &secretsv1alpha1.InfisicalPushSecretList{} + if err := r.List(ctx, pushSecrets); err != nil { + return []reconcile.Request{} + } + + requests := []reconcile.Request{} + for _, pushSecret := range pushSecrets.Items { + if pushSecret.Spec.Push.Secret.SecretName == o.GetName() && + pushSecret.Spec.Push.Secret.SecretNamespace == o.GetNamespace() { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: pushSecret.GetName(), + Namespace: pushSecret.GetNamespace(), + }, + }) + } + } + return requests + }), + ). + Complete(r) +} diff --git a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go new file mode 100644 index 000000000..47fc8e69b --- /dev/null +++ b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_helper.go @@ -0,0 +1,482 @@ +package controllers + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/api" + "github.com/Infisical/infisical/k8-operator/packages/constants" + "github.com/Infisical/infisical/k8-operator/packages/util" + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + infisicalSdk "github.com/infisical/go-sdk" + k8Errors "k8s.io/apimachinery/pkg/api/errors" +) + +func (r *InfisicalPushSecretReconciler) handleAuthentication(ctx context.Context, infisicalSecret v1alpha1.InfisicalPushSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (util.AuthenticationDetails, error) { + authStrategies := map[util.AuthStrategyType]func(ctx context.Context, reconcilerClient client.Client, secretCrd util.SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (util.AuthenticationDetails, error){ + util.AuthStrategy.UNIVERSAL_MACHINE_IDENTITY: util.HandleUniversalAuth, + util.AuthStrategy.KUBERNETES_MACHINE_IDENTITY: util.HandleKubernetesAuth, + util.AuthStrategy.AWS_IAM_MACHINE_IDENTITY: util.HandleAwsIamAuth, + util.AuthStrategy.AZURE_MACHINE_IDENTITY: util.HandleAzureAuth, + util.AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY: util.HandleGcpIdTokenAuth, + util.AuthStrategy.GCP_IAM_MACHINE_IDENTITY: util.HandleGcpIamAuth, + } + + for authStrategy, authHandler := range authStrategies { + authDetails, err := authHandler(ctx, r.Client, util.SecretAuthInput{ + Secret: infisicalSecret, + Type: util.SecretCrd.INFISICAL_PUSH_SECRET, + }, infisicalClient) + + if err == nil { + return authDetails, nil + } + + if !errors.Is(err, util.ErrAuthNotApplicable) { + return util.AuthenticationDetails{}, fmt.Errorf("authentication failed for strategy [%s] [err=%w]", authStrategy, err) + } + } + + return util.AuthenticationDetails{}, fmt.Errorf("no authentication method provided") + +} + +func (r *InfisicalPushSecretReconciler) getInfisicalCaCertificateFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalPushSecret) (caCertificate string, err error) { + + caCertificateFromKubeSecret, err := util.GetKubeSecretByNamespacedName(ctx, r.Client, types.NamespacedName{ + Namespace: infisicalSecret.Spec.TLS.CaRef.SecretNamespace, + Name: infisicalSecret.Spec.TLS.CaRef.SecretName, + }) + + if k8Errors.IsNotFound(err) { + return "", fmt.Errorf("kubernetes secret containing custom CA certificate cannot be found. [err=%s]", err) + } + + if err != nil { + return "", fmt.Errorf("something went wrong when fetching your CA certificate [err=%s]", err) + } + + caCertificateFromSecret := string(caCertificateFromKubeSecret.Data[infisicalSecret.Spec.TLS.CaRef.SecretKey]) + + return caCertificateFromSecret, nil +} + +func (r *InfisicalPushSecretReconciler) getResourceVariables(infisicalPushSecret v1alpha1.InfisicalPushSecret) util.ResourceVariables { + + var resourceVariables util.ResourceVariables + + if _, ok := infisicalPushSecretResourceVariablesMap[string(infisicalPushSecret.UID)]; !ok { + + ctx, cancel := context.WithCancel(context.Background()) + + client := infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{ + SiteUrl: api.API_HOST_URL, + CaCertificate: api.API_CA_CERTIFICATE, + UserAgent: api.USER_AGENT_NAME, + }) + + infisicalPushSecretResourceVariablesMap[string(infisicalPushSecret.UID)] = util.ResourceVariables{ + InfisicalClient: client, + CancelCtx: cancel, + AuthDetails: util.AuthenticationDetails{}, + } + + resourceVariables = infisicalPushSecretResourceVariablesMap[string(infisicalPushSecret.UID)] + + } else { + resourceVariables = infisicalPushSecretResourceVariablesMap[string(infisicalPushSecret.UID)] + } + + return resourceVariables + +} + +func (r *InfisicalPushSecretReconciler) updateResourceVariables(infisicalPushSecret v1alpha1.InfisicalPushSecret, resourceVariables util.ResourceVariables) { + infisicalPushSecretResourceVariablesMap[string(infisicalPushSecret.UID)] = resourceVariables +} + +func (r *InfisicalPushSecretReconciler) ReconcileInfisicalPushSecret(ctx context.Context, logger logr.Logger, infisicalPushSecret v1alpha1.InfisicalPushSecret) error { + + resourceVariables := r.getResourceVariables(infisicalPushSecret) + infisicalClient := resourceVariables.InfisicalClient + cancelCtx := resourceVariables.CancelCtx + authDetails := resourceVariables.AuthDetails + var err error + + if authDetails.AuthStrategy == "" { + logger.Info("No authentication strategy found. Attempting to authenticate") + authDetails, err = r.handleAuthentication(ctx, infisicalPushSecret, infisicalClient) + r.SetAuthenticatedStatusCondition(ctx, &infisicalPushSecret, err) + + if err != nil { + return fmt.Errorf("unable to authenticate [err=%s]", err) + } + + r.updateResourceVariables(infisicalPushSecret, util.ResourceVariables{ + InfisicalClient: infisicalClient, + CancelCtx: cancelCtx, + AuthDetails: authDetails, + }) + } + + kubePushSecret, err := util.GetKubeSecretByNamespacedName(ctx, r.Client, types.NamespacedName{ + Namespace: infisicalPushSecret.Spec.Push.Secret.SecretNamespace, + Name: infisicalPushSecret.Spec.Push.Secret.SecretName, + }) + + if err != nil { + return fmt.Errorf("unable to fetch kube secret [err=%s]", err) + } + + var kubeSecrets = make(map[string]string) + + for key, value := range kubePushSecret.Data { + kubeSecrets[key] = string(value) + } + + destination := infisicalPushSecret.Spec.Destination + existingSecrets, err := infisicalClient.Secrets().List(infisicalSdk.ListSecretsOptions{ + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + IncludeImports: false, + }) + + getExistingSecretByKey := func(key string) *infisicalSdk.Secret { + for _, secret := range existingSecrets { + if secret.SecretKey == key { + return &secret + } + } + return nil + } + + getExistingSecretById := func(id string) *infisicalSdk.Secret { + for _, secret := range existingSecrets { + if secret.ID == id { + return &secret + } + } + return nil + } + + updateExistingSecretByKey := func(key string, newSecretValue string) { + for i := range existingSecrets { + if existingSecrets[i].SecretKey == key { + existingSecrets[i].SecretValue = newSecretValue + break + } + } + } + + if err != nil { + return fmt.Errorf("unable to list secrets [err=%s]", err) + } + + updatePolicy := infisicalPushSecret.Spec.UpdatePolicy + + var secretsFailedToCreate []string + var secretsFailedToUpdate []string + var secretsFailedToDelete []string + var secretsFailedToReplaceById []string + + // If the ManagedSecrets are nil, we know this is the first time the InfisicalPushSecret is being reconciled. + if infisicalPushSecret.Status.ManagedSecrets == nil { + + infisicalPushSecret.Status.ManagedSecrets = make(map[string]string) // (string[id], string[key] ) + + for secretKey, secretValue := range kubeSecrets { + if exists := getExistingSecretByKey(secretKey); exists != nil { + + if updatePolicy == string(constants.PUSH_SECRET_REPLACE_POLICY_ENABLED) { + updatedSecret, err := infisicalClient.Secrets().Update(infisicalSdk.UpdateSecretOptions{ + SecretKey: secretKey, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + NewSecretValue: secretValue, + }) + + if err != nil { + secretsFailedToUpdate = append(secretsFailedToUpdate, secretKey) + logger.Info(fmt.Sprintf("unable to update secret [key=%s] [err=%s]", secretKey, err)) + continue + } + + infisicalPushSecret.Status.ManagedSecrets[updatedSecret.ID] = secretKey + } + } else { + createdSecret, err := infisicalClient.Secrets().Create(infisicalSdk.CreateSecretOptions{ + SecretKey: secretKey, + SecretValue: secretValue, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToCreate = append(secretsFailedToCreate, secretKey) + logger.Info(fmt.Sprintf("unable to create secret [key=%s] [err=%s]", secretKey, err)) + continue + } + + infisicalPushSecret.Status.ManagedSecrets[createdSecret.ID] = secretKey + } + } + } else { + + // Loop over all the managed secrets, and find the corresponding existingSecret that has the same ID. If the key doesn't match, delete the secret, and re-create it with the correct key/value + for managedSecretId, managedSecretKey := range infisicalPushSecret.Status.ManagedSecrets { + + existingSecret := getExistingSecretById(managedSecretId) + + if existingSecret != nil { + + if existingSecret.SecretKey != managedSecretKey { + // Secret key has changed, lets delete the secret and re-create it with the correct key + + logger.Info(fmt.Sprintf("Secret with ID [id=%s] has changed key from [%s] to [%s]. Deleting and re-creating secret", managedSecretId, managedSecretKey, existingSecret.SecretKey)) + + deletedSecret, err := infisicalClient.Secrets().Delete(infisicalSdk.DeleteSecretOptions{ + SecretKey: existingSecret.SecretKey, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToReplaceById = append(secretsFailedToReplaceById, managedSecretKey) + logger.Info(fmt.Sprintf("unable to delete secret [key=%s] [err=%s]", managedSecretKey, err)) + continue + } + + createdSecret, err := infisicalClient.Secrets().Create(infisicalSdk.CreateSecretOptions{ + SecretKey: managedSecretKey, + SecretValue: existingSecret.SecretValue, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToReplaceById = append(secretsFailedToReplaceById, managedSecretKey) + logger.Info(fmt.Sprintf("unable to create secret [key=%s] [err=%s]", managedSecretKey, err)) + continue + } + + delete(infisicalPushSecret.Status.ManagedSecrets, deletedSecret.ID) + infisicalPushSecret.Status.ManagedSecrets[createdSecret.ID] = managedSecretKey + } + + } + } + + // We need to check if any of the secrets have been removed in the new kube secret + for _, managedSecretKey := range infisicalPushSecret.Status.ManagedSecrets { + + if _, ok := kubeSecrets[managedSecretKey]; !ok { + + // Secret has been removed, verify that the secret is managed by the operator + if getExistingSecretByKey(managedSecretKey) != nil { + logger.Info(fmt.Sprintf("Secret with key [key=%s] has been removed from the kube secret. Deleting secret from Infisical", managedSecretKey)) + + deletedSecret, err := infisicalClient.Secrets().Delete(infisicalSdk.DeleteSecretOptions{ + SecretKey: managedSecretKey, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToDelete = append(secretsFailedToDelete, managedSecretKey) + logger.Info(fmt.Sprintf("unable to delete secret [key=%s] [err=%s]", managedSecretKey, err)) + continue + } + + delete(infisicalPushSecret.Status.ManagedSecrets, deletedSecret.ID) + } + } + } + + // We need to check if any new secrets have been added in the kube secret + for currentSecretKey := range kubeSecrets { + + if exists := getExistingSecretByKey(currentSecretKey); exists == nil { + + // Some secrets has been added, verify that the secret that has been added is not already managed by the operator + if _, ok := infisicalPushSecret.Status.ManagedSecrets[currentSecretKey]; !ok { + + // Secret was not managed by the operator, lets add it + logger.Info(fmt.Sprintf("Secret with key [key=%s] has been added to the kube secret. Creating secret in Infisical", currentSecretKey)) + + createdSecret, err := infisicalClient.Secrets().Create(infisicalSdk.CreateSecretOptions{ + SecretKey: currentSecretKey, + SecretValue: kubeSecrets[currentSecretKey], + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToCreate = append(secretsFailedToCreate, currentSecretKey) + logger.Info(fmt.Sprintf("unable to create secret [key=%s] [err=%s]", currentSecretKey, err)) + continue + } + + infisicalPushSecret.Status.ManagedSecrets[createdSecret.ID] = currentSecretKey + } + } else { + if updatePolicy == string(constants.PUSH_SECRET_REPLACE_POLICY_ENABLED) { + + existingSecret := getExistingSecretByKey(currentSecretKey) + + if existingSecret != nil && existingSecret.SecretValue != kubeSecrets[currentSecretKey] { + logger.Info(fmt.Sprintf("Secret with key [key=%s] has changed value. Updating secret in Infisical", currentSecretKey)) + + updatedSecret, err := infisicalClient.Secrets().Update(infisicalSdk.UpdateSecretOptions{ + SecretKey: currentSecretKey, + NewSecretValue: kubeSecrets[currentSecretKey], + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToUpdate = append(secretsFailedToUpdate, currentSecretKey) + logger.Info(fmt.Sprintf("unable to update secret [key=%s] [err=%s]", currentSecretKey, err)) + continue + } + + updateExistingSecretByKey(currentSecretKey, kubeSecrets[currentSecretKey]) + infisicalPushSecret.Status.ManagedSecrets[updatedSecret.ID] = currentSecretKey + } + } + } + } + + // Check if any of the existing secrets values have changed + for secretKey, secretValue := range kubeSecrets { + + existingSecret := getExistingSecretByKey(secretKey) + + if existingSecret != nil { + + _, managedByOperator := infisicalPushSecret.Status.ManagedSecrets[existingSecret.ID] + + if secretValue != existingSecret.SecretValue { + + if managedByOperator || updatePolicy == string(constants.PUSH_SECRET_REPLACE_POLICY_ENABLED) { + logger.Info(fmt.Sprintf("Secret with key [key=%s] has changed value. Updating secret in Infisical", secretKey)) + + updatedSecret, err := infisicalClient.Secrets().Update(infisicalSdk.UpdateSecretOptions{ + SecretKey: secretKey, + NewSecretValue: secretValue, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + secretsFailedToUpdate = append(secretsFailedToUpdate, secretKey) + logger.Info(fmt.Sprintf("unable to update secret [key=%s] [err=%s]", secretKey, err)) + continue + } + + infisicalPushSecret.Status.ManagedSecrets[updatedSecret.ID] = secretKey + } + } + } + } + } + + var errorMessage string + if len(secretsFailedToCreate) > 0 { + errorMessage = fmt.Sprintf("Failed to create secrets: [%s]", strings.Join(secretsFailedToCreate, ", ")) + } else { + errorMessage = "" + } + r.SetFailedToCreateSecretsStatusCondition(ctx, &infisicalPushSecret, fmt.Sprintf("Failed to create secrets: [%s]", errorMessage)) + + if len(secretsFailedToUpdate) > 0 { + errorMessage = fmt.Sprintf("Failed to update secrets: [%s]", strings.Join(secretsFailedToUpdate, ", ")) + } else { + errorMessage = "" + } + r.SetFailedToUpdateSecretsStatusCondition(ctx, &infisicalPushSecret, fmt.Sprintf("Failed to update secrets: [%s]", errorMessage)) + + if len(secretsFailedToDelete) > 0 { + errorMessage = fmt.Sprintf("Failed to delete secrets: [%s]", strings.Join(secretsFailedToDelete, ", ")) + } else { + errorMessage = "" + } + r.SetFailedToDeleteSecretsStatusCondition(ctx, &infisicalPushSecret, errorMessage) + + if len(secretsFailedToReplaceById) > 0 { + errorMessage = fmt.Sprintf("Failed to replace secrets: [%s]", strings.Join(secretsFailedToReplaceById, ", ")) + } else { + errorMessage = "" + } + r.SetFailedToReplaceSecretsStatusCondition(ctx, &infisicalPushSecret, errorMessage) + + // Update the status of the InfisicalPushSecret + if err := r.Client.Status().Update(ctx, &infisicalPushSecret); err != nil { + return fmt.Errorf("unable to update status of InfisicalPushSecret [err=%s]", err) + } + + return nil + +} + +func (r *InfisicalPushSecretReconciler) DeleteManagedSecrets(ctx context.Context, logger logr.Logger, infisicalPushSecret v1alpha1.InfisicalPushSecret) error { + if infisicalPushSecret.Spec.DeletionPolicy != string(constants.PUSH_SECRET_DELETE_POLICY_ENABLED) { + return nil + } + + resourceVariables := r.getResourceVariables(infisicalPushSecret) + infisicalClient := resourceVariables.InfisicalClient + + destination := infisicalPushSecret.Spec.Destination + existingSecrets, err := infisicalClient.Secrets().List(infisicalSdk.ListSecretsOptions{ + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + IncludeImports: false, + }) + + if err != nil { + return fmt.Errorf("unable to list secrets [err=%s]", err) + } + + existingSecretsMappedById := make(map[string]infisicalSdk.Secret) + for _, secret := range existingSecrets { + existingSecretsMappedById[secret.ID] = secret + } + + for managedSecretId, managedSecretKey := range infisicalPushSecret.Status.ManagedSecrets { + + if _, ok := existingSecretsMappedById[managedSecretId]; ok { + logger.Info(fmt.Sprintf("Deleting secret with key [key=%s]", managedSecretKey)) + + _, err := infisicalClient.Secrets().Delete(infisicalSdk.DeleteSecretOptions{ + SecretKey: managedSecretKey, + ProjectID: destination.ProjectID, + Environment: destination.EnvironmentSlug, + SecretPath: destination.SecretsPath, + }) + + if err != nil { + logger.Info(fmt.Sprintf("unable to delete secret [key=%s] [err=%s]", managedSecretKey, err)) + continue + } + } + + } + + return nil +} diff --git a/k8-operator/controllers/auto_redeployment.go b/k8-operator/controllers/infisicalsecret/auto_redeployment.go similarity index 81% rename from k8-operator/controllers/auto_redeployment.go rename to k8-operator/controllers/infisicalsecret/auto_redeployment.go index cd5bf193b..599e126b5 100644 --- a/k8-operator/controllers/auto_redeployment.go +++ b/k8-operator/controllers/infisicalsecret/auto_redeployment.go @@ -6,6 +6,8 @@ import ( "sync" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/constants" + "github.com/go-logr/logr" v1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" @@ -15,7 +17,7 @@ import ( const DEPLOYMENT_SECRET_NAME_ANNOTATION_PREFIX = "secrets.infisical.com/managed-secret" const AUTO_RELOAD_DEPLOYMENT_ANNOTATION = "secrets.infisical.com/auto-reload" // needs to be set to true for a deployment to start auto redeploying -func (r *InfisicalSecretReconciler) ReconcileDeploymentsWithManagedSecrets(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (int, error) { +func (r *InfisicalSecretReconciler) ReconcileDeploymentsWithManagedSecrets(ctx context.Context, logger logr.Logger, infisicalSecret v1alpha1.InfisicalSecret) (int, error) { listOfDeployments := &v1.DeploymentList{} err := r.Client.List(ctx, listOfDeployments, &client.ListOptions{Namespace: infisicalSecret.Spec.ManagedSecretReference.SecretNamespace}) if err != nil { @@ -42,8 +44,8 @@ func (r *InfisicalSecretReconciler) ReconcileDeploymentsWithManagedSecrets(ctx c wg.Add(1) go func(d v1.Deployment, s corev1.Secret) { defer wg.Done() - if err := r.ReconcileDeployment(ctx, d, s); err != nil { - fmt.Printf("unable to reconcile deployment with [name=%v]. Will try next requeue", deployment.ObjectMeta.Name) + if err := r.ReconcileDeployment(ctx, logger, d, s); err != nil { + logger.Error(err, fmt.Sprintf("unable to reconcile deployment with [name=%v]. Will try next requeue", deployment.ObjectMeta.Name)) } }(deployment, *managedKubeSecret) } @@ -80,17 +82,17 @@ func (r *InfisicalSecretReconciler) IsDeploymentUsingManagedSecret(deployment v1 // This function ensures that a deployment is in sync with a Kubernetes secret by comparing their versions. // If the version of the secret is different from the version annotation on the deployment, the annotation is updated to trigger a restart of the deployment. -func (r *InfisicalSecretReconciler) ReconcileDeployment(ctx context.Context, deployment v1.Deployment, secret corev1.Secret) error { +func (r *InfisicalSecretReconciler) ReconcileDeployment(ctx context.Context, logger logr.Logger, deployment v1.Deployment, secret corev1.Secret) error { annotationKey := fmt.Sprintf("%s.%s", DEPLOYMENT_SECRET_NAME_ANNOTATION_PREFIX, secret.Name) - annotationValue := secret.Annotations[SECRET_VERSION_ANNOTATION] + annotationValue := secret.Annotations[constants.SECRET_VERSION_ANNOTATION] if deployment.Annotations[annotationKey] == annotationValue && deployment.Spec.Template.Annotations[annotationKey] == annotationValue { - fmt.Printf("The [deploymentName=%v] is already using the most up to date managed secrets. No action required.\n", deployment.ObjectMeta.Name) + logger.Info(fmt.Sprintf("The [deploymentName=%v] is already using the most up to date managed secrets. No action required.", deployment.ObjectMeta.Name)) return nil } - fmt.Printf("deployment is using outdated managed secret. Starting re-deployment [deploymentName=%v]\n", deployment.ObjectMeta.Name) + logger.Info(fmt.Sprintf("Deployment is using outdated managed secret. Starting re-deployment [deploymentName=%v]", deployment.ObjectMeta.Name)) if deployment.Spec.Template.Annotations == nil { deployment.Spec.Template.Annotations = make(map[string]string) diff --git a/k8-operator/controllers/conditions.go b/k8-operator/controllers/infisicalsecret/conditions.go similarity index 86% rename from k8-operator/controllers/conditions.go rename to k8-operator/controllers/infisicalsecret/conditions.go index 312b1d699..7ac3a9e5f 100644 --- a/k8-operator/controllers/conditions.go +++ b/k8-operator/controllers/infisicalsecret/conditions.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/util" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -40,7 +42,7 @@ func (r *InfisicalSecretReconciler) SetReadyToSyncSecretsConditions(ctx context. return r.Client.Status().Update(ctx, infisicalSecret) } -func (r *InfisicalSecretReconciler) SetInfisicalTokenLoadCondition(ctx context.Context, infisicalSecret *v1alpha1.InfisicalSecret, authStrategy AuthStrategyType, errorToConditionOn error) { +func (r *InfisicalSecretReconciler) SetInfisicalTokenLoadCondition(ctx context.Context, logger logr.Logger, infisicalSecret *v1alpha1.InfisicalSecret, authStrategy util.AuthStrategyType, errorToConditionOn error) { if infisicalSecret.Status.Conditions == nil { infisicalSecret.Status.Conditions = []metav1.Condition{} } @@ -63,11 +65,11 @@ func (r *InfisicalSecretReconciler) SetInfisicalTokenLoadCondition(ctx context.C err := r.Client.Status().Update(ctx, infisicalSecret) if err != nil { - fmt.Println("Could not set condition for LoadedInfisicalToken") + logger.Error(err, "Could not set condition for LoadedInfisicalToken") } } -func (r *InfisicalSecretReconciler) SetInfisicalAutoRedeploymentReady(ctx context.Context, infisicalSecret *v1alpha1.InfisicalSecret, numDeployments int, errorToConditionOn error) { +func (r *InfisicalSecretReconciler) SetInfisicalAutoRedeploymentReady(ctx context.Context, logger logr.Logger, infisicalSecret *v1alpha1.InfisicalSecret, numDeployments int, errorToConditionOn error) { if infisicalSecret.Status.Conditions == nil { infisicalSecret.Status.Conditions = []metav1.Condition{} } @@ -90,6 +92,6 @@ func (r *InfisicalSecretReconciler) SetInfisicalAutoRedeploymentReady(ctx contex err := r.Client.Status().Update(ctx, infisicalSecret) if err != nil { - fmt.Println("Could not set condition for AutoRedeployReady") + logger.Error(err, "Could not set condition for AutoRedeployReady") } } diff --git a/k8-operator/controllers/infisicalsecret_controller.go b/k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go similarity index 52% rename from k8-operator/controllers/infisicalsecret_controller.go rename to k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go index 90baf2396..f5a974a67 100644 --- a/k8-operator/controllers/infisicalsecret_controller.go +++ b/k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go @@ -15,13 +15,24 @@ import ( secretsv1alpha1 "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" - infisicalSdk "github.com/infisical/go-sdk" + controllerhelpers "github.com/Infisical/infisical/k8-operator/packages/controllerutil" + "github.com/Infisical/infisical/k8-operator/packages/util" + "github.com/go-logr/logr" ) // InfisicalSecretReconciler reconciles a InfisicalSecret object type InfisicalSecretReconciler struct { client.Client - Scheme *runtime.Scheme + BaseLogger logr.Logger + Scheme *runtime.Scheme +} + +const FINALIZER_NAME = "secrets.finalizers.infisical.com" + +var infisicalSecretResourceVariablesMap map[string]util.ResourceVariables = make(map[string]util.ResourceVariables) + +func (r *InfisicalSecretReconciler) GetLogger(req ctrl.Request) logr.Logger { + return r.BaseLogger.WithValues("infisicalsecret", req.NamespacedName) } //+kubebuilder:rbac:groups=secrets.infisical.com,resources=infisicalsecrets,verbs=get;list;watch;create;update;patch;delete @@ -37,29 +48,21 @@ type InfisicalSecretReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.1/pkg/reconcile -type ResourceVariables struct { - infisicalClient infisicalSdk.InfisicalClientInterface - cancelCtx context.CancelFunc - authDetails AuthenticationDetails -} - -const FINALIZER_NAME = "secrets.finalizers.infisical.com" - -// Maps the infisicalSecretCR.UID to a infisicalSdk.InfisicalClientInterface and AuthenticationDetails. -var resourceVariablesMap = make(map[string]ResourceVariables) - func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - var infisicalSecretCR secretsv1alpha1.InfisicalSecret + + logger := r.GetLogger(req) + + var infisicalSecretCRD secretsv1alpha1.InfisicalSecret requeueTime := time.Minute // seconds - err := r.Get(ctx, req.NamespacedName, &infisicalSecretCR) + err := r.Get(ctx, req.NamespacedName, &infisicalSecretCRD) if err != nil { if errors.IsNotFound(err) { return ctrl.Result{ Requeue: false, }, nil } else { - fmt.Printf("\nUnable to fetch Infisical Secret CRD from cluster because [err=%v]", err) + logger.Error(err, "unable to fetch Infisical Secret CRD from cluster") return ctrl.Result{ RequeueAfter: requeueTime, }, nil @@ -68,80 +71,82 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Remove finalizers if they exist. This is to support previous InfisicalSecret CRD's that have finalizers on them. // In order to delete secrets with finalizers, we first remove the finalizers so we can use the simplified and improved deletion process - if !infisicalSecretCR.ObjectMeta.DeletionTimestamp.IsZero() && len(infisicalSecretCR.ObjectMeta.Finalizers) > 0 { - infisicalSecretCR.ObjectMeta.Finalizers = []string{} - if err := r.Update(ctx, &infisicalSecretCR); err != nil { - fmt.Printf("Error removing finalizers from Infisical Secret %s: %v\n", infisicalSecretCR.Name, err) + if !infisicalSecretCRD.ObjectMeta.DeletionTimestamp.IsZero() && len(infisicalSecretCRD.ObjectMeta.Finalizers) > 0 { + infisicalSecretCRD.ObjectMeta.Finalizers = []string{} + if err := r.Update(ctx, &infisicalSecretCRD); err != nil { + logger.Error(err, fmt.Sprintf("Error removing finalizers from Infisical Secret %s", infisicalSecretCRD.Name)) return ctrl.Result{}, err } // Our finalizers have been removed, so the reconciler can do nothing. return ctrl.Result{}, nil } - if infisicalSecretCR.Spec.ResyncInterval != 0 { - requeueTime = time.Second * time.Duration(infisicalSecretCR.Spec.ResyncInterval) - fmt.Printf("\nManual re-sync interval set. Interval: %v\n", requeueTime) + if infisicalSecretCRD.Spec.ResyncInterval != 0 { + requeueTime = time.Second * time.Duration(infisicalSecretCRD.Spec.ResyncInterval) + logger.Info(fmt.Sprintf("Manual re-sync interval set. Interval: %v", requeueTime)) + } else { - fmt.Printf("\nRe-sync interval set. Interval: %v\n", requeueTime) + logger.Info(fmt.Sprintf("Re-sync interval set. Interval: %v", requeueTime)) } // Check if the resource is already marked for deletion - if infisicalSecretCR.GetDeletionTimestamp() != nil { + if infisicalSecretCRD.GetDeletionTimestamp() != nil { return ctrl.Result{ Requeue: false, }, nil } // Get modified/default config - infisicalConfig, err := r.GetInfisicalConfigMap(ctx) + infisicalConfig, err := controllerhelpers.GetInfisicalConfigMap(ctx, r.Client) if err != nil { - fmt.Printf("unable to fetch infisical-config [err=%s]. Will requeue after [requeueTime=%v]\n", err, requeueTime) + logger.Error(err, fmt.Sprintf("unable to fetch infisical-config. Will requeue after [requeueTime=%v]", requeueTime)) return ctrl.Result{ RequeueAfter: requeueTime, }, nil } - if infisicalSecretCR.Spec.HostAPI == "" { + if infisicalSecretCRD.Spec.HostAPI == "" { api.API_HOST_URL = infisicalConfig["hostAPI"] } else { - api.API_HOST_URL = infisicalSecretCR.Spec.HostAPI + api.API_HOST_URL = infisicalSecretCRD.Spec.HostAPI } - if infisicalSecretCR.Spec.TLS.CaRef.SecretName != "" { - api.API_CA_CERTIFICATE, err = r.GetInfisicalCaCertificateFromKubeSecret(ctx, infisicalSecretCR) + if infisicalSecretCRD.Spec.TLS.CaRef.SecretName != "" { + api.API_CA_CERTIFICATE, err = r.getInfisicalCaCertificateFromKubeSecret(ctx, infisicalSecretCRD) if err != nil { - fmt.Printf("unable to fetch CA certificate [err=%s]. Will requeue after [requeueTime=%v]\n", err, requeueTime) + logger.Error(err, fmt.Sprintf("unable to fetch CA certificate. Will requeue after [requeueTime=%v]", requeueTime)) return ctrl.Result{ RequeueAfter: requeueTime, }, nil } - fmt.Println("Using custom CA certificate...") + logger.Info("Using custom CA certificate...") } else { api.API_CA_CERTIFICATE = "" } - err = r.ReconcileInfisicalSecret(ctx, infisicalSecretCR) - r.SetReadyToSyncSecretsConditions(ctx, &infisicalSecretCR, err) + err = r.ReconcileInfisicalSecret(ctx, logger, infisicalSecretCRD) + r.SetReadyToSyncSecretsConditions(ctx, &infisicalSecretCRD, err) if err != nil { - fmt.Printf("unable to reconcile Infisical Secret because [err=%v]. Will requeue after [requeueTime=%v]\n", err, requeueTime) + + logger.Error(err, fmt.Sprintf("unable to reconcile InfisicalSecret. Will requeue after [requeueTime=%v]", requeueTime)) return ctrl.Result{ RequeueAfter: requeueTime, }, nil } - numDeployments, err := r.ReconcileDeploymentsWithManagedSecrets(ctx, infisicalSecretCR) - r.SetInfisicalAutoRedeploymentReady(ctx, &infisicalSecretCR, numDeployments, err) + numDeployments, err := r.ReconcileDeploymentsWithManagedSecrets(ctx, logger, infisicalSecretCRD) + r.SetInfisicalAutoRedeploymentReady(ctx, logger, &infisicalSecretCRD, numDeployments, err) if err != nil { - fmt.Printf("unable to reconcile auto redeployment because [err=%v]", err) + logger.Error(err, fmt.Sprintf("unable to reconcile auto redeployment. Will requeue after [requeueTime=%v]", requeueTime)) return ctrl.Result{ RequeueAfter: requeueTime, }, nil } // Sync again after the specified time - fmt.Printf("Operator will requeue after [%v] \n", requeueTime) + logger.Info(fmt.Sprintf("Operator will requeue after [%v]", requeueTime)) return ctrl.Result{ RequeueAfter: requeueTime, }, nil @@ -151,16 +156,20 @@ func (r *InfisicalSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&secretsv1alpha1.InfisicalSecret{}, builder.WithPredicates(predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { - if rv, ok := resourceVariablesMap[string(e.ObjectNew.GetUID())]; ok { - rv.cancelCtx() - delete(resourceVariablesMap, string(e.ObjectNew.GetUID())) + if infisicalSecretResourceVariablesMap != nil { + if rv, ok := infisicalSecretResourceVariablesMap[string(e.ObjectNew.GetUID())]; ok { + rv.CancelCtx() + delete(infisicalSecretResourceVariablesMap, string(e.ObjectNew.GetUID())) + } } return true }, DeleteFunc: func(e event.DeleteEvent) bool { - if rv, ok := resourceVariablesMap[string(e.Object.GetUID())]; ok { - rv.cancelCtx() - delete(resourceVariablesMap, string(e.Object.GetUID())) + if infisicalSecretResourceVariablesMap != nil { + if rv, ok := infisicalSecretResourceVariablesMap[string(e.Object.GetUID())]; ok { + rv.CancelCtx() + delete(infisicalSecretResourceVariablesMap, string(e.Object.GetUID())) + } } return true }, diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go similarity index 55% rename from k8-operator/controllers/infisicalsecret_helper.go rename to k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go index cdf2a4a26..28a9843c9 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret/infisicalsecret_helper.go @@ -10,8 +10,10 @@ import ( "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" + "github.com/Infisical/infisical/k8-operator/packages/constants" "github.com/Infisical/infisical/k8-operator/packages/model" "github.com/Infisical/infisical/k8-operator/packages/util" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/types" @@ -20,113 +22,61 @@ import ( k8Errors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" ) -const SERVICE_ACCOUNT_ACCESS_KEY = "serviceAccountAccessKey" -const SERVICE_ACCOUNT_PUBLIC_KEY = "serviceAccountPublicKey" -const SERVICE_ACCOUNT_PRIVATE_KEY = "serviceAccountPrivateKey" - -const INFISICAL_MACHINE_IDENTITY_CLIENT_ID = "clientId" -const INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET = "clientSecret" - -const INFISICAL_TOKEN_SECRET_KEY_NAME = "infisicalToken" -const SECRET_VERSION_ANNOTATION = "secrets.infisical.com/version" // used to set the version of secrets via Etag -const OPERATOR_SETTINGS_CONFIGMAP_NAME = "infisical-config" -const OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE = "infisical-operator-system" -const INFISICAL_DOMAIN = "https://app.infisical.com/api" - -func (r *InfisicalSecretReconciler) HandleAuthentication(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { +func (r *InfisicalSecretReconciler) handleAuthentication(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (util.AuthenticationDetails, error) { // ? Legacy support, service token auth - infisicalToken, err := r.GetInfisicalTokenFromKubeSecret(ctx, infisicalSecret) + infisicalToken, err := r.getInfisicalTokenFromKubeSecret(ctx, infisicalSecret) if err != nil { - return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get service token from kube secret [err=%s]", err) + return util.AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get service token from kube secret [err=%s]", err) } if infisicalToken != "" { infisicalClient.Auth().SetAccessToken(infisicalToken) - return AuthenticationDetails{authStrategy: AuthStrategy.SERVICE_TOKEN}, nil + return util.AuthenticationDetails{AuthStrategy: util.AuthStrategy.SERVICE_TOKEN}, nil } // ? Legacy support, service account auth - serviceAccountCreds, err := r.GetInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) + serviceAccountCreds, err := r.getInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) if err != nil { - return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get service account creds from kube secret [err=%s]", err) + return util.AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get service account creds from kube secret [err=%s]", err) } if serviceAccountCreds.AccessKey != "" || serviceAccountCreds.PrivateKey != "" || serviceAccountCreds.PublicKey != "" { infisicalClient.Auth().SetAccessToken(serviceAccountCreds.AccessKey) - return AuthenticationDetails{authStrategy: AuthStrategy.SERVICE_ACCOUNT}, nil + return util.AuthenticationDetails{AuthStrategy: util.AuthStrategy.SERVICE_ACCOUNT}, nil } - authStrategies := map[AuthStrategyType]func(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error){ - AuthStrategy.UNIVERSAL_MACHINE_IDENTITY: r.handleUniversalAuth, - AuthStrategy.KUBERNETES_MACHINE_IDENTITY: r.handleKubernetesAuth, - AuthStrategy.AWS_IAM_MACHINE_IDENTITY: r.handleAwsIamAuth, - AuthStrategy.AZURE_MACHINE_IDENTITY: r.handleAzureAuth, - AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY: r.handleGcpIdTokenAuth, - AuthStrategy.GCP_IAM_MACHINE_IDENTITY: r.handleGcpIamAuth, + authStrategies := map[util.AuthStrategyType]func(ctx context.Context, reconcilerClient client.Client, secretCrd util.SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (util.AuthenticationDetails, error){ + util.AuthStrategy.UNIVERSAL_MACHINE_IDENTITY: util.HandleUniversalAuth, + util.AuthStrategy.KUBERNETES_MACHINE_IDENTITY: util.HandleKubernetesAuth, + util.AuthStrategy.AWS_IAM_MACHINE_IDENTITY: util.HandleAwsIamAuth, + util.AuthStrategy.AZURE_MACHINE_IDENTITY: util.HandleAzureAuth, + util.AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY: util.HandleGcpIdTokenAuth, + util.AuthStrategy.GCP_IAM_MACHINE_IDENTITY: util.HandleGcpIamAuth, } for authStrategy, authHandler := range authStrategies { - authDetails, err := authHandler(ctx, infisicalSecret, infisicalClient) + authDetails, err := authHandler(ctx, r.Client, util.SecretAuthInput{ + Secret: infisicalSecret, + Type: util.SecretCrd.INFISICAL_SECRET, + }, infisicalClient) if err == nil { return authDetails, nil } - if !errors.Is(err, ErrAuthNotApplicable) { - return AuthenticationDetails{}, fmt.Errorf("authentication failed for strategy [%s] [err=%w]", authStrategy, err) + if !errors.Is(err, util.ErrAuthNotApplicable) { + return util.AuthenticationDetails{}, fmt.Errorf("authentication failed for strategy [%s] [err=%w]", authStrategy, err) } } - return AuthenticationDetails{}, fmt.Errorf("no authentication method provided") + return util.AuthenticationDetails{}, fmt.Errorf("no authentication method provided") } -func (r *InfisicalSecretReconciler) GetInfisicalConfigMap(ctx context.Context) (configMap map[string]string, errToReturn error) { - // default key values - defaultConfigMapData := make(map[string]string) - defaultConfigMapData["hostAPI"] = INFISICAL_DOMAIN - - kubeConfigMap := &corev1.ConfigMap{} - err := r.Client.Get(ctx, types.NamespacedName{ - Namespace: OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE, - Name: OPERATOR_SETTINGS_CONFIGMAP_NAME, - }, kubeConfigMap) - - if err != nil { - if k8Errors.IsNotFound(err) { - kubeConfigMap = nil - } else { - return nil, fmt.Errorf("GetConfigMapByNamespacedName: unable to fetch config map in [namespacedName=%s] [err=%s]", OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE, err) - } - } - - if kubeConfigMap == nil { - return defaultConfigMapData, nil - } else { - for key, value := range defaultConfigMapData { - _, exists := kubeConfigMap.Data[key] - if !exists { - kubeConfigMap.Data[key] = value - } - } - - return kubeConfigMap.Data, nil - } -} - -func (r *InfisicalSecretReconciler) GetKubeSecretByNamespacedName(ctx context.Context, namespacedName types.NamespacedName) (*corev1.Secret, error) { - kubeSecret := &corev1.Secret{} - err := r.Client.Get(ctx, namespacedName, kubeSecret) - if err != nil { - kubeSecret = nil - } - - return kubeSecret, err -} - -func (r *InfisicalSecretReconciler) GetInfisicalTokenFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (string, error) { +func (r *InfisicalSecretReconciler) getInfisicalTokenFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (string, error) { // default to new secret ref structure secretName := infisicalSecret.Spec.Authentication.ServiceToken.ServiceTokenSecretReference.SecretName secretNamespace := infisicalSecret.Spec.Authentication.ServiceToken.ServiceTokenSecretReference.SecretNamespace @@ -139,7 +89,7 @@ func (r *InfisicalSecretReconciler) GetInfisicalTokenFromKubeSecret(ctx context. secretNamespace = infisicalSecret.Spec.TokenSecretReference.SecretNamespace } - tokenSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ + tokenSecret, err := util.GetKubeSecretByNamespacedName(ctx, r.Client, types.NamespacedName{ Namespace: secretNamespace, Name: secretName, }) @@ -152,36 +102,14 @@ func (r *InfisicalSecretReconciler) GetInfisicalTokenFromKubeSecret(ctx context. return "", fmt.Errorf("failed to read Infisical token secret from secret named [%s] in namespace [%s]: with error [%w]", infisicalSecret.Spec.TokenSecretReference.SecretName, infisicalSecret.Spec.TokenSecretReference.SecretNamespace, err) } - infisicalServiceToken := tokenSecret.Data[INFISICAL_TOKEN_SECRET_KEY_NAME] + infisicalServiceToken := tokenSecret.Data[constants.INFISICAL_TOKEN_SECRET_KEY_NAME] return strings.Replace(string(infisicalServiceToken), " ", "", -1), nil } -func (r *InfisicalSecretReconciler) GetInfisicalUniversalAuthFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (machineIdentityDetails model.MachineIdentityDetails, err error) { +func (r *InfisicalSecretReconciler) getInfisicalCaCertificateFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (caCertificate string, err error) { - universalAuthCredsFromKubeSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ - Namespace: infisicalSecret.Spec.Authentication.UniversalAuth.CredentialsRef.SecretNamespace, - Name: infisicalSecret.Spec.Authentication.UniversalAuth.CredentialsRef.SecretName, - }) - - if k8Errors.IsNotFound(err) { - return model.MachineIdentityDetails{}, nil - } - - if err != nil { - return model.MachineIdentityDetails{}, fmt.Errorf("something went wrong when fetching your machine identity credentials [err=%s]", err) - } - - clientIdFromSecret := universalAuthCredsFromKubeSecret.Data[INFISICAL_MACHINE_IDENTITY_CLIENT_ID] - clientSecretFromSecret := universalAuthCredsFromKubeSecret.Data[INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET] - - return model.MachineIdentityDetails{ClientId: string(clientIdFromSecret), ClientSecret: string(clientSecretFromSecret)}, nil - -} - -func (r *InfisicalSecretReconciler) GetInfisicalCaCertificateFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (caCertificate string, err error) { - - caCertificateFromKubeSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ + caCertificateFromKubeSecret, err := util.GetKubeSecretByNamespacedName(ctx, r.Client, types.NamespacedName{ Namespace: infisicalSecret.Spec.TLS.CaRef.SecretNamespace, Name: infisicalSecret.Spec.TLS.CaRef.SecretName, }) @@ -197,13 +125,12 @@ func (r *InfisicalSecretReconciler) GetInfisicalCaCertificateFromKubeSecret(ctx caCertificateFromSecret := string(caCertificateFromKubeSecret.Data[infisicalSecret.Spec.TLS.CaRef.SecretKey]) return caCertificateFromSecret, nil - } // Fetches service account credentials from a Kubernetes secret specified in the infisicalSecret object, extracts the access key, public key, and private key from the secret, and returns them as a ServiceAccountCredentials object. // If any keys are missing or an error occurs, returns an empty object or an error object, respectively. -func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (serviceAccountDetails model.ServiceAccountDetails, err error) { - serviceAccountCredsFromKubeSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ +func (r *InfisicalSecretReconciler) getInfisicalServiceAccountCredentialsFromKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (serviceAccountDetails model.ServiceAccountDetails, err error) { + serviceAccountCredsFromKubeSecret, err := util.GetKubeSecretByNamespacedName(ctx, r.Client, types.NamespacedName{ Namespace: infisicalSecret.Spec.Authentication.ServiceAccount.ServiceAccountSecretReference.SecretNamespace, Name: infisicalSecret.Spec.Authentication.ServiceAccount.ServiceAccountSecretReference.SecretName, }) @@ -216,9 +143,9 @@ func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKub return model.ServiceAccountDetails{}, fmt.Errorf("something went wrong when fetching your service account credentials [err=%s]", err) } - accessKeyFromSecret := serviceAccountCredsFromKubeSecret.Data[SERVICE_ACCOUNT_ACCESS_KEY] - publicKeyFromSecret := serviceAccountCredsFromKubeSecret.Data[SERVICE_ACCOUNT_PUBLIC_KEY] - privateKeyFromSecret := serviceAccountCredsFromKubeSecret.Data[SERVICE_ACCOUNT_PRIVATE_KEY] + accessKeyFromSecret := serviceAccountCredsFromKubeSecret.Data[constants.SERVICE_ACCOUNT_ACCESS_KEY] + publicKeyFromSecret := serviceAccountCredsFromKubeSecret.Data[constants.SERVICE_ACCOUNT_PUBLIC_KEY] + privateKeyFromSecret := serviceAccountCredsFromKubeSecret.Data[constants.SERVICE_ACCOUNT_PRIVATE_KEY] if accessKeyFromSecret == nil || publicKeyFromSecret == nil || privateKeyFromSecret == nil { return model.ServiceAccountDetails{}, nil @@ -227,7 +154,7 @@ func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKub return model.ServiceAccountDetails{AccessKey: string(accessKeyFromSecret), PrivateKey: string(privateKeyFromSecret), PublicKey: string(publicKeyFromSecret)}, nil } -func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { +func (r *InfisicalSecretReconciler) createInfisicalManagedKubeSecret(ctx context.Context, logger logr.Logger, infisicalSecret v1alpha1.InfisicalSecret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { plainProcessedSecrets := make(map[string][]byte) secretType := infisicalSecret.Spec.ManagedSecretReference.SecretType managedTemplateData := infisicalSecret.Spec.ManagedSecretReference.Template @@ -283,7 +210,7 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context } } - annotations[SECRET_VERSION_ANNOTATION] = ETag + annotations[constants.SECRET_VERSION_ANNOTATION] = ETag // create a new secret as specified by the managed secret spec of CRD newKubeSecretInstance := &corev1.Secret{ @@ -310,11 +237,11 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context return fmt.Errorf("unable to create the managed Kubernetes secret : %w", err) } - fmt.Printf("Successfully created a managed Kubernetes secret with your Infisical secrets. Type: %s\n", secretType) + logger.Info(fmt.Sprintf("Successfully created a managed Kubernetes secret with your Infisical secrets. Type: %s", secretType)) return nil } -func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, managedKubeSecret corev1.Secret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { +func (r *InfisicalSecretReconciler) updateInfisicalManagedKubeSecret(ctx context.Context, logger logr.Logger, infisicalSecret v1alpha1.InfisicalSecret, managedKubeSecret corev1.Secret, secretsFromAPI []model.SingleEnvironmentVariable, ETag string) error { managedTemplateData := infisicalSecret.Spec.ManagedSecretReference.Template plainProcessedSecrets := make(map[string][]byte) @@ -354,22 +281,22 @@ func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context } managedKubeSecret.Data = plainProcessedSecrets - managedKubeSecret.ObjectMeta.Annotations[SECRET_VERSION_ANNOTATION] = ETag + managedKubeSecret.ObjectMeta.Annotations[constants.SECRET_VERSION_ANNOTATION] = ETag err := r.Client.Update(ctx, &managedKubeSecret) if err != nil { return fmt.Errorf("unable to update Kubernetes secret because [%w]", err) } - fmt.Println("successfully updated managed Kubernetes secret") + logger.Info("successfully updated managed Kubernetes secret") return nil } -func (r *InfisicalSecretReconciler) GetResourceVariables(infisicalSecret v1alpha1.InfisicalSecret) ResourceVariables { +func (r *InfisicalSecretReconciler) getResourceVariables(infisicalSecret v1alpha1.InfisicalSecret) util.ResourceVariables { - var resourceVariables ResourceVariables + var resourceVariables util.ResourceVariables - if _, ok := resourceVariablesMap[string(infisicalSecret.UID)]; !ok { + if _, ok := infisicalSecretResourceVariablesMap[string(infisicalSecret.UID)]; !ok { ctx, cancel := context.WithCancel(context.Background()) @@ -379,52 +306,52 @@ func (r *InfisicalSecretReconciler) GetResourceVariables(infisicalSecret v1alpha UserAgent: api.USER_AGENT_NAME, }) - resourceVariablesMap[string(infisicalSecret.UID)] = ResourceVariables{ - infisicalClient: client, - cancelCtx: cancel, - authDetails: AuthenticationDetails{}, + infisicalSecretResourceVariablesMap[string(infisicalSecret.UID)] = util.ResourceVariables{ + InfisicalClient: client, + CancelCtx: cancel, + AuthDetails: util.AuthenticationDetails{}, } - resourceVariables = resourceVariablesMap[string(infisicalSecret.UID)] + resourceVariables = infisicalSecretResourceVariablesMap[string(infisicalSecret.UID)] } else { - resourceVariables = resourceVariablesMap[string(infisicalSecret.UID)] + resourceVariables = infisicalSecretResourceVariablesMap[string(infisicalSecret.UID)] } return resourceVariables } -func (r *InfisicalSecretReconciler) UpdateResourceVariables(infisicalSecret v1alpha1.InfisicalSecret, resourceVariables ResourceVariables) { - resourceVariablesMap[string(infisicalSecret.UID)] = resourceVariables +func (r *InfisicalSecretReconciler) updateResourceVariables(infisicalSecret v1alpha1.InfisicalSecret, resourceVariables util.ResourceVariables) { + infisicalSecretResourceVariablesMap[string(infisicalSecret.UID)] = resourceVariables } -func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) error { +func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context, logger logr.Logger, infisicalSecret v1alpha1.InfisicalSecret) error { - resourceVariables := r.GetResourceVariables(infisicalSecret) - infisicalClient := resourceVariables.infisicalClient - cancelCtx := resourceVariables.cancelCtx - authDetails := resourceVariables.authDetails + resourceVariables := r.getResourceVariables(infisicalSecret) + infisicalClient := resourceVariables.InfisicalClient + cancelCtx := resourceVariables.CancelCtx + authDetails := resourceVariables.AuthDetails var err error - if authDetails.authStrategy == "" { - fmt.Println("ReconcileInfisicalSecret: No authentication strategy found. Attempting to authenticate") - authDetails, err = r.HandleAuthentication(ctx, infisicalSecret, infisicalClient) - r.SetInfisicalTokenLoadCondition(ctx, &infisicalSecret, authDetails.authStrategy, err) + if authDetails.AuthStrategy == "" { + logger.Info("No authentication strategy found. Attempting to authenticate") + authDetails, err = r.handleAuthentication(ctx, infisicalSecret, infisicalClient) + r.SetInfisicalTokenLoadCondition(ctx, logger, &infisicalSecret, authDetails.AuthStrategy, err) if err != nil { return fmt.Errorf("unable to authenticate [err=%s]", err) } - r.UpdateResourceVariables(infisicalSecret, ResourceVariables{ - infisicalClient: infisicalClient, - cancelCtx: cancelCtx, - authDetails: authDetails, + r.updateResourceVariables(infisicalSecret, util.ResourceVariables{ + InfisicalClient: infisicalClient, + CancelCtx: cancelCtx, + AuthDetails: authDetails, }) } // Look for managed secret by name and namespace - managedKubeSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ + managedKubeSecret, err := util.GetKubeSecretByNamespacedName(ctx, r.Client, types.NamespacedName{ Name: infisicalSecret.Spec.ManagedSecretReference.SecretName, Namespace: infisicalSecret.Spec.ManagedSecretReference.SecretNamespace, }) @@ -436,14 +363,14 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context // Get exiting Etag if exists secretVersionBasedOnETag := "" if managedKubeSecret != nil { - secretVersionBasedOnETag = managedKubeSecret.Annotations[SECRET_VERSION_ANNOTATION] + secretVersionBasedOnETag = managedKubeSecret.Annotations[constants.SECRET_VERSION_ANNOTATION] } var plainTextSecretsFromApi []model.SingleEnvironmentVariable var updateDetails model.RequestUpdateUpdateDetails - if authDetails.authStrategy == AuthStrategy.SERVICE_ACCOUNT { // Service Account // ! Legacy auth method - serviceAccountCreds, err := r.GetInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) + if authDetails.AuthStrategy == util.AuthStrategy.SERVICE_ACCOUNT { // Service Account // ! Legacy auth method + serviceAccountCreds, err := r.getInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) if err != nil { return fmt.Errorf("ReconcileInfisicalSecret: unable to get service account creds from kube secret [err=%s]", err) } @@ -453,10 +380,10 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) } - fmt.Println("ReconcileInfisicalSecret: Fetched secrets via service account") + logger.Info("ReconcileInfisicalSecret: Fetched secrets via service account") - } else if authDetails.authStrategy == AuthStrategy.SERVICE_TOKEN { // Service Tokens // ! Legacy / Deprecated auth method - infisicalToken, err := r.GetInfisicalTokenFromKubeSecret(ctx, infisicalSecret) + } else if authDetails.AuthStrategy == util.AuthStrategy.SERVICE_TOKEN { // Service Tokens // ! Legacy / Deprecated auth method + infisicalToken, err := r.getInfisicalTokenFromKubeSecret(ctx, infisicalSecret) if err != nil { return fmt.Errorf("ReconcileInfisicalSecret: unable to get service token from kube secret [err=%s]", err) } @@ -470,28 +397,30 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) } - fmt.Println("ReconcileInfisicalSecret: Fetched secrets via [type=SERVICE_TOKEN]") - } else if authDetails.isMachineIdentityAuth { // * Machine Identity authentication, the SDK will be authenticated at this point - plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaMachineIdentity(infisicalClient, secretVersionBasedOnETag, authDetails.machineIdentityScope) + logger.Info("ReconcileInfisicalSecret: Fetched secrets via [type=SERVICE_TOKEN]") + + } else if authDetails.IsMachineIdentityAuth { // * Machine Identity authentication, the SDK will be authenticated at this point + plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaMachineIdentity(infisicalClient, secretVersionBasedOnETag, authDetails.MachineIdentityScope) if err != nil { return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) } - fmt.Printf("ReconcileInfisicalSecret: Fetched secrets via machine identity [type=%v]\n", authDetails.authStrategy) + + logger.Info(fmt.Sprintf("ReconcileInfisicalSecret: Fetched secrets via machine identity [type=%v]", authDetails.AuthStrategy)) } else { return errors.New("no authentication method provided yet. Please configure a authentication method then try again") } if !updateDetails.Modified { - fmt.Println("No secrets modified so reconcile not needed") + logger.Info("ReconcileInfisicalSecret: No secrets modified so reconcile not needed") return nil } if managedKubeSecret == nil { - return r.CreateInfisicalManagedKubeSecret(ctx, infisicalSecret, plainTextSecretsFromApi, updateDetails.ETag) + return r.createInfisicalManagedKubeSecret(ctx, logger, infisicalSecret, plainTextSecretsFromApi, updateDetails.ETag) } else { - return r.UpdateInfisicalManagedKubeSecret(ctx, infisicalSecret, *managedKubeSecret, plainTextSecretsFromApi, updateDetails.ETag) + return r.updateInfisicalManagedKubeSecret(ctx, logger, infisicalSecret, *managedKubeSecret, plainTextSecretsFromApi, updateDetails.ETag) } } diff --git a/k8-operator/controllers/suite_test.go b/k8-operator/controllers/infisicalsecret/suite_test.go similarity index 100% rename from k8-operator/controllers/suite_test.go rename to k8-operator/controllers/infisicalsecret/suite_test.go diff --git a/k8-operator/controllers/infisicalsecret_auth.go b/k8-operator/controllers/infisicalsecret_auth.go deleted file mode 100644 index 06e1c659a..000000000 --- a/k8-operator/controllers/infisicalsecret_auth.go +++ /dev/null @@ -1,150 +0,0 @@ -package controllers - -import ( - "context" - "errors" - "fmt" - - "github.com/Infisical/infisical/k8-operator/api/v1alpha1" - "github.com/Infisical/infisical/k8-operator/packages/util" - infisicalSdk "github.com/infisical/go-sdk" -) - -type AuthStrategyType string - -var AuthStrategy = struct { - SERVICE_TOKEN AuthStrategyType - SERVICE_ACCOUNT AuthStrategyType - UNIVERSAL_MACHINE_IDENTITY AuthStrategyType - KUBERNETES_MACHINE_IDENTITY AuthStrategyType - AWS_IAM_MACHINE_IDENTITY AuthStrategyType - AZURE_MACHINE_IDENTITY AuthStrategyType - GCP_ID_TOKEN_MACHINE_IDENTITY AuthStrategyType - GCP_IAM_MACHINE_IDENTITY AuthStrategyType -}{ - SERVICE_TOKEN: "SERVICE_TOKEN", - SERVICE_ACCOUNT: "SERVICE_ACCOUNT", - UNIVERSAL_MACHINE_IDENTITY: "UNIVERSAL_MACHINE_IDENTITY", - KUBERNETES_MACHINE_IDENTITY: "KUBERNETES_AUTH_MACHINE_IDENTITY", - AWS_IAM_MACHINE_IDENTITY: "AWS_IAM_MACHINE_IDENTITY", - AZURE_MACHINE_IDENTITY: "AZURE_MACHINE_IDENTITY", - GCP_ID_TOKEN_MACHINE_IDENTITY: "GCP_ID_TOKEN_MACHINE_IDENTITY", - GCP_IAM_MACHINE_IDENTITY: "GCP_IAM_MACHINE_IDENTITY", -} - -type AuthenticationDetails struct { - authStrategy AuthStrategyType - machineIdentityScope v1alpha1.MachineIdentityScopeInWorkspace // This will only be set if a machine identity auth method is used (e.g. UniversalAuth or KubernetesAuth, etc.) - isMachineIdentityAuth bool -} - -var ErrAuthNotApplicable = errors.New("authentication not applicable") - -func (r *InfisicalSecretReconciler) handleUniversalAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { - - // Machine Identities: - universalAuthKubeSecret, err := r.GetInfisicalUniversalAuthFromKubeSecret(ctx, infisicalSecret) - universalAuthSpec := infisicalSecret.Spec.Authentication.UniversalAuth - - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get machine identity creds from kube secret [err=%s]", err) - } - - if universalAuthKubeSecret.ClientId == "" && universalAuthKubeSecret.ClientSecret == "" { - return AuthenticationDetails{}, ErrAuthNotApplicable - } - - _, err = infisicalClient.Auth().UniversalAuthLogin(universalAuthKubeSecret.ClientId, universalAuthKubeSecret.ClientSecret) - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to login with machine identity credentials [err=%s]", err) - } - - fmt.Println("Successfully authenticated with machine identity credentials") - - return AuthenticationDetails{authStrategy: AuthStrategy.UNIVERSAL_MACHINE_IDENTITY, machineIdentityScope: universalAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil - -} - -func (r *InfisicalSecretReconciler) handleKubernetesAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { - kubernetesAuthSpec := infisicalSecret.Spec.Authentication.KubernetesAuth - - if kubernetesAuthSpec.IdentityID == "" { - return AuthenticationDetails{}, ErrAuthNotApplicable - } - - serviceAccountToken, err := util.GetServiceAccountToken(r.Client, kubernetesAuthSpec.ServiceAccountRef.Namespace, kubernetesAuthSpec.ServiceAccountRef.Name) - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to get service account token [err=%s]", err) - } - - _, err = infisicalClient.Auth().KubernetesRawServiceAccountTokenLogin(kubernetesAuthSpec.IdentityID, serviceAccountToken) - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to login with Kubernetes native auth [err=%s]", err) - } - - return AuthenticationDetails{authStrategy: AuthStrategy.KUBERNETES_MACHINE_IDENTITY, machineIdentityScope: kubernetesAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil - -} - -func (r *InfisicalSecretReconciler) handleAwsIamAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { - awsIamAuthSpec := infisicalSecret.Spec.Authentication.AwsIamAuth - - if awsIamAuthSpec.IdentityID == "" { - return AuthenticationDetails{}, ErrAuthNotApplicable - } - - _, err := infisicalClient.Auth().AwsIamAuthLogin(awsIamAuthSpec.IdentityID) - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to login with AWS IAM auth [err=%s]", err) - } - - return AuthenticationDetails{authStrategy: AuthStrategy.AWS_IAM_MACHINE_IDENTITY, machineIdentityScope: awsIamAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil - -} - -func (r *InfisicalSecretReconciler) handleAzureAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { - azureAuthSpec := infisicalSecret.Spec.Authentication.AzureAuth - - if azureAuthSpec.IdentityID == "" { - return AuthenticationDetails{}, ErrAuthNotApplicable - } - - _, err := infisicalClient.Auth().AzureAuthLogin(azureAuthSpec.IdentityID, azureAuthSpec.Resource) // If resource is empty(""), it will default to "https://management.azure.com/" in the SDK. - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to login with Azure auth [err=%s]", err) - } - - return AuthenticationDetails{authStrategy: AuthStrategy.AZURE_MACHINE_IDENTITY, machineIdentityScope: azureAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil - -} - -func (r *InfisicalSecretReconciler) handleGcpIdTokenAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { - gcpIdTokenSpec := infisicalSecret.Spec.Authentication.GcpIdTokenAuth - - if gcpIdTokenSpec.IdentityID == "" { - return AuthenticationDetails{}, ErrAuthNotApplicable - } - - _, err := infisicalClient.Auth().GcpIdTokenAuthLogin(gcpIdTokenSpec.IdentityID) - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to login with GCP Id Token auth [err=%s]", err) - } - - return AuthenticationDetails{authStrategy: AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY, machineIdentityScope: gcpIdTokenSpec.SecretsScope, isMachineIdentityAuth: true}, nil - -} - -func (r *InfisicalSecretReconciler) handleGcpIamAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { - gcpIamSpec := infisicalSecret.Spec.Authentication.GcpIamAuth - - if gcpIamSpec.IdentityID == "" && gcpIamSpec.ServiceAccountKeyFilePath == "" { - return AuthenticationDetails{}, ErrAuthNotApplicable - } - - _, err := infisicalClient.Auth().GcpIamAuthLogin(gcpIamSpec.IdentityID, gcpIamSpec.ServiceAccountKeyFilePath) - if err != nil { - return AuthenticationDetails{}, fmt.Errorf("unable to login with GCP IAM auth [err=%s]", err) - } - - return AuthenticationDetails{authStrategy: AuthStrategy.GCP_IAM_MACHINE_IDENTITY, machineIdentityScope: gcpIamSpec.SecretsScope, isMachineIdentityAuth: true}, nil -} diff --git a/k8-operator/kubectl-install/install-secrets-operator.yaml b/k8-operator/kubectl-install/install-secrets-operator.yaml index 79a926805..26d924c76 100644 --- a/k8-operator/kubectl-install/install-secrets-operator.yaml +++ b/k8-operator/kubectl-install/install-secrets-operator.yaml @@ -13,6 +13,215 @@ metadata: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: infisicalpushsecrets.secrets.infisical.com +spec: + group: secrets.infisical.com + names: + kind: InfisicalPushSecret + listKind: InfisicalPushSecretList + plural: infisicalpushsecrets + singular: infisicalpushsecret + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: InfisicalPushSecret is the Schema for the infisicalpushsecrets API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: InfisicalPushSecretSpec defines the desired state of InfisicalPushSecret + properties: + authentication: + properties: + awsIamAuth: + properties: + identityId: + type: string + required: + - identityId + type: object + azureAuth: + properties: + identityId: + type: string + resource: + type: string + required: + - identityId + type: object + gcpIamAuth: + properties: + identityId: + type: string + serviceAccountKeyFilePath: + type: string + required: + - identityId + - serviceAccountKeyFilePath + type: object + gcpIdTokenAuth: + properties: + identityId: + type: string + required: + - identityId + type: object + kubernetesAuth: + description: Rest of your types should be defined similarly... + properties: + identityId: + type: string + serviceAccountRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object + required: + - identityId + - serviceAccountRef + type: object + universalAuth: + description: PushSecretUniversalAuth defines universal authentication + properties: + credentialsRef: + properties: + secretName: + description: The name of the Kubernetes Secret + type: string + secretNamespace: + description: The name space where the Kubernetes Secret is located + type: string + required: + - secretName + - secretNamespace + type: object + required: + - credentialsRef + type: object + type: object + deletionPolicy: + type: string + destination: + properties: + envSlug: + type: string + projectId: + type: string + secretsPath: + type: string + required: + - envSlug + - projectId + - secretsPath + type: object + hostAPI: + type: string + push: + properties: + secret: + properties: + secretName: + description: The name of the Kubernetes Secret + type: string + secretNamespace: + description: The name space where the Kubernetes Secret is located + type: string + required: + - secretName + - secretNamespace + type: object + required: + - secret + type: object + resyncInterval: + type: string + updatePolicy: + type: string + required: + - destination + - push + - resyncInterval + type: object + status: + description: InfisicalPushSecretStatus defines the observed state of InfisicalPushSecret + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, \n type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + managedSecrets: + additionalProperties: + type: string + description: managed secrets is a map where the key is the ID, and the value is the secret key (string[id], string[key] ) + type: object + required: + - conditions + - managedSecrets + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.10.0 diff --git a/k8-operator/main.go b/k8-operator/main.go index 4fb64c7f4..4184c8218 100644 --- a/k8-operator/main.go +++ b/k8-operator/main.go @@ -16,7 +16,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" secretsv1alpha1 "github.com/Infisical/infisical/k8-operator/api/v1alpha1" - "github.com/Infisical/infisical/k8-operator/controllers" + infisicalPushSecretController "github.com/Infisical/infisical/k8-operator/controllers/infisicalpushsecret" + infisicalSecretController "github.com/Infisical/infisical/k8-operator/controllers/infisicalsecret" //+kubebuilder:scaffold:imports ) @@ -81,13 +82,24 @@ func main() { os.Exit(1) } - if err = (&controllers.InfisicalSecretReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + if err = (&infisicalSecretController.InfisicalSecretReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + BaseLogger: ctrl.Log, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "InfisicalSecret") os.Exit(1) } + + if err = (&infisicalPushSecretController.InfisicalPushSecretReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + BaseLogger: ctrl.Log, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "InfisicalPushSecret") + os.Exit(1) + } + //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/k8-operator/packages/api/api.go b/k8-operator/packages/api/api.go index dd2af3353..de12b4bc5 100644 --- a/k8-operator/packages/api/api.go +++ b/k8-operator/packages/api/api.go @@ -24,10 +24,6 @@ func CallGetServiceTokenDetailsV2(httpClient *resty.Client) (GetServiceTokenDeta return GetServiceTokenDetailsResponse{}, fmt.Errorf("CallGetServiceTokenDetails: Unsuccessful response: [response=%s]", response) } - // logging for better debugging and user experience - fmt.Printf("Workspace ID: %v\n", tokenDetailsResponse.Workspace) - fmt.Printf("TokenName: %v\n", tokenDetailsResponse.Name) - return tokenDetailsResponse, nil } diff --git a/k8-operator/packages/constants/constants.go b/k8-operator/packages/constants/constants.go new file mode 100644 index 000000000..909d806e5 --- /dev/null +++ b/k8-operator/packages/constants/constants.go @@ -0,0 +1,24 @@ +package constants + +const SERVICE_ACCOUNT_ACCESS_KEY = "serviceAccountAccessKey" +const SERVICE_ACCOUNT_PUBLIC_KEY = "serviceAccountPublicKey" +const SERVICE_ACCOUNT_PRIVATE_KEY = "serviceAccountPrivateKey" + +const INFISICAL_MACHINE_IDENTITY_CLIENT_ID = "clientId" +const INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET = "clientSecret" + +const INFISICAL_TOKEN_SECRET_KEY_NAME = "infisicalToken" +const SECRET_VERSION_ANNOTATION = "secrets.infisical.com/version" // used to set the version of secrets via Etag +const OPERATOR_SETTINGS_CONFIGMAP_NAME = "infisical-config" +const OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE = "infisical-operator-system" +const INFISICAL_DOMAIN = "https://app.infisical.com/api" + +const INFISICAL_PUSH_SECRET_FINALIZER_NAME = "pushsecret.secrets.infisical.com/finalizer" + +type PushSecretReplacePolicy string +type PushSecretDeletionPolicy string + +const ( + PUSH_SECRET_REPLACE_POLICY_ENABLED PushSecretReplacePolicy = "Replace" + PUSH_SECRET_DELETE_POLICY_ENABLED PushSecretDeletionPolicy = "Delete" +) diff --git a/k8-operator/packages/controllerutil/util.go b/k8-operator/packages/controllerutil/util.go new file mode 100644 index 000000000..8c610e2e5 --- /dev/null +++ b/k8-operator/packages/controllerutil/util.go @@ -0,0 +1,45 @@ +package controllerhelpers + +import ( + "context" + "fmt" + + "github.com/Infisical/infisical/k8-operator/packages/constants" + corev1 "k8s.io/api/core/v1" + k8Errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func GetInfisicalConfigMap(ctx context.Context, client client.Client) (configMap map[string]string, errToReturn error) { + // default key values + defaultConfigMapData := make(map[string]string) + defaultConfigMapData["hostAPI"] = constants.INFISICAL_DOMAIN + + kubeConfigMap := &corev1.ConfigMap{} + err := client.Get(ctx, types.NamespacedName{ + Namespace: constants.OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE, + Name: constants.OPERATOR_SETTINGS_CONFIGMAP_NAME, + }, kubeConfigMap) + + if err != nil { + if k8Errors.IsNotFound(err) { + kubeConfigMap = nil + } else { + return nil, fmt.Errorf("GetConfigMapByNamespacedName: unable to fetch config map in [namespacedName=%s] [err=%s]", constants.OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE, err) + } + } + + if kubeConfigMap == nil { + return defaultConfigMapData, nil + } else { + for key, value := range defaultConfigMapData { + _, exists := kubeConfigMap.Data[key] + if !exists { + kubeConfigMap.Data[key] = value + } + } + + return kubeConfigMap.Data, nil + } +} diff --git a/k8-operator/packages/util/auth.go b/k8-operator/packages/util/auth.go index d3ee0ce3b..d01174277 100644 --- a/k8-operator/packages/util/auth.go +++ b/k8-operator/packages/util/auth.go @@ -4,7 +4,12 @@ import ( "context" "fmt" + "errors" + corev1 "k8s.io/api/core/v1" + + "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + infisicalSdk "github.com/infisical/go-sdk" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -32,3 +37,324 @@ func GetServiceAccountToken(k8sClient client.Client, namespace string, serviceAc return string(token), nil } + +type AuthStrategyType string + +var AuthStrategy = struct { + SERVICE_TOKEN AuthStrategyType + SERVICE_ACCOUNT AuthStrategyType + UNIVERSAL_MACHINE_IDENTITY AuthStrategyType + KUBERNETES_MACHINE_IDENTITY AuthStrategyType + AWS_IAM_MACHINE_IDENTITY AuthStrategyType + AZURE_MACHINE_IDENTITY AuthStrategyType + GCP_ID_TOKEN_MACHINE_IDENTITY AuthStrategyType + GCP_IAM_MACHINE_IDENTITY AuthStrategyType +}{ + SERVICE_TOKEN: "SERVICE_TOKEN", + SERVICE_ACCOUNT: "SERVICE_ACCOUNT", + UNIVERSAL_MACHINE_IDENTITY: "UNIVERSAL_MACHINE_IDENTITY", + KUBERNETES_MACHINE_IDENTITY: "KUBERNETES_AUTH_MACHINE_IDENTITY", + AWS_IAM_MACHINE_IDENTITY: "AWS_IAM_MACHINE_IDENTITY", + AZURE_MACHINE_IDENTITY: "AZURE_MACHINE_IDENTITY", + GCP_ID_TOKEN_MACHINE_IDENTITY: "GCP_ID_TOKEN_MACHINE_IDENTITY", + GCP_IAM_MACHINE_IDENTITY: "GCP_IAM_MACHINE_IDENTITY", +} + +type SecretCrdType string + +var SecretCrd = struct { + INFISICAL_SECRET SecretCrdType + INFISICAL_PUSH_SECRET SecretCrdType +}{ + INFISICAL_SECRET: "INFISICAL_SECRET", + INFISICAL_PUSH_SECRET: "INFISICAL_PUSH_SECRET", +} + +type SecretAuthInput struct { + Secret interface{} + Type SecretCrdType +} + +type AuthenticationDetails struct { + AuthStrategy AuthStrategyType + MachineIdentityScope v1alpha1.MachineIdentityScopeInWorkspace // This will only be set if a machine identity auth method is used (e.g. UniversalAuth or KubernetesAuth, etc.) + IsMachineIdentityAuth bool + SecretType SecretCrdType +} + +var ErrAuthNotApplicable = errors.New("authentication not applicable") + +func HandleUniversalAuth(ctx context.Context, reconcilerClient client.Client, secretCrd SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + + var universalAuthSpec v1alpha1.UniversalAuthDetails + + switch secretCrd.Type { + case SecretCrd.INFISICAL_SECRET: + infisicalSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalSecret") + } + universalAuthSpec = infisicalSecret.Spec.Authentication.UniversalAuth + case SecretCrd.INFISICAL_PUSH_SECRET: + infisicalPushSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalPushSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalPushSecret") + } + + universalAuthSpec = v1alpha1.UniversalAuthDetails{ + CredentialsRef: infisicalPushSecret.Spec.Authentication.UniversalAuth.CredentialsRef, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + } + } + + universalAuthKubeSecret, err := GetInfisicalUniversalAuthFromKubeSecret(ctx, reconcilerClient, v1alpha1.KubeSecretReference{ + SecretNamespace: universalAuthSpec.CredentialsRef.SecretNamespace, + SecretName: universalAuthSpec.CredentialsRef.SecretName, + }) + + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get machine identity creds from kube secret [err=%s]", err) + } + + if universalAuthKubeSecret.ClientId == "" && universalAuthKubeSecret.ClientSecret == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err = infisicalClient.Auth().UniversalAuthLogin(universalAuthKubeSecret.ClientId, universalAuthKubeSecret.ClientSecret) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with machine identity credentials [err=%s]", err) + } + + return AuthenticationDetails{ + AuthStrategy: AuthStrategy.UNIVERSAL_MACHINE_IDENTITY, + MachineIdentityScope: universalAuthSpec.SecretsScope, + IsMachineIdentityAuth: true, + SecretType: secretCrd.Type, + }, nil +} + +func HandleKubernetesAuth(ctx context.Context, reconcilerClient client.Client, secretCrd SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + var kubernetesAuthSpec v1alpha1.KubernetesAuthDetails + + switch secretCrd.Type { + case SecretCrd.INFISICAL_SECRET: + infisicalSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalSecret") + } + kubernetesAuthSpec = infisicalSecret.Spec.Authentication.KubernetesAuth + case SecretCrd.INFISICAL_PUSH_SECRET: + infisicalPushSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalPushSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalPushSecret") + } + kubernetesAuthSpec = v1alpha1.KubernetesAuthDetails{ + IdentityID: infisicalPushSecret.Spec.Authentication.KubernetesAuth.IdentityID, + ServiceAccountRef: v1alpha1.KubernetesServiceAccountRef{ + Namespace: infisicalPushSecret.Spec.Authentication.KubernetesAuth.ServiceAccountRef.Namespace, + Name: infisicalPushSecret.Spec.Authentication.KubernetesAuth.ServiceAccountRef.Name, + }, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + } + } + + if kubernetesAuthSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + serviceAccountToken, err := GetServiceAccountToken(reconcilerClient, kubernetesAuthSpec.ServiceAccountRef.Namespace, kubernetesAuthSpec.ServiceAccountRef.Name) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to get service account token [err=%s]", err) + } + + _, err = infisicalClient.Auth().KubernetesRawServiceAccountTokenLogin(kubernetesAuthSpec.IdentityID, serviceAccountToken) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with Kubernetes native auth [err=%s]", err) + } + + return AuthenticationDetails{ + AuthStrategy: AuthStrategy.KUBERNETES_MACHINE_IDENTITY, + MachineIdentityScope: kubernetesAuthSpec.SecretsScope, + IsMachineIdentityAuth: true, + SecretType: secretCrd.Type, + }, nil + +} + +func HandleAwsIamAuth(ctx context.Context, reconcilerClient client.Client, secretCrd SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + awsIamAuthSpec := v1alpha1.AWSIamAuthDetails{} + + switch secretCrd.Type { + case SecretCrd.INFISICAL_SECRET: + infisicalSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalSecret") + } + + awsIamAuthSpec = infisicalSecret.Spec.Authentication.AwsIamAuth + case SecretCrd.INFISICAL_PUSH_SECRET: + infisicalPushSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalPushSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalPushSecret") + } + + awsIamAuthSpec = v1alpha1.AWSIamAuthDetails{ + IdentityID: infisicalPushSecret.Spec.Authentication.AwsIamAuth.IdentityID, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + } + } + + if awsIamAuthSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().AwsIamAuthLogin(awsIamAuthSpec.IdentityID) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with AWS IAM auth [err=%s]", err) + } + + return AuthenticationDetails{ + AuthStrategy: AuthStrategy.AWS_IAM_MACHINE_IDENTITY, + MachineIdentityScope: awsIamAuthSpec.SecretsScope, + IsMachineIdentityAuth: true, + SecretType: secretCrd.Type, + }, nil + +} + +func HandleAzureAuth(ctx context.Context, reconcilerClient client.Client, secretCrd SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + azureAuthSpec := v1alpha1.AzureAuthDetails{} + + switch secretCrd.Type { + case SecretCrd.INFISICAL_SECRET: + infisicalSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalSecret") + } + + azureAuthSpec = infisicalSecret.Spec.Authentication.AzureAuth + + case SecretCrd.INFISICAL_PUSH_SECRET: + infisicalPushSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalPushSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalPushSecret") + } + + azureAuthSpec = v1alpha1.AzureAuthDetails{ + IdentityID: infisicalPushSecret.Spec.Authentication.AzureAuth.IdentityID, + Resource: infisicalPushSecret.Spec.Authentication.AzureAuth.Resource, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + } + } + + if azureAuthSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().AzureAuthLogin(azureAuthSpec.IdentityID, azureAuthSpec.Resource) // If resource is empty(""), it will default to "https://management.azure.com/" in the SDK. + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with Azure auth [err=%s]", err) + } + + return AuthenticationDetails{ + AuthStrategy: AuthStrategy.AZURE_MACHINE_IDENTITY, + MachineIdentityScope: azureAuthSpec.SecretsScope, + IsMachineIdentityAuth: true, + SecretType: secretCrd.Type, + }, nil + +} + +func HandleGcpIdTokenAuth(ctx context.Context, reconcilerClient client.Client, secretCrd SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + gcpIdTokenSpec := v1alpha1.GCPIdTokenAuthDetails{} + + switch secretCrd.Type { + case SecretCrd.INFISICAL_SECRET: + infisicalSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalSecret") + } + + gcpIdTokenSpec = infisicalSecret.Spec.Authentication.GcpIdTokenAuth + case SecretCrd.INFISICAL_PUSH_SECRET: + infisicalPushSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalPushSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalPushSecret") + } + + gcpIdTokenSpec = v1alpha1.GCPIdTokenAuthDetails{ + IdentityID: infisicalPushSecret.Spec.Authentication.GcpIdTokenAuth.IdentityID, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + } + } + + if gcpIdTokenSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().GcpIdTokenAuthLogin(gcpIdTokenSpec.IdentityID) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with GCP Id Token auth [err=%s]", err) + } + + return AuthenticationDetails{ + AuthStrategy: AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY, + MachineIdentityScope: gcpIdTokenSpec.SecretsScope, + IsMachineIdentityAuth: true, + SecretType: secretCrd.Type, + }, nil + +} + +func HandleGcpIamAuth(ctx context.Context, reconcilerClient client.Client, secretCrd SecretAuthInput, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + gcpIamSpec := v1alpha1.GcpIamAuthDetails{} + + switch secretCrd.Type { + case SecretCrd.INFISICAL_SECRET: + infisicalSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalSecret") + } + + gcpIamSpec = infisicalSecret.Spec.Authentication.GcpIamAuth + case SecretCrd.INFISICAL_PUSH_SECRET: + infisicalPushSecret, ok := secretCrd.Secret.(v1alpha1.InfisicalPushSecret) + + if !ok { + return AuthenticationDetails{}, errors.New("unable to cast secret to InfisicalPushSecret") + } + + gcpIamSpec = v1alpha1.GcpIamAuthDetails{ + IdentityID: infisicalPushSecret.Spec.Authentication.GcpIamAuth.IdentityID, + ServiceAccountKeyFilePath: infisicalPushSecret.Spec.Authentication.GcpIamAuth.ServiceAccountKeyFilePath, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + } + } + + if gcpIamSpec.IdentityID == "" && gcpIamSpec.ServiceAccountKeyFilePath == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().GcpIamAuthLogin(gcpIamSpec.IdentityID, gcpIamSpec.ServiceAccountKeyFilePath) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with GCP IAM auth [err=%s]", err) + } + + return AuthenticationDetails{ + AuthStrategy: AuthStrategy.GCP_IAM_MACHINE_IDENTITY, + MachineIdentityScope: gcpIamSpec.SecretsScope, + IsMachineIdentityAuth: true, + SecretType: secretCrd.Type, + }, nil +} diff --git a/k8-operator/packages/util/kubernetes.go b/k8-operator/packages/util/kubernetes.go new file mode 100644 index 000000000..6397f4036 --- /dev/null +++ b/k8-operator/packages/util/kubernetes.go @@ -0,0 +1,50 @@ +package util + +import ( + "context" + "fmt" + + "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/model" + corev1 "k8s.io/api/core/v1" + k8Errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const INFISICAL_MACHINE_IDENTITY_CLIENT_ID = "clientId" +const INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET = "clientSecret" + +func GetKubeSecretByNamespacedName(ctx context.Context, reconcilerClient client.Client, namespacedName types.NamespacedName) (*corev1.Secret, error) { + kubeSecret := &corev1.Secret{} + err := reconcilerClient.Get(ctx, namespacedName, kubeSecret) + if err != nil { + kubeSecret = nil + } + + return kubeSecret, err +} + +func GetInfisicalUniversalAuthFromKubeSecret(ctx context.Context, reconcilerClient client.Client, universalAuthRef v1alpha1.KubeSecretReference) (machineIdentityDetails model.MachineIdentityDetails, err error) { + + universalAuthCredsFromKubeSecret, err := GetKubeSecretByNamespacedName(ctx, reconcilerClient, types.NamespacedName{ + Namespace: universalAuthRef.SecretNamespace, + Name: universalAuthRef.SecretName, + // Namespace: infisicalSecret.Spec.Authentication.UniversalAuth.CredentialsRef.SecretNamespace, + // Name: infisicalSecret.Spec.Authentication.UniversalAuth.CredentialsRef.SecretName, + }) + + if k8Errors.IsNotFound(err) { + return model.MachineIdentityDetails{}, nil + } + + if err != nil { + return model.MachineIdentityDetails{}, fmt.Errorf("something went wrong when fetching your machine identity credentials [err=%s]", err) + } + + clientIdFromSecret := universalAuthCredsFromKubeSecret.Data[INFISICAL_MACHINE_IDENTITY_CLIENT_ID] + clientSecretFromSecret := universalAuthCredsFromKubeSecret.Data[INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET] + + return model.MachineIdentityDetails{ClientId: string(clientIdFromSecret), ClientSecret: string(clientSecretFromSecret)}, nil + +} diff --git a/k8-operator/packages/util/models.go b/k8-operator/packages/util/models.go new file mode 100644 index 000000000..8030731c2 --- /dev/null +++ b/k8-operator/packages/util/models.go @@ -0,0 +1,13 @@ +package util + +import ( + "context" + + infisicalSdk "github.com/infisical/go-sdk" +) + +type ResourceVariables struct { + InfisicalClient infisicalSdk.InfisicalClientInterface + CancelCtx context.CancelFunc + AuthDetails AuthenticationDetails +} diff --git a/k8-operator/packages/util/time.go b/k8-operator/packages/util/time.go new file mode 100644 index 000000000..0b78a16a6 --- /dev/null +++ b/k8-operator/packages/util/time.go @@ -0,0 +1,40 @@ +package util + +import ( + "fmt" + "strconv" + "time" +) + +func ConvertResyncIntervalToDuration(resyncInterval string) (time.Duration, error) { + length := len(resyncInterval) + if length < 2 { + return 0, fmt.Errorf("invalid format") + } + + unit := resyncInterval[length-1:] + numberPart := resyncInterval[:length-1] + + number, err := strconv.Atoi(numberPart) + if err != nil { + return 0, err + } + + switch unit { + case "s": + if number < 5 { + return 0, fmt.Errorf("resync interval must be at least 5 seconds") + } + return time.Duration(number) * time.Second, nil + case "m": + return time.Duration(number) * time.Minute, nil + case "h": + return time.Duration(number) * time.Hour, nil + case "d": + return time.Duration(number) * 24 * time.Hour, nil + case "w": + return time.Duration(number) * 7 * 24 * time.Hour, nil + default: + return 0, fmt.Errorf("invalid time unit") + } +}