diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 02159fa9cb..f3298625e6 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -80,6 +80,7 @@ import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret- import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service"; import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; +import { TSecretSyncServiceFactory } from "@app/services/secret-sync/secret-sync-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service"; import { TSlackServiceFactory } from "@app/services/slack/slack-service"; @@ -210,6 +211,7 @@ declare module "fastify" { projectTemplate: TProjectTemplateServiceFactory; totp: TTotpServiceFactory; appConnection: TAppConnectionServiceFactory; + secretSync: TSecretSyncServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index aaa2014b8d..2dad77392b 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -372,6 +372,7 @@ import { TExternalGroupOrgRoleMappingsInsert, TExternalGroupOrgRoleMappingsUpdate } from "@app/db/schemas/external-group-org-role-mappings"; +import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs"; import { TSecretV2TagJunction, TSecretV2TagJunctionInsert, @@ -900,5 +901,6 @@ declare module "knex/types/tables" { TAppConnectionsInsert, TAppConnectionsUpdate >; + [TableName.SecretSync]: KnexOriginal.CompositeTableType<TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate>; } } diff --git a/backend/src/db/migrations/20250122055102_secret-sync.ts b/backend/src/db/migrations/20250122055102_secret-sync.ts new file mode 100644 index 0000000000..5f37950e34 --- /dev/null +++ b/backend/src/db/migrations/20250122055102_secret-sync.ts @@ -0,0 +1,50 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise<void> { + if (!(await knex.schema.hasTable(TableName.SecretSync))) { + await knex.schema.createTable(TableName.SecretSync, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("destination").notNullable(); + t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true); + t.integer("version").defaultTo(1).notNullable(); + t.jsonb("destinationConfig").notNullable(); + t.jsonb("syncOptions").notNullable(); + // we're including projectId in addition to folder ID because we allow folderId to be null (if the folder + // is deleted), to preserve sync configuration + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("folderId"); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("SET NULL"); + t.uuid("connectionId").notNullable(); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.timestamps(true, true, true); + // sync secrets to destination + t.string("syncStatus"); + t.string("lastSyncJobId"); + t.string("lastSyncMessage"); + t.datetime("lastSyncedAt"); + // import secrets from destination + t.string("importStatus"); + t.string("lastImportJobId"); + t.string("lastImportMessage"); + t.datetime("lastImportedAt"); + // remove secrets from destination + t.string("removeStatus"); + t.string("lastRemoveJobId"); + t.string("lastRemoveMessage"); + t.datetime("lastRemovedAt"); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSync); + } +} + +export async function down(knex: Knex): Promise<void> { + await knex.schema.dropTableIfExists(TableName.SecretSync); + await dropOnUpdateTrigger(knex, TableName.SecretSync); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index c991b913d2..3ead855305 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -131,7 +131,8 @@ export enum TableName { WorkflowIntegrations = "workflow_integrations", SlackIntegrations = "slack_integrations", ProjectSlackConfigs = "project_slack_configs", - AppConnection = "app_connections" + AppConnection = "app_connections", + SecretSync = "secret_syncs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/secret-syncs.ts b/backend/src/db/schemas/secret-syncs.ts new file mode 100644 index 0000000000..0e0728e874 --- /dev/null +++ b/backend/src/db/schemas/secret-syncs.ts @@ -0,0 +1,40 @@ +// 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 SecretSyncsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.string(), + isAutoSyncEnabled: z.boolean().default(true), + version: z.number().default(1), + destinationConfig: z.unknown(), + syncOptions: z.unknown(), + projectId: z.string(), + folderId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + importStatus: z.string().nullable().optional(), + lastImportJobId: z.string().nullable().optional(), + lastImportMessage: z.string().nullable().optional(), + lastImportedAt: z.date().nullable().optional(), + removeStatus: z.string().nullable().optional(), + lastRemoveJobId: z.string().nullable().optional(), + lastRemoveMessage: z.string().nullable().optional(), + lastRemovedAt: z.date().nullable().optional() +}); + +export type TSecretSyncs = z.infer<typeof SecretSyncsSchema>; +export type TSecretSyncsInsert = Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>; +export type TSecretSyncsUpdate = Partial<Omit<z.input<typeof SecretSyncsSchema>, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 3a0ad47dac..c8ee03a99b 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -24,6 +24,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { ), name: z.string().trim(), description: z.string().trim().nullish(), + // TODO(scott): once UI refactored permissions: OrgPermissionSchema.array() permissions: z.any().array() }), response: { @@ -96,6 +97,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .optional(), name: z.string().trim().optional(), description: z.string().trim().nullish(), + // TODO(scott): once UI refactored permissions: OrgPermissionSchema.array().optional() permissions: z.any().array().optional() }), response: { diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index e51c08fbe9..ff7dede5fc 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -81,7 +81,8 @@ export const auditLogServiceFactory = ({ } // add all cases in which project id or org id cannot be added if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) { - if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" }); + if (!data.projectId && !data.orgId) + throw new BadRequestError({ message: "Must specify either project id or org id" }); } return auditLogQueue.pushToLog(data); 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 2a5657cf0c..9c19cd3ccb 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -13,6 +13,13 @@ 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"; +import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; +import { + TCreateSecretSyncDTO, + TDeleteSecretSyncDTO, + TSecretSyncRaw, + TUpdateSecretSyncDTO +} from "@app/services/secret-sync/secret-sync-types"; export type TListProjectAuditLogDTO = { filter: { @@ -226,13 +233,22 @@ export enum EventType { DELETE_PROJECT_TEMPLATE = "delete-project-template", APPLY_PROJECT_TEMPLATE = "apply-project-template", GET_APP_CONNECTIONS = "get-app-connections", + GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details", GET_APP_CONNECTION = "get-app-connection", CREATE_APP_CONNECTION = "create-app-connection", UPDATE_APP_CONNECTION = "update-app-connection", DELETE_APP_CONNECTION = "delete-app-connection", CREATE_SHARED_SECRET = "create-shared-secret", DELETE_SHARED_SECRET = "delete-shared-secret", - READ_SHARED_SECRET = "read-shared-secret" + READ_SHARED_SECRET = "read-shared-secret", + GET_SECRET_SYNCS = "get-secret-syncs", + GET_SECRET_SYNC = "get-secret-sync", + CREATE_SECRET_SYNC = "create-secret-sync", + UPDATE_SECRET_SYNC = "update-secret-sync", + DELETE_SECRET_SYNC = "delete-secret-sync", + SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets", + SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets", + SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets" } interface UserActorMetadata { @@ -1893,6 +1909,15 @@ interface GetAppConnectionsEvent { }; } +interface GetAvailableAppConnectionsDetailsEvent { + type: EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS; + metadata: { + app?: AppConnection; + count: number; + connectionIds: string[]; + }; +} + interface GetAppConnectionEvent { type: EventType.GET_APP_CONNECTION; metadata: { @@ -1946,6 +1971,78 @@ interface ReadSharedSecretEvent { }; } +interface GetSecretSyncsEvent { + type: EventType.GET_SECRET_SYNCS; + metadata: { + destination?: SecretSync; + count: number; + syncIds: string[]; + }; +} + +interface GetSecretSyncEvent { + type: EventType.GET_SECRET_SYNC; + metadata: { + destination: SecretSync; + syncId: string; + }; +} + +interface CreateSecretSyncEvent { + type: EventType.CREATE_SECRET_SYNC; + metadata: Omit<TCreateSecretSyncDTO, "projectId"> & { syncId: string }; +} + +interface UpdateSecretSyncEvent { + type: EventType.UPDATE_SECRET_SYNC; + metadata: TUpdateSecretSyncDTO; +} + +interface DeleteSecretSyncEvent { + type: EventType.DELETE_SECRET_SYNC; + metadata: TDeleteSecretSyncDTO; +} + +interface SecretSyncSyncSecretsEvent { + type: EventType.SECRET_SYNC_SYNC_SECRETS; + metadata: Pick< + TSecretSyncRaw, + "syncOptions" | "destinationConfig" | "destination" | "syncStatus" | "connectionId" | "folderId" + > & { + syncId: string; + syncMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface SecretSyncImportSecretsEvent { + type: EventType.SECRET_SYNC_IMPORT_SECRETS; + metadata: Pick< + TSecretSyncRaw, + "syncOptions" | "destinationConfig" | "destination" | "importStatus" | "connectionId" | "folderId" + > & { + syncId: string; + importMessage: string | null; + jobId: string; + jobRanAt: Date; + importBehavior: SecretSyncImportBehavior; + }; +} + +interface SecretSyncRemoveSecretsEvent { + type: EventType.SECRET_SYNC_REMOVE_SECRETS; + metadata: Pick< + TSecretSyncRaw, + "syncOptions" | "destinationConfig" | "destination" | "removeStatus" | "connectionId" | "folderId" + > & { + syncId: string; + removeMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -2119,10 +2216,19 @@ export type Event = | DeleteProjectTemplateEvent | ApplyProjectTemplateEvent | GetAppConnectionsEvent + | GetAvailableAppConnectionsDetailsEvent | GetAppConnectionEvent | CreateAppConnectionEvent | UpdateAppConnectionEvent | DeleteAppConnectionEvent | CreateSharedSecretEvent | DeleteSharedSecretEvent - | ReadSharedSecretEvent; + | ReadSharedSecretEvent + | GetSecretSyncsEvent + | GetSecretSyncEvent + | CreateSecretSyncEvent + | UpdateSecretSyncEvent + | DeleteSecretSyncEvent + | SecretSyncSyncSecretsEvent + | SecretSyncImportSecretsEvent + | SecretSyncRemoveSecretsEvent; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 014fcccfd4..c54daa3a64 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -50,8 +50,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ }, pkiEst: false, enforceMfa: false, - projectTemplates: false, - appConnections: false + projectTemplates: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 678e15fd46..0242377b04 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -68,7 +68,6 @@ export type TFeatureSet = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: false; - appConnections: false; // TODO: remove once live }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 487d2155c8..c72008057f 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -1,4 +1,12 @@ -import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"; +import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; +import { z } from "zod"; + +import { + CASL_ACTION_SCHEMA_ENUM, + CASL_ACTION_SCHEMA_NATIVE_ENUM +} from "@app/ee/services/permission/permission-schemas"; +import { PermissionConditionSchema } from "@app/ee/services/permission/permission-types"; +import { PermissionConditionOperators } from "@app/lib/casl"; export enum OrgPermissionActions { Read = "read", @@ -7,6 +15,14 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionAppConnectionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + Connect = "connect" +} + export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -31,6 +47,10 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections" } +export type AppConnectionSubjectFields = { + connectionId: string; +}; + export type OrgPermissionSet = | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] | [OrgPermissionActions, OrgPermissionSubjects.Role] @@ -47,9 +67,109 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] - | [OrgPermissionActions, OrgPermissionSubjects.AppConnections] + | [ + OrgPermissionAppConnectionActions, + ( + | OrgPermissionSubjects.AppConnections + | (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields) + ) + ] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; +const AppConnectionConditionSchema = z + .object({ + connectionId: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + +export const OrgPermissionSchema = z.discriminatedUnion("subject", [ + z.object({ + subject: z.literal(OrgPermissionSubjects.Workspace).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_ENUM([OrgPermissionActions.Create]).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Member).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Settings).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.IncidentAccount).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Sso).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Scim).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Groups).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.SecretScanning).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Billing).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Identity).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.Kms).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.AuditLogs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.ProjectTemplates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.AppConnections).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAppConnectionActions).describe( + "Describe what action an entity can take." + ), + conditions: AppConnectionConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(OrgPermissionSubjects.AdminConsole).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAdminConsoleAction).describe( + "Describe what action an entity can take." + ) + }) +]); + const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility); // ws permissions @@ -125,10 +245,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); - can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAppConnectionActions.Read, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAppConnectionActions.Create, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAppConnectionActions.Edit, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAppConnectionActions.Delete, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); @@ -160,7 +281,7 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); return rules; }; diff --git a/backend/src/ee/services/permission/permission-schemas.ts b/backend/src/ee/services/permission/permission-schemas.ts new file mode 100644 index 0000000000..fb462e4aac --- /dev/null +++ b/backend/src/ee/services/permission/permission-schemas.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const CASL_ACTION_SCHEMA_NATIVE_ENUM = <ACTION extends z.EnumLike>(actions: ACTION) => + z + .union([z.nativeEnum(actions), z.nativeEnum(actions).array().min(1)]) + .transform((el) => (typeof el === "string" ? [el] : el)); + +export const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) => + z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el)); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 47142054e6..3dc7daddca 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -1,6 +1,10 @@ import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; import { z } from "zod"; +import { + CASL_ACTION_SCHEMA_ENUM, + CASL_ACTION_SCHEMA_NATIVE_ENUM +} from "@app/ee/services/permission/permission-schemas"; import { conditionsMatcher, PermissionConditionOperators } from "@app/lib/casl"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; @@ -30,6 +34,16 @@ export enum ProjectPermissionDynamicSecretActions { Lease = "lease" } +export enum ProjectPermissionSecretSyncActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SyncSecrets = "sync-secrets", + ImportSecrets = "import-secrets", + RemoveSecrets = "remove-secrets" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -60,7 +74,8 @@ export enum ProjectPermissionSub { PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", - Cmek = "cmek" + Cmek = "cmek", + SecretSyncs = "secret-syncs" } export type SecretSubjectFields = { @@ -140,6 +155,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] + | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] @@ -147,14 +163,6 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms]; -const CASL_ACTION_SCHEMA_NATIVE_ENUM = <ACTION extends z.EnumLike>(actions: ACTION) => - z - .union([z.nativeEnum(actions), z.nativeEnum(actions).array().min(1)]) - .transform((el) => (typeof el === "string" ? [el] : el)); - -const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) => - z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el)); - // akhilmhdh: don't modify this for v2 // if you want to update create a new schema const SecretConditionV1Schema = z @@ -392,10 +400,15 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.Cmek).describe("The entity this permission pertains to."), - inverted: z.boolean().optional().describe("Whether rule allows or forbids."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCmekActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( + "Describe what action an entity can take." + ) }) ]; @@ -549,6 +562,18 @@ const buildAdminPermissionRules = () => { ], ProjectPermissionSub.Cmek ); + can( + [ + ProjectPermissionSecretSyncActions.Create, + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSecretSyncActions.RemoveSecrets + ], + ProjectPermissionSub.SecretSyncs + ); return rules; }; @@ -713,6 +738,19 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Cmek ); + can( + [ + ProjectPermissionSecretSyncActions.Create, + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSecretSyncActions.RemoveSecrets + ], + ProjectPermissionSub.SecretSyncs + ); + return rules; }; @@ -746,6 +784,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); + can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); return rules; }; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 723a22817a..dbfdfd063c 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -23,6 +23,8 @@ export const KeyStorePrefixes = { `sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const, SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, + SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}` @@ -30,6 +32,7 @@ export const KeyStorePrefixes = { export const KeyStoreTtls = { SetSyncSecretIntegrationLastRunTimestampInSeconds: 60, + SetSecretSyncLastRunTimestampInSeconds: 60, AccessTokenStatusUpdateInSeconds: 120 }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e6fa7344a2..800788179d 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,5 +1,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; export const GROUPS = { CREATE: { @@ -1643,6 +1645,83 @@ export const AppConnections = { }; }, DELETE: (app: AppConnection) => ({ - connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} connection to be deleted.` + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.` }) }; + +export const SecretSyncs = { + LIST: (destination?: SecretSync) => ({ + projectId: `The ID of the project to list ${destination ? SECRET_SYNC_NAME_MAP[destination] : "Secret"} Syncs from.` + }), + GET_BY_ID: (destination: SecretSync) => ({ + syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to retrieve.` + }), + GET_BY_NAME: (destination: SecretSync) => ({ + syncName: `The name of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to retrieve.`, + projectId: `The ID of the project the ${SECRET_SYNC_NAME_MAP[destination]} Sync is associated with.` + }), + CREATE: (destination: SecretSync) => { + const destinationName = SECRET_SYNC_NAME_MAP[destination]; + return { + name: `The name of the ${destinationName} Sync to create. Must be slug-friendly.`, + description: `An optional description for the ${destinationName} Sync.`, + projectId: "The ID of the project to create the sync in.", + environment: `The slug of the project environment to sync secrets from.`, + secretPath: `The folder path to sync secrets from.`, + connectionId: `The ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]] + } Connection to use for syncing.`, + isAutoSyncEnabled: `Whether secrets should be automatically synced when changes occur at the source location or not.`, + syncOptions: "Optional parameters to modify how secrets are synced." + }; + }, + UPDATE: (destination: SecretSync) => { + const destinationName = SECRET_SYNC_NAME_MAP[destination]; + return { + syncId: `The ID of the ${destinationName} Sync to be updated.`, + connectionId: `The updated ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_SYNC_CONNECTION_MAP[destination]] + } Connection to use for syncing.`, + name: `The updated name of the ${destinationName} Sync. Must be slug-friendly.`, + environment: `The updated slug of the project environment to sync secrets from.`, + secretPath: `The updated folder path to sync secrets from.`, + description: `The updated description of the ${destinationName} Sync.`, + isAutoSyncEnabled: `Whether secrets should be automatically synced when changes occur at the source location or not.`, + syncOptions: "Optional parameters to modify how secrets are synced." + }; + }, + DELETE: (destination: SecretSync) => ({ + syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to be deleted.`, + removeSecrets: `Whether previously synced secrets should be removed prior to deletion.` + }), + SYNC_SECRETS: (destination: SecretSync) => ({ + syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger a sync for.` + }), + IMPORT_SECRETS: (destination: SecretSync) => ({ + syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger importing secrets for.`, + importBehavior: `Specify whether Infisical should prioritize secret values from Infisical or ${SECRET_SYNC_NAME_MAP[destination]}.` + }), + REMOVE_SECRETS: (destination: SecretSync) => ({ + syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to trigger removing secrets for.` + }), + SYNC_OPTIONS: (destination: SecretSync) => { + const destinationName = SECRET_SYNC_NAME_MAP[destination]; + return { + INITIAL_SYNC_BEHAVIOR: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.`, + PREPEND_PREFIX: `Optionally prepend a prefix to your secrets' keys when syncing to ${destinationName}.`, + APPEND_SUFFIX: `Optionally append a suffix to your secrets' keys when syncing to ${destinationName}.` + }; + }, + DESTINATION_CONFIG: { + AWS_PARAMETER_STORE: { + REGION: "The AWS region to sync secrets to.", + PATH: "The Parameter Store path to sync secrets to." + }, + GITHUB: { + ORG: "The name of the GitHub organization.", + OWNER: "The name of the GitHub account owner of the repository.", + REPO: "The name of the GitHub repository.", + ENV: "The name of the GitHub environment." + } + } +}; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 330193052c..f9aec5881d 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -15,6 +15,12 @@ import { TIntegrationSyncPayload, TSyncSecretsDTO } from "@app/services/secret/secret-types"; +import { + TQueueSecretSyncImportSecretsByIdDTO, + TQueueSecretSyncRemoveSecretsByIdDTO, + TQueueSecretSyncSyncSecretsByIdDTO, + TQueueSendSecretSyncActionFailedNotificationsDTO +} from "@app/services/secret-sync/secret-sync-types"; export enum QueueName { SecretRotation = "secret-rotation", @@ -36,7 +42,8 @@ export enum QueueName { SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", - ImportSecretsFromExternalSource = "import-secrets-from-external-source" + ImportSecretsFromExternalSource = "import-secrets-from-external-source", + AppConnectionSecretSync = "app-connection-secret-sync" } export enum QueueJobs { @@ -61,7 +68,11 @@ export enum QueueJobs { ProjectV3Migration = "project-v3-migration", IdentityAccessTokenStatusUpdate = "identity-access-token-status-update", ServiceTokenStatusUpdate = "service-token-status-update", - ImportSecretsFromExternalSource = "import-secrets-from-external-source" + ImportSecretsFromExternalSource = "import-secrets-from-external-source", + SecretSyncSyncSecrets = "secret-sync-sync-secrets", + SecretSyncImportSecrets = "secret-sync-import-secrets", + SecretSyncRemoveSecrets = "secret-sync-remove-secrets", + SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications" } export type TQueueJobTypes = { @@ -184,6 +195,23 @@ export type TQueueJobTypes = { }; }; }; + [QueueName.AppConnectionSecretSync]: + | { + name: QueueJobs.SecretSyncSyncSecrets; + payload: TQueueSecretSyncSyncSecretsByIdDTO; + } + | { + name: QueueJobs.SecretSyncImportSecrets; + payload: TQueueSecretSyncImportSecretsByIdDTO; + } + | { + name: QueueJobs.SecretSyncRemoveSecrets; + payload: TQueueSecretSyncRemoveSecretsByIdDTO; + } + | { + name: QueueJobs.SecretSyncSendActionFailedNotifications; + payload: TQueueSendSecretSyncActionFailedNotificationsDTO; + }; }; export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3e5575daa5..442f110808 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -196,6 +196,9 @@ import { secretImportDALFactory } from "@app/services/secret-import/secret-impor import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; +import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; +import { secretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-queue"; +import { secretSyncServiceFactory } from "@app/services/secret-sync/secret-sync-service"; import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; @@ -318,6 +321,7 @@ export const registerRoutes = async ( const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); const appConnectionDAL = appConnectionDALFactory(db); + const secretSyncDAL = secretSyncDALFactory(db, folderDAL); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -824,6 +828,29 @@ export const registerRoutes = async ( kmsService }); + const secretSyncQueue = secretSyncQueueFactory({ + queueService, + secretSyncDAL, + folderDAL, + secretImportDAL, + secretV2BridgeDAL, + kmsService, + keyStore, + auditLogService, + smtpService, + projectDAL, + projectMembershipDAL, + projectBotDAL, + secretDAL, + secretBlindIndexDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + secretVersionV2BridgeDAL, + secretVersionTagV2BridgeDAL, + resourceMetadataDAL + }); + const secretQueueService = secretQueueFactory({ keyStore, queueService, @@ -858,7 +885,8 @@ export const registerRoutes = async ( projectKeyDAL, projectUserMembershipRoleDAL, orgService, - resourceMetadataDAL + resourceMetadataDAL, + secretSyncQueue }); const projectService = projectServiceFactory({ @@ -1369,8 +1397,17 @@ export const registerRoutes = async ( const appConnectionService = appConnectionServiceFactory({ appConnectionDAL, permissionService, - kmsService, - licenseService + kmsService + }); + + const secretSyncService = secretSyncServiceFactory({ + secretSyncDAL, + permissionService, + appConnectionService, + folderDAL, + secretSyncQueue, + projectBotService, + keyStore }); await superAdminService.initServerCfg(); @@ -1470,7 +1507,8 @@ export const registerRoutes = async ( externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, projectTemplate: projectTemplateService, totp: totpService, - appConnection: appConnectionService + appConnection: appConnectionService, + secretSync: secretSyncService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts similarity index 80% rename from backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts rename to backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index ec3b633a19..41a87feb5a 100644 --- a/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -15,7 +15,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten app, createSchema, updateSchema, - responseSchema + sanitizedResponseSchema }: { app: AppConnection; server: FastifyZodProvider; @@ -26,7 +26,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten description?: string | null; }>; updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>; - responseSchema: z.ZodTypeAny; + sanitizedResponseSchema: z.ZodTypeAny; }) => { const appName = APP_CONNECTION_NAME_MAP[app]; @@ -39,7 +39,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten schema: { description: `List the ${appName} Connections for the current organization.`, response: { - 200: z.object({ appConnections: responseSchema.array() }) + 200: z.object({ appConnections: sanitizedResponseSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -63,6 +63,44 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten } }); + server.route({ + method: "GET", + url: "/available", + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${appName} Connections the current user has permission to establish connections with.`, + response: { + 200: z.object({ + appConnections: z.object({ app: z.literal(app), name: z.string(), id: z.string().uuid() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const appConnections = await server.services.appConnection.listAvailableAppConnectionsForUser( + app, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS, + metadata: { + app, + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + return { appConnections }; + } + }); + server.route({ method: "GET", url: "/:connectionId", @@ -75,7 +113,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten connectionId: z.string().uuid().describe(AppConnections.GET_BY_ID(app).connectionId) }), response: { - 200: z.object({ appConnection: responseSchema }) + 200: z.object({ appConnection: sanitizedResponseSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -105,7 +143,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten server.route({ method: "GET", - url: `/name/:connectionName`, + url: `/connection-name/:connectionName`, config: { rateLimit: readLimit }, @@ -114,11 +152,12 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten params: z.object({ connectionName: z .string() - .min(0, "Connection name required") + .trim() + .min(1, "Connection name required") .describe(AppConnections.GET_BY_NAME(app).connectionName) }), response: { - 200: z.object({ appConnection: responseSchema }) + 200: z.object({ appConnection: sanitizedResponseSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -158,7 +197,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten } ${appName} Connection for the current organization.`, body: createSchema, response: { - 200: z.object({ appConnection: responseSchema }) + 200: z.object({ appConnection: sanitizedResponseSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -168,7 +207,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten const appConnection = (await server.services.appConnection.createAppConnection( { name, method, app, credentials, description }, req.permission - )) as TAppConnection; + )) as T; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -201,7 +240,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten }), body: updateSchema, response: { - 200: z.object({ appConnection: responseSchema }) + 200: z.object({ appConnection: sanitizedResponseSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -244,7 +283,7 @@ export const registerAppConnectionEndpoints = <T extends TAppConnection, I exten connectionId: z.string().uuid().describe(AppConnections.DELETE(app).connectionId) }), response: { - 200: z.object({ appConnection: responseSchema }) + 200: z.object({ appConnection: sanitizedResponseSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts deleted file mode 100644 index 273d4b9e16..0000000000 --- a/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { - CreateGitHubConnectionSchema, - SanitizedGitHubConnectionSchema, - UpdateGitHubConnectionSchema -} from "@app/services/app-connection/github"; - -import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; - -export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => - registerAppConnectionEndpoints({ - app: AppConnection.GitHub, - server, - responseSchema: SanitizedGitHubConnectionSchema, - createSchema: CreateGitHubConnectionSchema, - updateSchema: UpdateGitHubConnectionSchema - }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/index.ts b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts deleted file mode 100644 index b56a65f508..0000000000 --- a/backend/src/server/routes/v1/app-connection-routers/apps/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router"; -import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; - -export const APP_CONNECTION_REGISTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> = { - [AppConnection.AWS]: registerAwsConnectionRouter, - [AppConnection.GitHub]: registerGitHubConnectionRouter -}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts similarity index 90% rename from backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts rename to backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts index 189ca4fbdf..87ed022a28 100644 --- a/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts @@ -11,7 +11,7 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => registerAppConnectionEndpoints({ app: AppConnection.AWS, server, - responseSchema: SanitizedAwsConnectionSchema, + sanitizedResponseSchema: SanitizedAwsConnectionSchema, createSchema: CreateAwsConnectionSchema, updateSchema: UpdateAwsConnectionSchema }); diff --git a/backend/src/server/routes/v1/app-connection-routers/github-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/github-connection-router.ts new file mode 100644 index 0000000000..9c33f3fad2 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/github-connection-router.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitHubConnectionSchema, + SanitizedGitHubConnectionSchema, + UpdateGitHubConnectionSchema +} from "@app/services/app-connection/github"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.GitHub, + server, + sanitizedResponseSchema: SanitizedGitHubConnectionSchema, + createSchema: CreateGitHubConnectionSchema, + updateSchema: UpdateGitHubConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/repositories`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + repositories: z + .object({ id: z.number(), name: z.string(), owner: z.object({ login: z.string(), id: z.number() }) }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const repositories = await server.services.appConnection.github.listRepositories(connectionId, req.permission); + + return { repositories }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + organizations: z.object({ id: z.number(), login: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const organizations = await server.services.appConnection.github.listOrganizations(connectionId, req.permission); + + return { organizations }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/environments`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + repo: z.string().min(1, "Repository name is required"), + owner: z.string().min(1, "Repository owner name is required") + }), + response: { + 200: z.object({ + environments: z.object({ id: z.number(), name: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + const { repo, owner } = req.query; + + const environments = await server.services.appConnection.github.listEnvironments( + { + connectionId, + repo, + owner + }, + req.permission + ); + + return { environments }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 7200574495..2570cb3ada 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,2 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { registerAwsConnectionRouter } from "./aws-connection-router"; +import { registerGitHubConnectionRouter } from "./github-connection-router"; + export * from "./app-connection-router"; -export * from "./apps"; + +export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server: FastifyZodProvider) => Promise<void>> = + { + [AppConnection.AWS]: registerAwsConnectionRouter, + [AppConnection.GitHub]: registerGitHubConnectionRouter + }; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 7fae1d1f99..e3f6c7f2e3 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,6 +1,10 @@ -import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "@app/server/routes/v1/app-connection-routers"; +import { + APP_CONNECTION_REGISTER_ROUTER_MAP, + registerAppConnectionRouter +} from "@app/server/routes/v1/app-connection-routers"; import { registerCmekRouter } from "@app/server/routes/v1/cmek-router"; import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"; +import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/server/routes/v1/secret-sync-routers"; import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; @@ -113,12 +117,28 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" }); await server.register( - async (appConnectionsRouter) => { - await appConnectionsRouter.register(registerAppConnectionRouter); - for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_MAP)) { - await appConnectionsRouter.register(router, { prefix: `/${app}` }); + async (appConnectionRouter) => { + // register generic app connection endpoints + await appConnectionRouter.register(registerAppConnectionRouter); + + // register service specific endpoints (app-connections/aws, app-connections/github, etc.) + for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_ROUTER_MAP)) { + await appConnectionRouter.register(router, { prefix: `/${app}` }); } }, { prefix: "/app-connections" } ); + + await server.register( + async (secretSyncRouter) => { + // register generic secret sync endpoints + await secretSyncRouter.register(registerSecretSyncRouter); + + // register service specific secret sync endpoints (secret-syncs/aws-parameter-store, secret-syncs/github, etc.) + for await (const [destination, router] of Object.entries(SECRET_SYNC_REGISTER_ROUTER_MAP)) { + await secretSyncRouter.register(router, { prefix: `/${destination}` }); + } + }, + { prefix: "/secret-syncs" } + ); }; diff --git a/backend/src/server/routes/v1/secret-sync-routers/aws-parameter-store-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/aws-parameter-store-sync-router.ts new file mode 100644 index 0000000000..8f02b9e6e9 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/aws-parameter-store-sync-router.ts @@ -0,0 +1,17 @@ +import { + AwsParameterStoreSyncSchema, + CreateAwsParameterStoreSyncSchema, + UpdateAwsParameterStoreSyncSchema +} from "@app/services/secret-sync/aws-parameter-store"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerAwsParameterStoreSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.AWSParameterStore, + server, + responseSchema: AwsParameterStoreSyncSchema, + createSchema: CreateAwsParameterStoreSyncSchema, + updateSchema: UpdateAwsParameterStoreSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/github-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/github-sync-router.ts new file mode 100644 index 0000000000..a84d70354b --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/github-sync-router.ts @@ -0,0 +1,13 @@ +import { CreateGitHubSyncSchema, GitHubSyncSchema, UpdateGitHubSyncSchema } from "@app/services/secret-sync/github"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerGitHubSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.GitHub, + server, + responseSchema: GitHubSyncSchema, + createSchema: CreateGitHubSyncSchema, + updateSchema: UpdateGitHubSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts new file mode 100644 index 0000000000..ecc21b7764 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -0,0 +1,11 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router"; +import { registerGitHubSyncRouter } from "./github-sync-router"; + +export * from "./secret-sync-router"; + +export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: FastifyZodProvider) => Promise<void>> = { + [SecretSync.AWSParameterStore]: registerAwsParameterStoreSyncRouter, + [SecretSync.GitHub]: registerGitHubSyncRouter +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts new file mode 100644 index 0000000000..31826f86f8 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts @@ -0,0 +1,408 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SecretSyncs } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; +import { TSecretSync, TSecretSyncInput } from "@app/services/secret-sync/secret-sync-types"; + +export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TSecretSyncInput>({ + server, + destination, + createSchema, + updateSchema, + responseSchema +}: { + destination: SecretSync; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + environment: string; + secretPath: string; + projectId: string; + connectionId: string; + destinationConfig: I["destinationConfig"]; + syncOptions: I["syncOptions"]; + description?: string | null; + isAutoSyncEnabled?: boolean; + }>; + updateSchema: z.ZodType<{ + connectionId?: string; + name?: string; + environment?: string; + secretPath?: string; + destinationConfig?: I["destinationConfig"]; + syncOptions?: I["syncOptions"]; + description?: string | null; + isAutoSyncEnabled?: boolean; + }>; + responseSchema: z.ZodTypeAny; +}) => { + const destinationName = SECRET_SYNC_NAME_MAP[destination]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${destinationName} Syncs for the specified project.`, + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST(destination).projectId) + }), + response: { + 200: z.object({ secretSyncs: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const secretSyncs = (await server.services.secretSync.listSecretSyncsByProjectId( + { projectId, destination }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_SYNCS, + metadata: { + destination, + count: secretSyncs.length, + syncIds: secretSyncs.map((connection) => connection.id) + } + } + }); + + return { secretSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:syncId", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${destinationName} Sync by ID.`, + params: z.object({ + syncId: z.string().uuid().describe(SecretSyncs.GET_BY_ID(destination).syncId) + }), + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncId } = req.params; + + const secretSync = (await server.services.secretSync.findSecretSyncById( + { syncId, destination }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretSync.projectId, + event: { + type: EventType.GET_SECRET_SYNC, + metadata: { + syncId, + destination + } + } + }); + + return { secretSync }; + } + }); + + server.route({ + method: "GET", + url: `/sync-name/:syncName`, + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${destinationName} Sync by name and project ID.`, + params: z.object({ + syncName: z.string().trim().min(1, "Sync name required").describe(SecretSyncs.GET_BY_NAME(destination).syncName) + }), + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretSyncs.GET_BY_NAME(destination).projectId) + }), + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncName } = req.params; + const { projectId } = req.query; + + const secretSync = (await server.services.secretSync.findSecretSyncByName( + { syncName, projectId, destination }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_SYNC, + metadata: { + syncId: secretSync.id, + destination + } + } + }); + + return { secretSync }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Create ${ + startsWithVowel(destinationName) ? "an" : "a" + } ${destinationName} Sync for the specified project environment.`, + body: createSchema, + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretSync = (await server.services.secretSync.createSecretSync( + { ...req.body, destination }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretSync.projectId, + event: { + type: EventType.CREATE_SECRET_SYNC, + metadata: { + syncId: secretSync.id, + destination, + ...req.body + } + } + }); + + return { secretSync }; + } + }); + + server.route({ + method: "PATCH", + url: "/:syncId", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Update the specified ${destinationName} Sync.`, + params: z.object({ + syncId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).syncId) + }), + body: updateSchema, + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncId } = req.params; + + const secretSync = (await server.services.secretSync.updateSecretSync( + { ...req.body, syncId, destination }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretSync.projectId, + event: { + type: EventType.UPDATE_SECRET_SYNC, + metadata: { + syncId, + destination, + ...req.body + } + } + }); + + return { secretSync }; + } + }); + + server.route({ + method: "DELETE", + url: `/:syncId`, + config: { + rateLimit: writeLimit + }, + schema: { + description: `Delete the specified ${destinationName} Sync.`, + params: z.object({ + syncId: z.string().uuid().describe(SecretSyncs.DELETE(destination).syncId) + }), + querystring: z.object({ + removeSecrets: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + .describe(SecretSyncs.DELETE(destination).removeSecrets) + }), + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncId } = req.params; + const { removeSecrets } = req.query; + + const secretSync = (await server.services.secretSync.deleteSecretSync( + { destination, syncId, removeSecrets }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_SECRET_SYNC, + metadata: { + destination, + syncId, + removeSecrets + } + } + }); + + return { secretSync }; + } + }); + + server.route({ + method: "POST", + url: "/:syncId/sync-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Trigger a sync for the specified ${destinationName} Sync.`, + params: z.object({ + syncId: z.string().uuid().describe(SecretSyncs.SYNC_SECRETS(destination).syncId) + }), + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncId } = req.params; + + const secretSync = (await server.services.secretSync.triggerSecretSyncSyncSecretsById( + { + syncId, + destination, + auditLogInfo: req.auditLogInfo + }, + req.permission + )) as T; + + return { secretSync }; + } + }); + + server.route({ + method: "POST", + url: "/:syncId/import-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Import secrets from the specified ${destinationName} Sync destination.`, + params: z.object({ + syncId: z.string().uuid().describe(SecretSyncs.IMPORT_SECRETS(destination).syncId) + }), + querystring: z.object({ + importBehavior: z + .nativeEnum(SecretSyncImportBehavior) + .describe(SecretSyncs.IMPORT_SECRETS(destination).importBehavior) + }), + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncId } = req.params; + const { importBehavior } = req.query; + + const secretSync = (await server.services.secretSync.triggerSecretSyncImportSecretsById( + { + syncId, + destination, + importBehavior + }, + req.permission + )) as T; + + return { secretSync }; + } + }); + + server.route({ + method: "POST", + url: "/:syncId/remove-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Remove previously synced secrets from the specified ${destinationName} Sync destination.`, + params: z.object({ + syncId: z.string().uuid().describe(SecretSyncs.REMOVE_SECRETS(destination).syncId) + }), + response: { + 200: z.object({ secretSync: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { syncId } = req.params; + + const secretSync = (await server.services.secretSync.triggerSecretSyncRemoveSecretsById( + { + syncId, + destination + }, + req.permission + )) as T; + + return { secretSync }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts new file mode 100644 index 0000000000..5736767ddd --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SecretSyncs } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + AwsParameterStoreSyncListItemSchema, + AwsParameterStoreSyncSchema +} from "@app/services/secret-sync/aws-parameter-store"; +import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github"; + +const SecretSyncSchema = z.discriminatedUnion("destination", [AwsParameterStoreSyncSchema, GitHubSyncSchema]); + +const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ + AwsParameterStoreSyncListItemSchema, + GitHubSyncListItemSchema +]); + +export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List the available Secret Sync Options.", + response: { + 200: z.object({ + secretSyncOptions: SecretSyncOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const secretSyncOptions = server.services.secretSync.listSecretSyncOptions(); + return { secretSyncOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all the Secret Syncs for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.LIST().projectId) + }), + response: { + 200: z.object({ secretSyncs: SecretSyncSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const secretSyncs = await server.services.secretSync.listSecretSyncsByProjectId({ projectId }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_SYNCS, + metadata: { + syncIds: secretSyncs.map((sync) => sync.id), + count: secretSyncs.length + } + } + }); + + return { secretSyncs }; + } + }); +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index d69b7dec1a..e96886e9f9 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -2,3 +2,50 @@ export enum AppConnection { GitHub = "github", AWS = "aws" } + +export enum AWSRegion { + // US + US_EAST_1 = "us-east-1", // N. Virginia + US_EAST_2 = "us-east-2", // Ohio + US_WEST_1 = "us-west-1", // N. California + US_WEST_2 = "us-west-2", // Oregon + + // GovCloud + US_GOV_EAST_1 = "us-gov-east-1", // US-East + US_GOV_WEST_1 = "us-gov-west-1", // US-West + + // Africa + AF_SOUTH_1 = "af-south-1", // Cape Town + + // Asia Pacific + AP_EAST_1 = "ap-east-1", // Hong Kong + AP_SOUTH_1 = "ap-south-1", // Mumbai + AP_SOUTH_2 = "ap-south-2", // Hyderabad + AP_NORTHEAST_1 = "ap-northeast-1", // Tokyo + AP_NORTHEAST_2 = "ap-northeast-2", // Seoul + AP_NORTHEAST_3 = "ap-northeast-3", // Osaka + AP_SOUTHEAST_1 = "ap-southeast-1", // Singapore + AP_SOUTHEAST_2 = "ap-southeast-2", // Sydney + AP_SOUTHEAST_3 = "ap-southeast-3", // Jakarta + AP_SOUTHEAST_4 = "ap-southeast-4", // Melbourne + + // Canada + CA_CENTRAL_1 = "ca-central-1", // Central + + // Europe + EU_CENTRAL_1 = "eu-central-1", // Frankfurt + EU_CENTRAL_2 = "eu-central-2", // Zurich + EU_WEST_1 = "eu-west-1", // Ireland + EU_WEST_2 = "eu-west-2", // London + EU_WEST_3 = "eu-west-3", // Paris + EU_SOUTH_1 = "eu-south-1", // Milan + EU_SOUTH_2 = "eu-south-2", // Spain + EU_NORTH_1 = "eu-north-1", // Stockholm + + // Middle East + ME_SOUTH_1 = "me-south-1", // Bahrain + ME_CENTRAL_1 = "me-central-1", // UAE + + // South America + SA_EAST_1 = "sa-east-1" // Sao Paulo +} diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 787839cf7b..3f52b12854 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,3 +1,4 @@ +import { TAppConnections } from "@app/db/schemas/app-connections"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types"; @@ -64,9 +65,8 @@ export const validateAppConnectionCredentials = async ( ): Promise<TAppConnection["credentials"]> => { const { app } = appConnection; switch (app) { - case AppConnection.AWS: { + case AppConnection.AWS: return validateAwsConnectionCredentials(appConnection); - } case AppConnection.GitHub: return validateGitHubConnectionCredentials(appConnection); default: @@ -90,3 +90,17 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => throw new Error(`Unhandled App Connection Method: ${method}`); } }; + +export const decryptAppConnection = async ( + appConnection: TAppConnections, + kmsService: TAppConnectionServiceFactoryDep["kmsService"] +) => { + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; +}; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 9b9f16626d..91e7d9dc44 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -1,13 +1,12 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; -import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { - decryptAppConnectionCredentials, + decryptAppConnection, encryptAppConnectionCredentials, getAppConnectionMethodName, listAppConnectionOptions, @@ -23,6 +22,7 @@ import { } from "@app/services/app-connection/app-connection-types"; import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws"; import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github"; +import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; @@ -31,7 +31,6 @@ export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; - licenseService: Pick<TLicenseServiceFactory, "getPlan">; // TODO: remove once launched }; export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServiceFactory>; @@ -44,19 +43,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp export const appConnectionServiceFactory = ({ appConnectionDAL, permissionService, - kmsService, - licenseService + kmsService }: TAppConnectionServiceFactoryDep) => { - // app connections are disabled for public until launch - const checkAppServicesAvailability = async (orgId: string) => { - const subscription = await licenseService.getPlan(orgId); - - if (!subscription.appConnections) throw new BadRequestError({ message: "App Connections are not available yet." }); - }; - const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { - await checkAppServicesAvailability(actor.orgId); - const { permission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -65,7 +54,10 @@ export const appConnectionServiceFactory = ({ actor.orgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + OrgPermissionSubjects.AppConnections + ); const appConnections = await appConnectionDAL.find( app @@ -78,24 +70,11 @@ export const appConnectionServiceFactory = ({ return Promise.all( appConnections .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) - .map(async ({ encryptedCredentials, ...connection }) => { - const credentials = await decryptAppConnectionCredentials({ - encryptedCredentials, - kmsService, - orgId: connection.orgId - }); - - return { - ...connection, - credentials - } as TAppConnection; - }) + .map((appConnection) => decryptAppConnection(appConnection, kmsService)) ); }; const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { - await checkAppServicesAvailability(actor.orgId); - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); @@ -108,24 +87,18 @@ export const appConnectionServiceFactory = ({ appConnection.orgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + OrgPermissionSubjects.AppConnections + ); if (appConnection.app !== app) throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); - return { - ...appConnection, - credentials: await decryptAppConnectionCredentials({ - encryptedCredentials: appConnection.encryptedCredentials, - orgId: appConnection.orgId, - kmsService - }) - } as TAppConnection; + return decryptAppConnection(appConnection, kmsService); }; const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => { - await checkAppServicesAvailability(actor.orgId); - const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId }); if (!appConnection) @@ -139,27 +112,21 @@ export const appConnectionServiceFactory = ({ appConnection.orgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Read, + OrgPermissionSubjects.AppConnections + ); if (appConnection.app !== app) throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` }); - return { - ...appConnection, - credentials: await decryptAppConnectionCredentials({ - encryptedCredentials: appConnection.encryptedCredentials, - orgId: appConnection.orgId, - kmsService - }) - } as TAppConnection; + return decryptAppConnection(appConnection, kmsService); }; const createAppConnection = async ( { method, app, credentials, ...params }: TCreateAppConnectionDTO, actor: OrgServiceActor ) => { - await checkAppServicesAvailability(actor.orgId); - const { permission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -168,7 +135,10 @@ export const appConnectionServiceFactory = ({ actor.orgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); const appConnection = await appConnectionDAL.transaction(async (tx) => { const isConflictingName = Boolean( @@ -216,15 +186,13 @@ export const appConnectionServiceFactory = ({ }; }); - return appConnection; + return appConnection as TAppConnection; }; const updateAppConnection = async ( { connectionId, credentials, ...params }: TUpdateAppConnectionDTO, actor: OrgServiceActor ) => { - await checkAppServicesAvailability(actor.orgId); - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); @@ -237,7 +205,10 @@ export const appConnectionServiceFactory = ({ appConnection.orgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Edit, + OrgPermissionSubjects.AppConnections + ); const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => { if (params.name && appConnection.name !== params.name) { @@ -304,19 +275,10 @@ export const appConnectionServiceFactory = ({ return updatedConnection; }); - return { - ...updatedAppConnection, - credentials: await decryptAppConnectionCredentials({ - encryptedCredentials: updatedAppConnection.encryptedCredentials, - orgId: updatedAppConnection.orgId, - kmsService - }) - } as TAppConnection; + return decryptAppConnection(updatedAppConnection, kmsService); }; const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { - await checkAppServicesAvailability(actor.orgId); - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); @@ -329,23 +291,85 @@ export const appConnectionServiceFactory = ({ appConnection.orgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAppConnectionActions.Delete, + OrgPermissionSubjects.AppConnections + ); if (appConnection.app !== app) throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); - // TODO: specify delete error message if due to existing dependencies + // TODO (scott): add option to delete all dependencies - const deletedAppConnection = await appConnectionDAL.deleteById(connectionId); + try { + const deletedAppConnection = await appConnectionDAL.deleteById(connectionId); - return { - ...deletedAppConnection, - credentials: await decryptAppConnectionCredentials({ - encryptedCredentials: deletedAppConnection.encryptedCredentials, - orgId: deletedAppConnection.orgId, - kmsService - }) - } as TAppConnection; + return await decryptAppConnection(deletedAppConnection, kmsService); + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23503") { + throw new BadRequestError({ + message: + "Cannot delete App Connection with existing connections. Remove all existing connections and try again." + }); + } + + throw err; + } + }; + + const connectAppConnectionById = async <T extends TAppConnection>( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor + ) => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + appConnection.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionAppConnectionActions.Connect, + subject(OrgPermissionSubjects.AppConnections, { connectionId: appConnection.id }) + ); + + if (appConnection.app !== app) + throw new BadRequestError({ + message: `${ + APP_CONNECTION_NAME_MAP[appConnection.app as AppConnection] + } Connection with ID ${connectionId} cannot be used to connect to ${APP_CONNECTION_NAME_MAP[app]}` + }); + + const connection = await decryptAppConnection(appConnection, kmsService); + + return connection as T; + }; + + const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor) => { + const { permission: orgPermission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + const appConnections = await appConnectionDAL.find({ app, orgId: actor.orgId }); + + const availableConnections = appConnections.filter((connection) => + orgPermission.can( + OrgPermissionAppConnectionActions.Connect, + subject(OrgPermissionSubjects.AppConnections, { connectionId: connection.id }) + ) + ); + + return availableConnections as Omit<TAppConnection, "credentials">[]; }; return { @@ -355,6 +379,9 @@ export const appConnectionServiceFactory = ({ findAppConnectionByName, createAppConnection, updateAppConnection, - deleteAppConnection + deleteAppConnection, + connectAppConnectionById, + listAvailableAppConnectionsForUser, + github: githubConnectionService(connectAppConnectionById) }; }; diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts index 36008bc58a..d814215298 100644 --- a/backend/src/services/app-connection/aws/aws-connection-fns.ts +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -4,7 +4,7 @@ import { randomUUID } from "crypto"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { AwsConnectionMethod } from "./aws-connection-enums"; import { TAwsConnectionConfig } from "./aws-connection-types"; @@ -20,7 +20,7 @@ export const getAwsAppConnectionListItem = () => { }; }; -export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = "us-east-1") => { +export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = AWSRegion.US_EAST_1) => { const appCfg = getConfig(); let accessKeyId: string; diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts index 914e92671f..c06c6f0edb 100644 --- a/backend/src/services/app-connection/aws/aws-connection-schemas.ts +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -38,11 +38,11 @@ export const AwsConnectionSchema = z.intersection( export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ BaseAwsConnectionSchema.extend({ method: z.literal(AwsConnectionMethod.AssumeRole), - credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true }) + credentials: AwsConnectionAssumeRoleCredentialsSchema.pick({}) }), BaseAwsConnectionSchema.extend({ method: z.literal(AwsConnectionMethod.AccessKey), - credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true }) + credentials: AwsConnectionAccessTokenCredentialsSchema.pick({ accessKeyId: true }) }) ]); @@ -75,7 +75,7 @@ export const UpdateAwsConnectionSchema = z export const AwsConnectionListItemSchema = z.object({ name: z.literal("AWS"), app: z.literal(AppConnection.AWS), - // the below is preferable but currently breaks mintlify + // the below is preferable but currently breaks with our zod to json schema parser // methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]), methods: z.nativeEnum(AwsConnectionMethod).array(), accessKeyId: z.string().optional() diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 01fa7846fb..391ba5f965 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,3 +1,5 @@ +import { createAppAuth } from "@octokit/auth-app"; +import { Octokit } from "@octokit/rest"; import { AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -8,7 +10,7 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list import { AppConnection } from "../app-connection-enums"; import { GitHubConnectionMethod } from "./github-connection-enums"; -import { TGitHubConnectionConfig } from "./github-connection-types"; +import { TGitHubConnection, TGitHubConnectionConfig } from "./github-connection-types"; export const getGitHubConnectionListItem = () => { const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig(); @@ -22,10 +24,131 @@ export const getGitHubConnectionListItem = () => { }; }; +export const getGitHubClient = (appConnection: TGitHubConnection) => { + const appCfg = getConfig(); + + const { method, credentials } = appConnection; + + let client: Octokit; + + switch (method) { + case GitHubConnectionMethod.App: + if (!appCfg.INF_APP_CONNECTION_GITHUB_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + client = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY, + installationId: credentials.installationId + } + }); + break; + case GitHubConnectionMethod.OAuth: + client = new Octokit({ + auth: credentials.accessToken + }); + break; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}` + }); + } + + return client; +}; + +type GitHubOrganization = { + login: string; + id: number; +}; + +type GitHubRepository = { + id: number; + name: string; + owner: GitHubOrganization; +}; + +export const getGitHubRepositories = async (appConnection: TGitHubConnection) => { + const client = getGitHubClient(appConnection); + + let repositories: GitHubRepository[]; + + switch (appConnection.method) { + case GitHubConnectionMethod.App: + repositories = await client.paginate("GET /installation/repositories"); + break; + case GitHubConnectionMethod.OAuth: + default: + repositories = (await client.paginate("GET /user/repos")).filter((repo) => repo.permissions?.admin); + break; + } + + return repositories; +}; + +export const getGitHubOrganizations = async (appConnection: TGitHubConnection) => { + const client = getGitHubClient(appConnection); + + let organizations: GitHubOrganization[]; + + switch (appConnection.method) { + case GitHubConnectionMethod.App: { + const installationRepositories = await client.paginate("GET /installation/repositories"); + + const organizationMap: Record<string, GitHubOrganization> = {}; + + installationRepositories.forEach((repo) => { + if (repo.owner.type === "Organization") { + organizationMap[repo.owner.id] = repo.owner; + } + }); + + organizations = Object.values(organizationMap); + + break; + } + case GitHubConnectionMethod.OAuth: + default: + organizations = await client.paginate("GET /user/orgs"); + break; + } + + return organizations; +}; + +export const getGitHubEnvironments = async (appConnection: TGitHubConnection, owner: string, repo: string) => { + const client = getGitHubClient(appConnection); + + try { + const environments = await client.paginate("GET /repos/{owner}/{repo}/environments", { + owner, + repo + }); + + return environments; + } catch (e) { + // repo doesn't have envs + if ((e as { status: number }).status === 404) { + return []; + } + + throw e; + } +}; + type TokenRespData = { access_token: string; scope: string; token_type: string; + error?: string; }; export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { @@ -53,7 +176,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect if (!clientId || !clientSecret) { throw new InternalServerError({ - message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured` + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` }); } @@ -65,7 +191,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect client_id: clientId, client_secret: clientSecret, code: credentials.code, - redirect_uri: `${SITE_URL}/app-connections/github/oauth/callback` + redirect_uri: `${SITE_URL}/organization/app-connections/github/oauth/callback` }, headers: { Accept: "application/json", @@ -90,6 +216,8 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect id: number; account: { login: string; + type: string; + id: number; }; }[]; }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { @@ -111,10 +239,13 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect } } + if (!tokenResp.data.access_token) { + throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); + } + switch (method) { case GitHubConnectionMethod.App: return { - // access token not needed for GitHub App installationId: credentials.installationId }; case GitHubConnectionMethod.OAuth: diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index 5adb211bac..e98b9169db 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -57,7 +57,7 @@ export const UpdateGitHubConnectionSchema = z const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); -export const GitHubAppConnectionSchema = z.intersection( +export const GitHubConnectionSchema = z.intersection( BaseGitHubConnectionSchema, z.discriminatedUnion("method", [ z.object({ @@ -74,19 +74,19 @@ export const GitHubAppConnectionSchema = z.intersection( export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.App), - credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true }) + credentials: GitHubConnectionAppOutputCredentialsSchema.pick({}) }), BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.OAuth), - credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true }) + credentials: GitHubConnectionOAuthOutputCredentialsSchema.pick({}) }) ]); export const GitHubConnectionListItemSchema = z.object({ name: z.literal("GitHub"), app: z.literal(AppConnection.GitHub), - // the below is preferable but currently breaks mintlify - // methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), methods: z.nativeEnum(GitHubConnectionMethod).array(), oauthClientId: z.string().optional(), appClientSlug: z.string().optional() diff --git a/backend/src/services/app-connection/github/github-connection-service.ts b/backend/src/services/app-connection/github/github-connection-service.ts new file mode 100644 index 0000000000..b4e95c5a7f --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-service.ts @@ -0,0 +1,55 @@ +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + getGitHubEnvironments, + getGitHubOrganizations, + getGitHubRepositories +} from "@app/services/app-connection/github/github-connection-fns"; +import { TGitHubConnection } from "@app/services/app-connection/github/github-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise<TGitHubConnection>; + +type TListGitHubEnvironmentsDTO = { + connectionId: string; + repo: string; + owner: string; +}; + +export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); + + const repositories = await getGitHubRepositories(appConnection); + + return repositories; + }; + + const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); + + const organizations = await getGitHubOrganizations(appConnection); + + return organizations; + }; + + const listEnvironments = async ( + { connectionId, repo, owner }: TListGitHubEnvironmentsDTO, + actor: OrgServiceActor + ) => { + const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); + + const environments = await getGitHubEnvironments(appConnection, owner, repo); + + return environments; + }; + + return { + listRepositories, + listOrganizations, + listEnvironments + }; +}; diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts index 5a9b13c00f..714c871749 100644 --- a/backend/src/services/app-connection/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -5,11 +5,11 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { CreateGitHubConnectionSchema, - GitHubAppConnectionSchema, + GitHubConnectionSchema, ValidateGitHubConnectionCredentialsSchema } from "./github-connection-schemas"; -export type TGitHubConnection = z.infer<typeof GitHubAppConnectionSchema>; +export type TGitHubConnection = z.infer<typeof GitHubConnectionSchema>; export type TGitHubConnectionInput = z.infer<typeof CreateGitHubConnectionSchema> & { app: AppConnection.GitHub; diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-constants.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-constants.ts new file mode 100644 index 0000000000..37605442d1 --- /dev/null +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const AWS_PARAMETER_STORE_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "AWS Parameter Store", + destination: SecretSync.AWSParameterStore, + connection: AppConnection.AWS, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts new file mode 100644 index 0000000000..a495f9e839 --- /dev/null +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -0,0 +1,207 @@ +import AWS, { AWSError } from "aws-sdk"; + +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types"; + +type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>; + +const MAX_RETRIES = 5; +const BATCH_SIZE = 10; + +const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => { + const { destinationConfig, connection } = secretSync; + + const config = await getAwsConnectionConfig(connection, destinationConfig.region); + + const ssm = new AWS.SSM({ + apiVersion: "2014-11-06", + region: destinationConfig.region + }); + + ssm.config.update(config); + + return ssm; +}; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + +const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => { + const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {}; + let hasNext = true; + let nextToken: string | undefined; + let attempt = 0; + + while (hasNext) { + try { + // eslint-disable-next-line no-await-in-loop + const parameters = await ssm + .getParametersByPath({ + Path: path, + Recursive: false, + WithDecryption: true, + MaxResults: BATCH_SIZE, + NextToken: nextToken + }) + .promise(); + + attempt = 0; + + if (parameters.Parameters) { + parameters.Parameters.forEach((parameter) => { + if (parameter.Name) { + // no leading slash if path is '/' + const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; + awsParameterStoreSecretsRecord[secKey] = parameter; + } + }); + } + + hasNext = Boolean(parameters.NextToken); + nextToken = parameters.NextToken; + } catch (e) { + if ((e as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + attempt += 1; + // eslint-disable-next-line no-await-in-loop + await sleep(); + } + + throw e; + } + } + + return awsParameterStoreSecretsRecord; +}; + +const putParameter = async ( + ssm: AWS.SSM, + params: AWS.SSM.PutParameterRequest, + attempt = 0 +): Promise<AWS.SSM.PutParameterResult> => { + try { + return await ssm.putParameter(params).promise(); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + await sleep(); + + // retry + return putParameter(ssm, params, attempt + 1); + } + throw error; + } +}; + +const deleteParametersBatch = async ( + ssm: AWS.SSM, + parameters: AWS.SSM.Parameter[], + attempt = 0 +): Promise<AWS.SSM.DeleteParameterResult[]> => { + const results: AWS.SSM.DeleteParameterResult[] = []; + let remainingParams = [...parameters]; + + while (remainingParams.length > 0) { + const batch = remainingParams.slice(0, BATCH_SIZE); + + try { + // eslint-disable-next-line no-await-in-loop + const result = await ssm.deleteParameters({ Names: batch.map((param) => param.Name!) }).promise(); + results.push(result); + remainingParams = remainingParams.slice(BATCH_SIZE); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + // eslint-disable-next-line no-await-in-loop + await sleep(); + + // Retry the current batch + // eslint-disable-next-line no-await-in-loop + return [...results, ...(await deleteParametersBatch(ssm, remainingParams, attempt + 1))]; + } + throw error; + } + } + + return results; +}; + +export const AwsParameterStoreSyncFns = { + syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { + const { destinationConfig } = secretSync; + + const ssm = await getSSM(secretSync); + + // TODO(scott): KMS Key ID, Tags + + const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + // skip empty values (not allowed by AWS) or secrets that haven't changed + if (!value || (key in awsParameterStoreSecretsRecord && awsParameterStoreSecretsRecord[key].Value === value)) { + // eslint-disable-next-line no-continue + continue; + } + + try { + await putParameter(ssm, { + Name: `${destinationConfig.path}${key}`, + Type: "SecureString", + Value: value, + Overwrite: true + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + const parametersToDelete: AWS.SSM.Parameter[] = []; + + for (const entry of Object.entries(awsParameterStoreSecretsRecord)) { + const [key, parameter] = entry; + + if (!(key in secretMap) || !secretMap[key].value) { + parametersToDelete.push(parameter); + } + } + + await deleteParametersBatch(ssm, parametersToDelete); + }, + getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => { + const { destinationConfig } = secretSync; + + const ssm = await getSSM(secretSync); + + const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + + return Object.fromEntries( + Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }]) + ); + }, + removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { + const { destinationConfig } = secretSync; + + const ssm = await getSSM(secretSync); + + const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + + const parametersToDelete: AWS.SSM.Parameter[] = []; + + for (const entry of Object.entries(awsParameterStoreSecretsRecord)) { + const [key, param] = entry; + + if (key in secretMap) { + parametersToDelete.push(param); + } + } + + await deleteParametersBatch(ssm, parametersToDelete); + } +}; diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts new file mode 100644 index 0000000000..e89096baa1 --- /dev/null +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; + +const AwsParameterStoreSyncDestinationConfigSchema = z.object({ + region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.REGION), + path: z + .string() + .trim() + .min(1, "Parameter Store Path required") + .max(2048, "Cannot exceed 2048 characters") + .regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format') + .describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.PATH) +}); + +export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(SecretSync.AWSParameterStore).extend({ + destination: z.literal(SecretSync.AWSParameterStore), + destinationConfig: AwsParameterStoreSyncDestinationConfigSchema +}); + +export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.AWSParameterStore +).extend({ + destinationConfig: AwsParameterStoreSyncDestinationConfigSchema +}); + +export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.AWSParameterStore +).extend({ + destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional() +}); + +export const AwsParameterStoreSyncListItemSchema = z.object({ + name: z.literal("AWS Parameter Store"), + connection: z.literal(AppConnection.AWS), + destination: z.literal(SecretSync.AWSParameterStore), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-types.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-types.ts new file mode 100644 index 0000000000..dada28435b --- /dev/null +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TAwsConnection } from "@app/services/app-connection/aws"; + +import { + AwsParameterStoreSyncListItemSchema, + AwsParameterStoreSyncSchema, + CreateAwsParameterStoreSyncSchema +} from "./aws-parameter-store-sync-schemas"; + +export type TAwsParameterStoreSync = z.infer<typeof AwsParameterStoreSyncSchema>; + +export type TAwsParameterStoreSyncInput = z.infer<typeof CreateAwsParameterStoreSyncSchema>; + +export type TAwsParameterStoreSyncListItem = z.infer<typeof AwsParameterStoreSyncListItemSchema>; + +export type TAwsParameterStoreSyncWithCredentials = TAwsParameterStoreSync & { + connection: TAwsConnection; +}; diff --git a/backend/src/services/secret-sync/aws-parameter-store/index.ts b/backend/src/services/secret-sync/aws-parameter-store/index.ts new file mode 100644 index 0000000000..20728cd8ff --- /dev/null +++ b/backend/src/services/secret-sync/aws-parameter-store/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-parameter-store-sync-constants"; +export * from "./aws-parameter-store-sync-fns"; +export * from "./aws-parameter-store-sync-schemas"; +export * from "./aws-parameter-store-sync-types"; diff --git a/backend/src/services/secret-sync/github/github-sync-constants.ts b/backend/src/services/secret-sync/github/github-sync-constants.ts new file mode 100644 index 0000000000..f97b96d9ee --- /dev/null +++ b/backend/src/services/secret-sync/github/github-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const GITHUB_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "GitHub", + destination: SecretSync.GitHub, + connection: AppConnection.GitHub, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/github/github-sync-enums.ts b/backend/src/services/secret-sync/github/github-sync-enums.ts new file mode 100644 index 0000000000..c0109370ed --- /dev/null +++ b/backend/src/services/secret-sync/github/github-sync-enums.ts @@ -0,0 +1,11 @@ +export enum GitHubSyncScope { + Repository = "repository", + Organization = "organization", + RepositoryEnvironment = "repository-environment" +} + +export enum GitHubSyncVisibility { + All = "all", + Private = "private", + Selected = "selected" +} diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts new file mode 100644 index 0000000000..a09a411635 --- /dev/null +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -0,0 +1,242 @@ +import { Octokit } from "@octokit/rest"; +import sodium from "libsodium-wrappers"; + +import { getGitHubClient } from "@app/services/app-connection/github"; +import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithCredentials } from "./github-sync-types"; + +// TODO: rate limit handling + +const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => { + let encryptedSecrets: TGitHubSecret[]; + + const { destinationConfig } = secretSync; + + switch (destinationConfig.scope) { + case GitHubSyncScope.Organization: { + encryptedSecrets = await client.paginate("GET /orgs/{org}/actions/secrets", { + org: destinationConfig.org + }); + break; + } + case GitHubSyncScope.Repository: { + encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/actions/secrets", { + owner: destinationConfig.owner, + repo: destinationConfig.repo + }); + + break; + } + case GitHubSyncScope.RepositoryEnvironment: + default: { + encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", { + owner: destinationConfig.owner, + repo: destinationConfig.repo, + environment_name: destinationConfig.env + }); + break; + } + } + + return encryptedSecrets; +}; + +const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => { + let publicKey: TGitHubPublicKey; + + const { destinationConfig } = secretSync; + + switch (destinationConfig.scope) { + case GitHubSyncScope.Organization: { + publicKey = ( + await client.request("GET /orgs/{org}/actions/secrets/public-key", { + org: destinationConfig.org + }) + ).data; + break; + } + case GitHubSyncScope.Repository: { + publicKey = ( + await client.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", { + owner: destinationConfig.owner, + repo: destinationConfig.repo + }) + ).data; + break; + } + case GitHubSyncScope.RepositoryEnvironment: + default: { + publicKey = ( + await client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", { + owner: destinationConfig.owner, + repo: destinationConfig.repo, + environment_name: destinationConfig.env + }) + ).data; + break; + } + } + + return publicKey; +}; + +const deleteSecret = async ( + client: Octokit, + secretSync: TGitHubSyncWithCredentials, + encryptedSecret: TGitHubSecret +) => { + const { destinationConfig } = secretSync; + + switch (destinationConfig.scope) { + case GitHubSyncScope.Organization: { + await client.request(`DELETE /orgs/{org}/actions/secrets/{secret_name}`, { + org: destinationConfig.org, + secret_name: encryptedSecret.name + }); + break; + } + case GitHubSyncScope.Repository: { + await client.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: destinationConfig.owner, + repo: destinationConfig.repo, + secret_name: encryptedSecret.name + }); + break; + } + case GitHubSyncScope.RepositoryEnvironment: + default: { + await client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", { + owner: destinationConfig.owner, + repo: destinationConfig.repo, + environment_name: destinationConfig.env, + secret_name: encryptedSecret.name + }); + break; + } + } +}; + +const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: TGitHubSecretPayload) => { + const { destinationConfig } = secretSync; + + switch (destinationConfig.scope) { + case GitHubSyncScope.Organization: { + const { visibility, selectedRepositoryIds } = destinationConfig; + + await client.request(`PUT /orgs/{org}/actions/secrets/{secret_name}`, { + org: destinationConfig.org, + ...payload, + visibility, + ...(visibility === GitHubSyncVisibility.Selected && { + selected_repository_ids: selectedRepositoryIds + }) + }); + break; + } + case GitHubSyncScope.Repository: { + await client.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: destinationConfig.owner, + repo: destinationConfig.repo, + ...payload + }); + break; + } + case GitHubSyncScope.RepositoryEnvironment: + default: { + await client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", { + owner: destinationConfig.owner, + repo: destinationConfig.repo, + environment_name: destinationConfig.env, + ...payload + }); + break; + } + } +}; + +export const GithubSyncFns = { + syncSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => { + switch (secretSync.destinationConfig.scope) { + case GitHubSyncScope.Organization: + if (Object.values(secretMap).length > 1000) { + throw new SecretSyncError({ + message: "GitHub does not support storing more than 1,000 secrets at the organization level.", + shouldRetry: false + }); + } + break; + case GitHubSyncScope.Repository: + case GitHubSyncScope.RepositoryEnvironment: + if (Object.values(secretMap).length > 100) { + throw new SecretSyncError({ + message: "GitHub does not support storing more than 100 secrets at the repository level.", + shouldRetry: false + }); + } + break; + default: + throw new Error( + `Unsupported GitHub Sync scope ${ + (secretSync.destinationConfig as TGitHubSyncWithCredentials["destinationConfig"]).scope + }` + ); + } + + const client = getGitHubClient(secretSync.connection); + + const encryptedSecrets = await getEncryptedSecrets(client, secretSync); + + const publicKey = await getPublicKey(client, secretSync); + + for await (const encryptedSecret of encryptedSecrets) { + if (!(encryptedSecret.name in secretMap)) { + await deleteSecret(client, secretSync, encryptedSecret); + } + } + + await sodium.ready.then(async () => { + for await (const key of Object.keys(secretMap)) { + // convert secret & base64 key to Uint8Array. + const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL); + const binarySecretValue = sodium.from_string(secretMap[key].value); + + // encrypt secret using libsodium + const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey); + + // convert encrypted Uint8Array to base64 + const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); + + try { + await putSecret(client, secretSync, { + secret_name: key, + encrypted_value: encryptedSecretValue, + key_id: publicKey.key_id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }); + }, + getSecrets: async (secretSync: TGitHubSyncWithCredentials) => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + removeSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => { + const client = getGitHubClient(secretSync.connection); + + const encryptedSecrets = await getEncryptedSecrets(client, secretSync); + + for await (const encryptedSecret of encryptedSecrets) { + if (encryptedSecret.name in secretMap) { + await deleteSecret(client, secretSync, encryptedSecret); + } + } + } +}; diff --git a/backend/src/services/secret-sync/github/github-sync-schemas.ts b/backend/src/services/secret-sync/github/github-sync-schemas.ts new file mode 100644 index 0000000000..37a294a1be --- /dev/null +++ b/backend/src/services/secret-sync/github/github-sync-schemas.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const GitHubSyncDestinationConfigSchema = z + .discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitHubSyncScope.Organization), + org: z.string().min(1, "Organization name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ORG), + visibility: z.nativeEnum(GitHubSyncVisibility), + selectedRepositoryIds: z.number().array().optional() + }), + z.object({ + scope: z.literal(GitHubSyncScope.Repository), + owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER), + repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO) + }), + z.object({ + scope: z.literal(GitHubSyncScope.RepositoryEnvironment), + owner: z.string().min(1, "Repository owner name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.OWNER), + repo: z.string().min(1, "Repository name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.REPO), + env: z.string().min(1, "Environment name required").describe(SecretSyncs.DESTINATION_CONFIG.GITHUB.ENV) + }) + ]) + .superRefine((options, ctx) => { + if (options.scope === GitHubSyncScope.Organization) { + if (options.visibility === GitHubSyncVisibility.Selected) { + if (!options.selectedRepositoryIds?.length) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select at least 1 repository", + path: ["selectedRepositoryIds"] + }); + return; + } + + if (options.selectedRepositoryIds?.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Selected repositories is only supported for visibility "Selected"`, + path: ["selectedRepositoryIds"] + }); + } + } + }); + +const GitHubSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const GitHubSyncSchema = BaseSecretSyncSchema(SecretSync.GitHub, GitHubSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.GitHub), + destinationConfig: GitHubSyncDestinationConfigSchema +}); + +export const CreateGitHubSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.GitHub, + GitHubSyncOptionsConfig +).extend({ + destinationConfig: GitHubSyncDestinationConfigSchema +}); + +export const UpdateGitHubSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.GitHub, + GitHubSyncOptionsConfig +).extend({ + destinationConfig: GitHubSyncDestinationConfigSchema.optional() +}); + +export const GitHubSyncListItemSchema = z.object({ + name: z.literal("GitHub"), + connection: z.literal(AppConnection.GitHub), + destination: z.literal(SecretSync.GitHub), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/github/github-sync-types.ts b/backend/src/services/secret-sync/github/github-sync-types.ts new file mode 100644 index 0000000000..c917a9fa4d --- /dev/null +++ b/backend/src/services/secret-sync/github/github-sync-types.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { TGitHubConnection } from "@app/services/app-connection/github"; + +import { CreateGitHubSyncSchema, GitHubSyncListItemSchema, GitHubSyncSchema } from "./github-sync-schemas"; + +export type TGitHubSync = z.infer<typeof GitHubSyncSchema>; + +export type TGitHubSyncInput = z.infer<typeof CreateGitHubSyncSchema>; + +export type TGitHubSyncListItem = z.infer<typeof GitHubSyncListItemSchema>; + +export type TGitHubSyncWithCredentials = TGitHubSync & { + connection: TGitHubConnection; +}; + +export type TGitHubSecret = { + name: string; + created_at: string; + updated_at: string; + visibility?: "all" | "private" | "selected"; + selected_repositories_url?: string | undefined; +}; + +export type TGitHubPublicKey = { + key_id: string; + key: string; + id?: number | undefined; + url?: string | undefined; + title?: string | undefined; + created_at?: string | undefined; +}; + +export type TGitHubSecretPayload = { + key_id: string; + secret_name: string; + encrypted_value: string; +}; diff --git a/backend/src/services/secret-sync/github/index.ts b/backend/src/services/secret-sync/github/index.ts new file mode 100644 index 0000000000..a136d77801 --- /dev/null +++ b/backend/src/services/secret-sync/github/index.ts @@ -0,0 +1,4 @@ +export * from "./github-sync-constants"; +export * from "./github-sync-fns"; +export * from "./github-sync-schemas"; +export * from "./github-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts new file mode 100644 index 0000000000..8d99f8637c --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -0,0 +1,212 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TSecretSyncs } from "@app/db/schemas/secret-syncs"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; + +export type TSecretSyncDALFactory = ReturnType<typeof secretSyncDALFactory>; + +type SecretSyncFindFilter = Parameters<typeof buildFindFilter<TSecretSyncs>>[0]; + +const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: SecretSyncFindFilter; tx?: Knex }) => { + const query = (tx || db.replicaNode())(TableName.SecretSync) + .leftJoin(TableName.SecretFolder, `${TableName.SecretSync}.folderId`, `${TableName.SecretFolder}.id`) + .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join(TableName.AppConnection, `${TableName.SecretSync}.connectionId`, `${TableName.AppConnection}.id`) + .select(selectAllTableCols(TableName.SecretSync)) + .select( + // environment + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + // entire connection + db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), + db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), + db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"), + db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), + db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt") + ); + + // prepends table name to filter keys to avoid ambiguous col references, skipping utility filters like $in, etc. + const prependTableName = (filterObj: object): SecretSyncFindFilter => + Object.fromEntries( + Object.entries(filterObj).map(([key, value]) => + key.startsWith("$") ? [key, prependTableName(value as object)] : [`${TableName.SecretSync}.${key}`, value] + ) + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableName(filter))); + } + + return query; +}; + +const expandSecretSync = ( + secretSync: Awaited<ReturnType<typeof baseSecretSyncQuery>>[number], + folder?: Awaited<ReturnType<TSecretFolderDALFactory["findSecretPathByFolderIds"]>>[number] +) => { + const { + envId, + envName, + envSlug, + connectionApp, + connectionName, + connectionId, + connectionOrgId, + connectionEncryptedCredentials, + connectionMethod, + connectionDescription, + connectionCreatedAt, + connectionUpdatedAt, + connectionVersion, + ...el + } = secretSync; + + return { + ...el, + connectionId, + environment: envId ? { id: envId, name: envName, slug: envSlug } : null, + connection: { + app: connectionApp, + id: connectionId, + name: connectionName, + orgId: connectionOrgId, + encryptedCredentials: connectionEncryptedCredentials, + method: connectionMethod, + description: connectionDescription, + createdAt: connectionCreatedAt, + updatedAt: connectionUpdatedAt, + version: connectionVersion + }, + folder: folder + ? { + id: folder.id, + path: folder.path + } + : null + }; +}; + +export const secretSyncDALFactory = ( + db: TDbClient, + folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds"> +) => { + const secretSyncOrm = ormify(db, TableName.SecretSync); + + const findById = async (id: string, tx?: Knex) => { + try { + const secretSync = await baseSecretSyncQuery({ + filter: { id }, + db, + tx + }).first(); + + if (secretSync) { + // TODO (scott): replace with cached folder path once implemented + const [folderWithPath] = secretSync.folderId + ? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]) + : []; + return expandSecretSync(secretSync, folderWithPath); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find by ID - Secret Sync" }); + } + }; + + const create = async (data: Parameters<(typeof secretSyncOrm)["create"]>[0]) => { + try { + const secretSync = (await secretSyncOrm.transaction(async (tx) => { + const sync = await secretSyncOrm.create(data, tx); + + return baseSecretSyncQuery({ + filter: { id: sync.id }, + db, + tx + }).first(); + }))!; + + // TODO (scott): replace with cached folder path once implemented + const [folderWithPath] = secretSync.folderId + ? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]) + : []; + return expandSecretSync(secretSync, folderWithPath); + } catch (error) { + throw new DatabaseError({ error, name: "Create - Secret Sync" }); + } + }; + + const updateById = async (syncId: string, data: Parameters<(typeof secretSyncOrm)["updateById"]>[1]) => { + try { + const secretSync = (await secretSyncOrm.transaction(async (tx) => { + const sync = await secretSyncOrm.updateById(syncId, data, tx); + + return baseSecretSyncQuery({ + filter: { id: sync.id }, + db, + tx + }).first(); + }))!; + + // TODO (scott): replace with cached folder path once implemented + const [folderWithPath] = secretSync.folderId + ? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]) + : []; + return expandSecretSync(secretSync, folderWithPath); + } catch (error) { + throw new DatabaseError({ error, name: "Update by ID - Secret Sync" }); + } + }; + + const findOne = async (filter: Parameters<(typeof secretSyncOrm)["findOne"]>[0], tx?: Knex) => { + try { + const secretSync = await baseSecretSyncQuery({ filter, db, tx }).first(); + + if (secretSync) { + // TODO (scott): replace with cached folder path once implemented + const [folderWithPath] = secretSync.folderId + ? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]) + : []; + return expandSecretSync(secretSync, folderWithPath); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find One - Secret Sync" }); + } + }; + + const find = async (filter: Parameters<(typeof secretSyncOrm)["find"]>[0], tx?: Knex) => { + try { + const secretSyncs = await baseSecretSyncQuery({ filter, db, tx }); + + if (!secretSyncs.length) return []; + + const foldersWithPath = await folderDAL.findSecretPathByFolderIds( + secretSyncs[0].projectId, + secretSyncs.filter((sync) => Boolean(sync.folderId)).map((sync) => sync.folderId!) + ); + + // TODO (scott): replace with cached folder path once implemented + const folderRecord: Record<string, (typeof foldersWithPath)[number]> = {}; + + foldersWithPath.forEach((folder) => { + if (folder) folderRecord[folder.id] = folder; + }); + + return secretSyncs.map((secretSync) => + expandSecretSync(secretSync, secretSync.folderId ? folderRecord[secretSync.folderId] : undefined) + ); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Secret Sync" }); + } + }; + + return { ...secretSyncOrm, findById, findOne, find, create, updateById }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts new file mode 100644 index 0000000000..406a3a1615 --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -0,0 +1,15 @@ +export enum SecretSync { + AWSParameterStore = "aws-parameter-store", + GitHub = "github" +} + +export enum SecretSyncInitialSyncBehavior { + OverwriteDestination = "overwrite-destination", + ImportPrioritizeSource = "import-prioritize-source", + ImportPrioritizeDestination = "import-prioritize-destination" +} + +export enum SecretSyncImportBehavior { + PrioritizeSource = "prioritize-source", + PrioritizeDestination = "prioritize-destination" +} diff --git a/backend/src/services/secret-sync/secret-sync-errors.ts b/backend/src/services/secret-sync/secret-sync-errors.ts new file mode 100644 index 0000000000..859fbb00dc --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-errors.ts @@ -0,0 +1,23 @@ +export class SecretSyncError extends Error { + name: string; + + error?: unknown; + + secretKey?: string; + + shouldRetry?: boolean; + + constructor({ + name, + error, + secretKey, + message, + shouldRetry = true + }: { name?: string; error?: unknown; secretKey?: string; shouldRetry?: boolean; message?: string } = {}) { + super(message); + this.name = name || "SecretSyncError"; + this.error = error; + this.secretKey = secretKey; + this.shouldRetry = shouldRetry; + } +} diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts new file mode 100644 index 0000000000..de39fef02f --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -0,0 +1,127 @@ +import { AxiosError } from "axios"; + +import { + AWS_PARAMETER_STORE_SYNC_LIST_OPTION, + AwsParameterStoreSyncFns +} from "@app/services/secret-sync/aws-parameter-store"; +import { GITHUB_SYNC_LIST_OPTION, GithubSyncFns } from "@app/services/secret-sync/github"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { + TSecretMap, + TSecretSyncListItem, + TSecretSyncWithCredentials +} from "@app/services/secret-sync/secret-sync-types"; + +const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = { + [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, + [SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION +}; + +export const listSecretSyncOptions = () => { + return Object.values(SECRET_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +// const addAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => { +// let secretMap = { ...unprocessedSecretMap }; +// +// const { appendSuffix, prependPrefix } = secretSync.syncOptions; +// +// if (appendSuffix || prependPrefix) { +// secretMap = {}; +// Object.entries(unprocessedSecretMap).forEach(([key, value]) => { +// secretMap[`${prependPrefix || ""}${key}${appendSuffix || ""}`] = value; +// }); +// } +// +// return secretMap; +// }; +// +// const stripAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretMap: TSecretMap) => { +// let secretMap = { ...unprocessedSecretMap }; +// +// const { appendSuffix, prependPrefix } = secretSync.syncOptions; +// +// if (appendSuffix || prependPrefix) { +// secretMap = {}; +// Object.entries(unprocessedSecretMap).forEach(([key, value]) => { +// let processedKey = key; +// +// if (prependPrefix && processedKey.startsWith(prependPrefix)) { +// processedKey = processedKey.slice(prependPrefix.length); +// } +// +// if (appendSuffix && processedKey.endsWith(appendSuffix)) { +// processedKey = processedKey.slice(0, -appendSuffix.length); +// } +// +// secretMap[processedKey] = value; +// }); +// } +// +// return secretMap; +// }; + +export const SecretSyncFns = { + syncSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => { + // const affixedSecretMap = addAffixes(secretSync, secretMap); + + switch (secretSync.destination) { + case SecretSync.AWSParameterStore: + return AwsParameterStoreSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.GitHub: + return GithubSyncFns.syncSecrets(secretSync, secretMap); + default: + throw new Error( + `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` + ); + } + }, + getSecrets: async (secretSync: TSecretSyncWithCredentials): Promise<TSecretMap> => { + let secretMap: TSecretMap; + switch (secretSync.destination) { + case SecretSync.AWSParameterStore: + secretMap = await AwsParameterStoreSyncFns.getSecrets(secretSync); + break; + case SecretSync.GitHub: + secretMap = await GithubSyncFns.getSecrets(secretSync); + break; + default: + throw new Error( + `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` + ); + } + + return secretMap; + // return stripAffixes(secretSync, secretMap); + }, + removeSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise<void> => { + // const affixedSecretMap = addAffixes(secretSync, secretMap); + + switch (secretSync.destination) { + case SecretSync.AWSParameterStore: + return AwsParameterStoreSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.GitHub: + return GithubSyncFns.removeSecrets(secretSync, secretMap); + default: + throw new Error( + `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` + ); + } + } +}; + +export const parseSyncErrorMessage = (err: unknown): string => { + if (err instanceof SecretSyncError) { + return JSON.stringify({ + secretKey: err.secretKey, + error: err.message ?? parseSyncErrorMessage(err.error) + }); + } + + if (err instanceof AxiosError) { + return err?.response?.data ? JSON.stringify(err?.response?.data) : err?.message ?? "An unknown error occurred."; + } + + return (err as Error)?.message || "An unknown error occurred."; +}; diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts new file mode 100644 index 0000000000..67ba7b6903 --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = { + [SecretSync.AWSParameterStore]: "AWS Parameter Store", + [SecretSync.GitHub]: "GitHub" +}; + +export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = { + [SecretSync.AWSParameterStore]: AppConnection.AWS, + [SecretSync.GitHub]: AppConnection.GitHub +}; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts new file mode 100644 index 0000000000..d2bcdb590a --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -0,0 +1,955 @@ +import opentelemetry from "@opentelemetry/api"; +import { AxiosError } from "axios"; +import { Job } from "bullmq"; + +import { ProjectMembershipRole, SecretType } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@app/services/secret/secret-fns"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; +import { fnSecretsV2FromImports } from "@app/services/secret-import/secret-import-fns"; +import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; +import { + SecretSync, + SecretSyncImportBehavior, + SecretSyncInitialSyncBehavior +} from "@app/services/secret-sync/secret-sync-enums"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; +import { + SecretSyncAction, + SecretSyncStatus, + TQueueSecretSyncImportSecretsByIdDTO, + TQueueSecretSyncRemoveSecretsByIdDTO, + TQueueSecretSyncsByPathDTO, + TQueueSecretSyncSyncSecretsByIdDTO, + TQueueSendSecretSyncActionFailedNotificationsDTO, + TSecretMap, + TSecretSyncImportSecretsDTO, + TSecretSyncRaw, + TSecretSyncRemoveSecretsDTO, + TSecretSyncSyncSecretsDTO, + TSecretSyncWithCredentials, + TSendSecretSyncFailedNotificationsJobDTO +} from "@app/services/secret-sync/secret-sync-types"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; +import { expandSecretReferencesFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; +import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>; + +type TSecretSyncQueueFactoryDep = { + queueService: Pick<TQueueServiceFactory, "queue" | "start">; + kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; + keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">; + folderDAL: TSecretFolderDALFactory; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + | "findByFolderId" + | "find" + | "insertMany" + | "upsertSecretReferences" + | "findBySecretKeys" + | "bulkUpdate" + | "deleteMany" + >; + secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; + secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">; + auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; + projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">; + projectDAL: TProjectDALFactory; + smtpService: Pick<TSmtpService, "sendMail">; + projectBotDAL: TProjectBotDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">; + secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">; + resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">; +}; + +type SecretSyncActionJob = Job< + TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO +>; + +const getRequeueDelay = (failureCount?: number) => { + if (!failureCount) return 0; + + const baseDelay = 1000; + const maxDelay = 30000; + + const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay); + + const jitter = delay * (0.5 + Math.random() * 0.5); + + return jitter; +}; + +export const secretSyncQueueFactory = ({ + queueService, + kmsService, + keyStore, + folderDAL, + secretV2BridgeDAL, + secretImportDAL, + secretSyncDAL, + auditLogService, + projectMembershipDAL, + projectDAL, + smtpService, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + secretVersionV2BridgeDAL, + secretVersionTagV2BridgeDAL, + resourceMetadataDAL +}: TSecretSyncQueueFactoryDep) => { + const appCfg = getConfig(); + + const integrationMeter = opentelemetry.metrics.getMeter("SecretSyncs"); + const syncSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_sync_secrets_errors", { + description: "Secret Sync - sync secrets errors", + unit: "1" + }); + const importSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_import_secrets_errors", { + description: "Secret Sync - import secrets errors", + unit: "1" + }); + const removeSecretsErrorHistogram = integrationMeter.createHistogram("secret_sync_remove_secrets_errors", { + description: "Secret Sync - remove secrets errors", + unit: "1" + }); + + const $createManySecretsRawFn = createManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + kmsService, + secretVersionV2BridgeDAL, + secretV2BridgeDAL, + secretVersionTagV2BridgeDAL, + resourceMetadataDAL + }); + + const $updateManySecretsRawFn = updateManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + kmsService, + secretVersionV2BridgeDAL, + secretV2BridgeDAL, + secretVersionTagV2BridgeDAL, + resourceMetadataDAL + }); + + const $getInfisicalSecrets = async ( + secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, + includeImports = true + ) => { + const { projectId, folderId, environment, folder } = secretSync; + + if (!folderId || !environment || !folder) + throw new SecretSyncError({ + message: + "Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path.", + shouldRetry: false + }); + + const secretMap: TSecretMap = {}; + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptSecretValue = (value?: Buffer | undefined | null) => + value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""; + + const { expandSecretReferences } = expandSecretReferencesFactory({ + decryptSecretValue, + secretDAL: secretV2BridgeDAL, + folderDAL, + projectId, + canExpandValue: () => true + }); + + const secrets = await secretV2BridgeDAL.findByFolderId(folderId); + + await Promise.allSettled( + secrets.map(async (secret) => { + const secretKey = secret.key; + const secretValue = decryptSecretValue(secret.encryptedValue); + const expandedSecretValue = await expandSecretReferences({ + environment: environment.slug, + secretPath: folder.path, + skipMultilineEncoding: secret.skipMultilineEncoding, + value: secretValue + }); + secretMap[secretKey] = { value: expandedSecretValue || "" }; + + if (secret.encryptedComment) { + const commentValue = decryptSecretValue(secret.encryptedComment); + secretMap[secretKey].comment = commentValue; + } + + secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); + }) + ); + + if (!includeImports) return secretMap; + + const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); + + if (secretImports.length) { + const importedSecrets = await fnSecretsV2FromImports({ + decryptor: decryptSecretValue, + folderDAL, + secretDAL: secretV2BridgeDAL, + expandSecretReferences, + secretImportDAL, + secretImports, + hasSecretAccess: () => true + }); + + for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { + for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { + const importedSecret = importedSecrets[i].secrets[j]; + if (!secretMap[importedSecret.key]) { + secretMap[importedSecret.key] = { + skipMultilineEncoding: importedSecret.skipMultilineEncoding, + comment: importedSecret.secretComment, + value: importedSecret.secretValue || "" + }; + } + } + } + } + + return secretMap; + }; + + const queueSecretSyncSyncSecretsById = async (payload: TQueueSecretSyncSyncSecretsByIdDTO) => + queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncSyncSecrets, payload, { + delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queueSecretSyncImportSecretsById = async (payload: TQueueSecretSyncImportSecretsByIdDTO) => + queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncImportSecrets, payload, { + attempts: 1, + removeOnComplete: true, + removeOnFail: true + }); + + const queueSecretSyncRemoveSecretsById = async (payload: TQueueSecretSyncRemoveSecretsByIdDTO) => + queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncRemoveSecrets, payload, { + attempts: 1, + removeOnComplete: true, + removeOnFail: true + }); + + const $queueSendSecretSyncFailedNotifications = async (payload: TQueueSendSecretSyncActionFailedNotificationsDTO) => { + if (!appCfg.isSmtpConfigured) return; + + await queueService.queue( + QueueName.AppConnectionSecretSync, + QueueJobs.SecretSyncSendActionFailedNotifications, + payload, + { + jobId: `secret-sync-${payload.secretSync.id}-failed-notifications`, + attempts: 5, + delay: 1000 * 60, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnFail: true, + removeOnComplete: true + } + ); + }; + + const $importSecrets = async ( + secretSync: TSecretSyncWithCredentials, + importBehavior: SecretSyncImportBehavior + ): Promise<TSecretMap> => { + const { projectId, environment, folder } = secretSync; + + if (!environment || !folder) + throw new Error( + "Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path." + ); + + const importedSecrets = await SecretSyncFns.getSecrets(secretSync); + + if (!Object.keys(importedSecrets).length) return {}; + + const importedSecretMap: TSecretMap = {}; + + const secretMap = await $getInfisicalSecrets(secretSync, false); + + const secretsToCreate: Parameters<typeof $createManySecretsRawFn>[0]["secrets"] = []; + const secretsToUpdate: Parameters<typeof $updateManySecretsRawFn>[0]["secrets"] = []; + + Object.entries(importedSecrets).forEach(([key, secretData]) => { + const { value, comment = "", skipMultilineEncoding } = secretData; + + const secret = { + secretName: key, + secretValue: value, + type: SecretType.Shared, + secretComment: comment, + skipMultilineEncoding: skipMultilineEncoding ?? undefined + }; + + if (Object.hasOwn(secretMap, key)) { + secretsToUpdate.push(secret); + if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData; + } else { + secretsToCreate.push(secret); + importedSecretMap[key] = secretData; + } + }); + + if (secretsToCreate.length) { + await $createManySecretsRawFn({ + projectId, + path: folder.path, + environment: environment.slug, + secrets: secretsToCreate + }); + } + + if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination && secretsToUpdate.length) { + await $updateManySecretsRawFn({ + projectId, + path: folder.path, + environment: environment.slug, + secrets: secretsToUpdate + }); + } + + return importedSecretMap; + }; + + const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => { + const { + data: { syncId, auditLogInfo } + } = job; + + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + + await secretSyncDAL.updateById(syncId, { + syncStatus: SecretSyncStatus.Running + }); + + logger.info( + `SecretSync Sync [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]` + ); + + let isSynced = false; + let syncMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = secretSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + const secretSyncWithCredentials = { + ...secretSync, + connection: { + ...secretSync.connection, + credentials + } + } as TSecretSyncWithCredentials; + + const { + lastSyncedAt, + syncOptions: { initialSyncBehavior } + } = secretSyncWithCredentials; + + const secretMap = await $getInfisicalSecrets(secretSync); + + if (!lastSyncedAt && initialSyncBehavior !== SecretSyncInitialSyncBehavior.OverwriteDestination) { + const importedSecretMap = await $importSecrets( + secretSyncWithCredentials, + initialSyncBehavior === SecretSyncInitialSyncBehavior.ImportPrioritizeSource + ? SecretSyncImportBehavior.PrioritizeSource + : SecretSyncImportBehavior.PrioritizeDestination + ); + + Object.entries(importedSecretMap).forEach(([key, secretData]) => { + secretMap[key] = secretData; + }); + } + + await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap); + + isSynced = true; + } catch (err) { + logger.error( + err, + `SecretSync Sync Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + syncSecretsErrorHistogram.record(1, { + version: 1, + destination: secretSync.destination, + syncId: secretSync.id, + projectId: secretSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + syncMessage = parseSyncErrorMessage(err); + + if (err instanceof SecretSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const syncStatus = isSynced ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: secretSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.SECRET_SYNC_SYNC_SECRETS, + metadata: { + syncId: secretSync.id, + syncOptions: secretSync.syncOptions, + destination: secretSync.destination, + destinationConfig: secretSync.destinationConfig, + folderId: secretSync.folderId, + connectionId: secretSync.connectionId, + jobRanAt: ranAt, + jobId: job.id!, + syncStatus, + syncMessage + } + } + }); + + if (isSynced || isFinalAttempt) { + const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, { + syncStatus, + lastSyncJobId: job.id, + lastSyncMessage: syncMessage, + lastSyncedAt: isSynced ? ranAt : undefined + }); + + if (!isSynced) { + await $queueSendSecretSyncFailedNotifications({ + secretSync: updatedSecretSync, + action: SecretSyncAction.SyncSecrets, + auditLogInfo + }); + } + } + } + + logger.info("SecretSync Sync Job with ID %s Completed", job.id); + }; + + const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => { + const { + data: { syncId, auditLogInfo, importBehavior } + } = job; + + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + + await secretSyncDAL.updateById(syncId, { + importStatus: SecretSyncStatus.Running + }); + + logger.info( + `SecretSync Import [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]` + ); + + let isSuccess = false; + let importMessage: string | null = null; + const isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = secretSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + await $importSecrets( + { + ...secretSync, + connection: { + ...secretSync.connection, + credentials + } + } as TSecretSyncWithCredentials, + importBehavior + ); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `SecretSync Import Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + importSecretsErrorHistogram.record(1, { + version: 1, + destination: secretSync.destination, + syncId: secretSync.id, + projectId: secretSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + importMessage = parseSyncErrorMessage(err); + + // re-throw so job fails + throw err; + } finally { + const ranAt = new Date(); + const importStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: secretSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.SECRET_SYNC_IMPORT_SECRETS, + metadata: { + syncId: secretSync.id, + syncOptions: secretSync.syncOptions, + destination: secretSync.destination, + destinationConfig: secretSync.destinationConfig, + folderId: secretSync.folderId, + connectionId: secretSync.connectionId, + jobRanAt: ranAt, + jobId: job.id!, + importStatus, + importMessage, + importBehavior + } + } + }); + + if (isSuccess || isFinalAttempt) { + const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, { + importStatus, + lastImportJobId: job.id, + lastImportMessage: importMessage, + lastImportedAt: isSuccess ? ranAt : undefined + }); + + if (!isSuccess) { + await $queueSendSecretSyncFailedNotifications({ + secretSync: updatedSecretSync, + action: SecretSyncAction.ImportSecrets, + auditLogInfo + }); + } + } + } + + logger.info("SecretSync Import Job with ID %s Completed", job.id); + }; + + const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { + const { + data: { syncId, auditLogInfo, deleteSyncOnComplete } + } = job; + + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + + await secretSyncDAL.updateById(syncId, { + removeStatus: SecretSyncStatus.Running + }); + + logger.info( + `SecretSync Remove [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]` + ); + + let isSuccess = false; + let removeMessage: string | null = null; + const isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = secretSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + const secretMap = await $getInfisicalSecrets(secretSync); + + await SecretSyncFns.removeSecrets( + { + ...secretSync, + connection: { + ...secretSync.connection, + credentials + } + } as TSecretSyncWithCredentials, + secretMap + ); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `SecretSync Remove Error [syncId=${secretSync.id}] [destination=${secretSync.destination}] [projectId=${secretSync.projectId}] [folderId=${secretSync.folderId}] [connectionId=${secretSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + removeSecretsErrorHistogram.record(1, { + version: 1, + destination: secretSync.destination, + syncId: secretSync.id, + projectId: secretSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + removeMessage = parseSyncErrorMessage(err); + + // re-throw so job fails + throw err; + } finally { + const ranAt = new Date(); + const removeStatus = isSuccess ? SecretSyncStatus.Succeeded : SecretSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: secretSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.SECRET_SYNC_REMOVE_SECRETS, + metadata: { + syncId: secretSync.id, + syncOptions: secretSync.syncOptions, + destination: secretSync.destination, + destinationConfig: secretSync.destinationConfig, + folderId: secretSync.folderId, + connectionId: secretSync.connectionId, + jobRanAt: ranAt, + jobId: job.id!, + removeStatus, + removeMessage + } + } + }); + + if (isSuccess || isFinalAttempt) { + if (isSuccess && deleteSyncOnComplete) { + await secretSyncDAL.deleteById(secretSync.id); + } else { + const updatedSecretSync = await secretSyncDAL.updateById(secretSync.id, { + removeStatus, + lastRemoveJobId: job.id, + lastRemoveMessage: removeMessage, + lastRemovedAt: isSuccess ? ranAt : undefined + }); + + if (!isSuccess) { + await $queueSendSecretSyncFailedNotifications({ + secretSync: updatedSecretSync, + action: SecretSyncAction.RemoveSecrets, + auditLogInfo + }); + } + } + } + } + + logger.info("SecretSync Remove Job with ID %s Completed", job.id); + }; + + const $sendSecretSyncFailedNotifications = async (job: TSendSecretSyncFailedNotificationsJobDTO) => { + const { + data: { secretSync, auditLogInfo, action } + } = job; + + const { projectId, destination, name, folder, lastSyncMessage, lastRemoveMessage, lastImportMessage, environment } = + secretSync; + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + let projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const triggeredByUserId = + auditLogInfo && auditLogInfo.actor.type === ActorType.USER && auditLogInfo.actor.metadata.userId; + + // only notify triggering user if triggered by admin + if (triggeredByUserId && projectAdmins.map((admin) => admin.userId).includes(triggeredByUserId)) { + projectAdmins = projectAdmins.filter((admin) => admin.userId === triggeredByUserId); + } + + const syncDestination = SECRET_SYNC_NAME_MAP[destination as SecretSync]; + + let actionLabel: string; + let failureMessage: string | null | undefined; + + switch (action) { + case SecretSyncAction.ImportSecrets: + actionLabel = "Import"; + failureMessage = lastImportMessage; + + break; + case SecretSyncAction.RemoveSecrets: + actionLabel = "Remove"; + failureMessage = lastRemoveMessage; + + break; + case SecretSyncAction.SyncSecrets: + default: + actionLabel = `Sync`; + failureMessage = lastSyncMessage; + break; + } + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.SecretSyncFailed, + subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`, + substitutions: { + syncName: name, + syncDestination, + content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`, + failureMessage, + secretPath: folder?.path, + environment: environment?.name, + projectName: project.name, + syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}` + } + }); + }; + + const queueSecretSyncsSyncSecretsByPath = async ({ + secretPath, + projectId, + environmentSlug + }: TQueueSecretSyncsByPathDTO) => { + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath); + + if (!folder) + throw new Error( + `Could not find folder at path "${secretPath}" for environment with slug "${environmentSlug}" in project with ID "${projectId}"` + ); + + const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true }); + + await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id }))); + }; + + const $handleAcquireLockFailure = async (job: SecretSyncActionJob) => { + const { syncId, auditLogInfo } = job.data; + + switch (job.name) { + case QueueJobs.SecretSyncSyncSecrets: { + const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO; + + if (failedToAcquireLockCount < 10) { + await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); + return; + } + + const secretSync = await secretSyncDAL.updateById(syncId, { + syncStatus: SecretSyncStatus.Failed, + lastSyncMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastSyncJobId: job.id + }); + + await $queueSendSecretSyncFailedNotifications({ + secretSync, + action: SecretSyncAction.SyncSecrets, + auditLogInfo + }); + + break; + } + // Scott: the two cases below are unlikely to happen as we check the lock at the API level but including this as a fallback + case QueueJobs.SecretSyncImportSecrets: { + const secretSync = await secretSyncDAL.updateById(syncId, { + importStatus: SecretSyncStatus.Failed, + lastImportMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastImportJobId: job.id + }); + + await $queueSendSecretSyncFailedNotifications({ + secretSync, + action: SecretSyncAction.ImportSecrets, + auditLogInfo + }); + + break; + } + case QueueJobs.SecretSyncRemoveSecrets: { + const secretSync = await secretSyncDAL.updateById(syncId, { + removeStatus: SecretSyncStatus.Failed, + lastRemoveMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastRemoveJobId: job.id + }); + + await $queueSendSecretSyncFailedNotifications({ + secretSync, + action: SecretSyncAction.RemoveSecrets, + auditLogInfo + }); + + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled Secret Sync Job ${job.name}`); + } + }; + + queueService.start(QueueName.AppConnectionSecretSync, async (job) => { + if (job.name === QueueJobs.SecretSyncSendActionFailedNotifications) { + await $sendSecretSyncFailedNotifications(job as TSendSecretSyncFailedNotificationsJobDTO); + return; + } + + const { syncId } = job.data as + | TQueueSecretSyncSyncSecretsByIdDTO + | TQueueSecretSyncImportSecretsByIdDTO + | TQueueSecretSyncRemoveSecretsByIdDTO; + + let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>; + + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.SecretSyncLock(syncId)], + // scott: not sure on this duration; syncs can take excessive amounts of time so we need to keep it locked, + // but should always release below... + 5 * 60 * 1000 + ); + } catch (e) { + logger.info(`SecretSync Failed to acquire lock [syncId=${syncId}] [job=${job.name}]`); + + await $handleAcquireLockFailure(job as SecretSyncActionJob); + + return; + } + + try { + switch (job.name) { + case QueueJobs.SecretSyncSyncSecrets: + await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO); + break; + case QueueJobs.SecretSyncImportSecrets: + await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO); + break; + case QueueJobs.SecretSyncRemoveSecrets: + await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO); + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled Secret Sync Job ${job.name}`); + } + } finally { + await lock.release(); + } + }); + + return { + queueSecretSyncSyncSecretsById, + queueSecretSyncImportSecretsById, + queueSecretSyncRemoveSecretsById, + queueSecretSyncsSyncSecretsByPath + }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-schemas.ts b/backend/src/services/secret-sync/secret-sync-schemas.ts new file mode 100644 index 0000000000..9821c4e1d5 --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-schemas.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs"; +import { SecretSyncs } from "@app/lib/api-docs"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { slugSchema } from "@app/server/lib/schemas"; +import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums"; +import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) => + z.object({ + initialSyncBehavior: (options.canImportSecrets + ? z.nativeEnum(SecretSyncInitialSyncBehavior) + : z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination) + ).describe(SecretSyncs.SYNC_OPTIONS(secretSync).INITIAL_SYNC_BEHAVIOR) + // prependPrefix: z + // .string() + // .trim() + // .transform((str) => str.toUpperCase()) + // .optional() + // .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX), + // appendSuffix: z + // .string() + // .trim() + // .transform((str) => str.toUpperCase()) + // .optional() + // .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX) + }); + +export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) => + SecretSyncsSchema.omit({ + destination: true, + destinationConfig: true, + syncOptions: true + }).extend({ + // destination needs to be on the extended object for type differentiation + syncOptions: SyncOptionsSchema(destination, syncOptionsConfig), + // join properties + projectId: z.string(), + connection: z.object({ + app: z.literal(SECRET_SYNC_CONNECTION_MAP[destination]), + name: z.string(), + id: z.string().uuid() + }), + environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }).nullable(), + folder: z.object({ id: z.string(), path: z.string() }).nullable() + }); + +export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name), + projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretSyncs.CREATE(destination).description), + connectionId: z.string().uuid().describe(SecretSyncs.CREATE(destination).connectionId), + environment: slugSchema({ field: "environment", max: 64 }).describe(SecretSyncs.CREATE(destination).environment), + secretPath: z + .string() + .trim() + .min(1, "Secret path required") + .transform(removeTrailingSlash) + .describe(SecretSyncs.CREATE(destination).secretPath), + isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled), + syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions) + }); + +export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(), + connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretSyncs.UPDATE(destination).description), + environment: slugSchema({ field: "environment", max: 64 }) + .optional() + .describe(SecretSyncs.UPDATE(destination).environment), + secretPath: z + .string() + .trim() + .min(1, "Invalid secret path") + .transform(removeTrailingSlash) + .optional() + .describe(SecretSyncs.UPDATE(destination).secretPath), + isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled), + syncOptions: SyncOptionsSchema(destination, syncOptionsConfig) + .optional() + .describe(SecretSyncs.UPDATE(destination).syncOptions) + }); diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts new file mode 100644 index 0000000000..4ce2fd2d55 --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -0,0 +1,562 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSecretSyncActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; +import { + SecretSyncStatus, + TCreateSecretSyncDTO, + TDeleteSecretSyncDTO, + TFindSecretSyncByIdDTO, + TFindSecretSyncByNameDTO, + TListSecretSyncsByProjectId, + TSecretSync, + TTriggerSecretSyncImportSecretsByIdDTO, + TTriggerSecretSyncRemoveSecretsByIdDTO, + TTriggerSecretSyncSyncSecretsByIdDTO, + TUpdateSecretSyncDTO +} from "@app/services/secret-sync/secret-sync-types"; + +import { TSecretSyncDALFactory } from "./secret-sync-dal"; +import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps"; +import { TSecretSyncQueueFactory } from "./secret-sync-queue"; + +type TSecretSyncServiceFactoryDep = { + secretSyncDAL: TSecretSyncDALFactory; + appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">; + permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">; + projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">; + folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">; + keyStore: Pick<TKeyStoreFactory, "getItem">; + secretSyncQueue: Pick< + TSecretSyncQueueFactory, + "queueSecretSyncSyncSecretsById" | "queueSecretSyncImportSecretsById" | "queueSecretSyncRemoveSecretsById" + >; +}; + +export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFactory>; + +export const secretSyncServiceFactory = ({ + secretSyncDAL, + folderDAL, + permissionService, + appConnectionService, + projectBotService, + secretSyncQueue, + keyStore +}: TSecretSyncServiceFactoryDep) => { + const listSecretSyncsByProjectId = async ( + { projectId, destination }: TListSecretSyncsByProjectId, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + const secretSyncs = await secretSyncDAL.find({ + ...(destination && { destination }), + projectId + }); + + return secretSyncs as TSecretSync[]; + }; + + const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => { + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + return secretSync as TSecretSync; + }; + + const findSecretSyncByName = async ( + { destination, syncName, projectId }: TFindSecretSyncByNameDTO, + actor: OrgServiceActor + ) => { + const folders = await folderDAL.findByProjectId(projectId); + + // we prevent conflicting names within a project so this will only return one at most + const [secretSync] = await secretSyncDAL.find({ + name: syncName, + $in: { + folderId: folders.map((folder) => folder.id) + } + }); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with name "${syncName}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + return secretSync as TSecretSync; + }; + + const createSecretSync = async ( + { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, + actor: OrgServiceActor + ) => { + const { permission: projectPermission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ message: "Project version does not support Secret Syncs" }); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Create, + ProjectPermissionSub.SecretSyncs + ); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath + }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination]; + + // validates permission to connect and app is valid for sync destination + await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor); + + const secretSync = await secretSyncDAL.transaction(async (tx) => { + const isConflictingName = Boolean( + ( + await secretSyncDAL.find( + { + name: params.name, + projectId + }, + tx + ) + ).length + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `A Secret Sync with the name "${params.name}" already exists for the project with ID "${folder.projectId}"` + }); + + const sync = await secretSyncDAL.create({ + folderId: folder.id, + ...params, + ...(params.isAutoSyncEnabled && { syncStatus: SecretSyncStatus.Pending }), + projectId + }); + + return sync; + }); + + if (secretSync.isAutoSyncEnabled) await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id }); + + return secretSync as TSecretSync; + }; + + const updateSecretSync = async ( + { destination, syncId, secretPath, environment, ...params }: TUpdateSecretSyncDTO, + actor: OrgServiceActor + ) => { + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID ${syncId}` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + const updatedSecretSync = await secretSyncDAL.transaction(async (tx) => { + let { folderId } = secretSync; + + if (params.connectionId) { + const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync]; + + // validates permission to connect and app is valid for sync destination + await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor); + } + + if ( + (secretPath && secretPath !== secretSync.folder?.path) || + (environment && environment !== secretSync.environment?.slug) + ) { + const updatedEnvironment = environment ?? secretSync.environment?.slug; + const updatedSecretPath = secretPath ?? secretSync.folder?.path; + + if (!updatedEnvironment || !updatedSecretPath) + throw new BadRequestError({ message: "Must specify both source environment and secret path" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: updatedEnvironment, + secretPath: updatedSecretPath + }) + ); + + const newFolder = await folderDAL.findBySecretPath(secretSync.projectId, updatedEnvironment, updatedSecretPath); + + if (!newFolder) + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${secretSync.projectId}"` + }); + + folderId = newFolder.id; + } + + if (params.name && secretSync.name !== params.name) { + const isConflictingName = Boolean( + ( + await secretSyncDAL.find( + { + name: params.name, + projectId: secretSync.projectId + }, + tx + ) + ).length + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `A Secret Sync with the name "${params.name}" already exists for project with ID "${secretSync.projectId}"` + }); + } + + const isAutoSyncEnabled = params.isAutoSyncEnabled ?? secretSync.isAutoSyncEnabled; + + const updatedSync = await secretSyncDAL.updateById(syncId, { + ...params, + ...(isAutoSyncEnabled && folderId && { syncStatus: SecretSyncStatus.Pending }), + folderId + }); + + return updatedSync; + }); + + if (updatedSecretSync.isAutoSyncEnabled) + await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId: secretSync.id }); + + return updatedSecretSync as TSecretSync; + }; + + const deleteSecretSync = async ( + { destination, syncId, removeSecrets }: TDeleteSecretSyncDTO, + actor: OrgServiceActor + ) => { + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + if (removeSecrets) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.RemoveSecrets, + ProjectPermissionSub.SecretSyncs + ); + + if (!secretSync.folderId) + throw new BadRequestError({ + message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.` + }); + + const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId))); + + if (isSyncJobRunning) + throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` }); + + await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, deleteSyncOnComplete: true }); + + const updatedSecretSync = await secretSyncDAL.updateById(syncId, { + removeStatus: SecretSyncStatus.Pending + }); + + return updatedSecretSync; + } + + await secretSyncDAL.deleteById(syncId); + + return secretSync as TSecretSync; + }; + + const triggerSecretSyncSyncSecretsById = async ( + { syncId, destination, ...params }: TTriggerSecretSyncSyncSecretsByIdDTO, + actor: OrgServiceActor + ) => { + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + if (!secretSync.folderId) + throw new BadRequestError({ + message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.` + }); + + const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId))); + + if (isSyncJobRunning) + throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` }); + + await secretSyncQueue.queueSecretSyncSyncSecretsById({ syncId, ...params }); + + const updatedSecretSync = await secretSyncDAL.updateById(syncId, { + syncStatus: SecretSyncStatus.Pending + }); + + return updatedSecretSync as TSecretSync; + }; + + const triggerSecretSyncImportSecretsById = async ( + { syncId, destination, ...params }: TTriggerSecretSyncImportSecretsByIdDTO, + actor: OrgServiceActor + ) => { + if (!listSecretSyncOptions().find((option) => option.destination === destination)?.canImportSecrets) { + throw new BadRequestError({ + message: `${SECRET_SYNC_NAME_MAP[destination]} does not support importing secrets.` + }); + } + + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + if (!secretSync.folderId) + throw new BadRequestError({ + message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.` + }); + + const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId))); + + if (isSyncJobRunning) + throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` }); + + await secretSyncQueue.queueSecretSyncImportSecretsById({ syncId, ...params }); + + const updatedSecretSync = await secretSyncDAL.updateById(syncId, { + importStatus: SecretSyncStatus.Pending + }); + + return updatedSecretSync as TSecretSync; + }; + + const triggerSecretSyncRemoveSecretsById = async ( + { syncId, destination, ...params }: TTriggerSecretSyncRemoveSecretsByIdDTO, + actor: OrgServiceActor + ) => { + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) + throw new NotFoundError({ + message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId: secretSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.RemoveSecrets, + ProjectPermissionSub.SecretSyncs + ); + + if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) + throw new BadRequestError({ + message: `Secret sync with ID "${secretSync.id}" is not configured for ${SECRET_SYNC_NAME_MAP[destination]}` + }); + + if (!secretSync.folderId) + throw new BadRequestError({ + message: `Invalid source configuration: folder no longer exists. Please configure a valid source and try again.` + }); + + const isSyncJobRunning = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretSyncLock(syncId))); + + if (isSyncJobRunning) + throw new BadRequestError({ message: `A job for this sync is already in progress. Please try again shortly.` }); + + await secretSyncQueue.queueSecretSyncRemoveSecretsById({ syncId, ...params }); + + const updatedSecretSync = await secretSyncDAL.updateById(syncId, { + removeStatus: SecretSyncStatus.Pending + }); + + return updatedSecretSync as TSecretSync; + }; + + return { + listSecretSyncOptions, + listSecretSyncsByProjectId, + findSecretSyncById, + findSecretSyncByName, + createSecretSync, + updateSecretSync, + deleteSecretSync, + triggerSecretSyncSyncSecretsById, + triggerSecretSyncImportSecretsById, + triggerSecretSyncRemoveSecretsById + }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts new file mode 100644 index 0000000000..eade6671dd --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -0,0 +1,148 @@ +import { Job } from "bullmq"; + +import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { QueueJobs } from "@app/queue"; +import { + TGitHubSync, + TGitHubSyncInput, + TGitHubSyncListItem, + TGitHubSyncWithCredentials +} from "@app/services/secret-sync/github"; +import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; +import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; + +import { + TAwsParameterStoreSync, + TAwsParameterStoreSyncInput, + TAwsParameterStoreSyncListItem, + TAwsParameterStoreSyncWithCredentials +} from "./aws-parameter-store"; + +export type TSecretSync = TAwsParameterStoreSync | TGitHubSync; + +export type TSecretSyncWithCredentials = TAwsParameterStoreSyncWithCredentials | TGitHubSyncWithCredentials; + +export type TSecretSyncInput = TAwsParameterStoreSyncInput | TGitHubSyncInput; + +export type TSecretSyncListItem = TAwsParameterStoreSyncListItem | TGitHubSyncListItem; + +export type TSyncOptionsConfig = { + canImportSecrets: boolean; +}; + +export type TListSecretSyncsByProjectId = { + projectId: string; + destination?: SecretSync; +}; + +export type TFindSecretSyncByIdDTO = { + syncId: string; + destination: SecretSync; +}; + +export type TFindSecretSyncByNameDTO = { + syncName: string; + projectId: string; + destination: SecretSync; +}; + +export type TCreateSecretSyncDTO = Pick<TSecretSync, "syncOptions" | "destinationConfig" | "name" | "connectionId"> & { + destination: SecretSync; + projectId: string; + secretPath: string; + environment: string; + isAutoSyncEnabled?: boolean; +}; + +export type TUpdateSecretSyncDTO = Partial<Omit<TCreateSecretSyncDTO, "projectId">> & { + syncId: string; + destination: SecretSync; +}; + +export type TDeleteSecretSyncDTO = { + destination: SecretSync; + syncId: string; + removeSecrets: boolean; +}; + +type AuditLogInfo = Pick<TCreateAuditLogDTO, "userAgent" | "userAgentType" | "ipAddress" | "actor">; + +export enum SecretSyncStatus { + Pending = "pending", + Running = "running", + Succeeded = "succeeded", + Failed = "failed" +} + +export enum SecretSyncAction { + SyncSecrets = "sync-secrets", + ImportSecrets = "import-secrets", + RemoveSecrets = "remove-secrets" +} + +export type TSecretSyncRaw = NonNullable<Awaited<ReturnType<TSecretSyncDALFactory["findById"]>>>; + +export type TQueueSecretSyncsByPathDTO = { + secretPath: string; + environmentSlug: string; + projectId: string; +}; + +export type TQueueSecretSyncSyncSecretsByIdDTO = { + syncId: string; + failedToAcquireLockCount?: number; + auditLogInfo?: AuditLogInfo; +}; + +export type TTriggerSecretSyncSyncSecretsByIdDTO = { + destination: SecretSync; +} & TQueueSecretSyncSyncSecretsByIdDTO; + +export type TQueueSecretSyncImportSecretsByIdDTO = { + syncId: string; + importBehavior: SecretSyncImportBehavior; + auditLogInfo?: AuditLogInfo; +}; + +export type TTriggerSecretSyncImportSecretsByIdDTO = { + destination: SecretSync; +} & TQueueSecretSyncImportSecretsByIdDTO; + +export type TQueueSecretSyncRemoveSecretsByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; + deleteSyncOnComplete?: boolean; +}; + +export type TTriggerSecretSyncRemoveSecretsByIdDTO = { + destination: SecretSync; +} & TQueueSecretSyncRemoveSecretsByIdDTO; + +export type TQueueSendSecretSyncActionFailedNotificationsDTO = { + secretSync: TSecretSyncRaw; + auditLogInfo?: AuditLogInfo; + action: SecretSyncAction; +}; + +export type TSecretSyncSyncSecretsDTO = Job<TQueueSecretSyncSyncSecretsByIdDTO, void, QueueJobs.SecretSyncSyncSecrets>; +export type TSecretSyncImportSecretsDTO = Job< + TQueueSecretSyncImportSecretsByIdDTO, + void, + QueueJobs.SecretSyncSyncSecrets +>; +export type TSecretSyncRemoveSecretsDTO = Job< + TQueueSecretSyncRemoveSecretsByIdDTO, + void, + QueueJobs.SecretSyncSyncSecrets +>; + +export type TSendSecretSyncFailedNotificationsJobDTO = Job< + TQueueSendSecretSyncActionFailedNotificationsDTO, + void, + QueueJobs.SecretSyncSendActionFailedNotifications +>; + +export type TSecretMap = Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined } +>; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 661da23628..dc973c0b1f 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -29,6 +29,7 @@ import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@a import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-queue"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { ActorType } from "../auth/auth-type"; @@ -107,6 +108,7 @@ type TSecretQueueFactoryDep = { orgService: Pick<TOrgServiceFactory, "addGhostUser">; projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">; resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">; + secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">; }; export type TGetSecrets = { @@ -166,7 +168,8 @@ export const secretQueueFactory = ({ orgService, projectUserMembershipRoleDAL, projectKeyDAL, - resourceMetadataDAL + resourceMetadataDAL, + secretSyncQueue }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -633,6 +636,9 @@ export const secretQueueFactory = ({ } } ); + + await secretSyncQueue.queueSecretSyncsSyncSecretsByPath({ projectId, environmentSlug: environment, secretPath }); + await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false }); if (!excludeReplication) { await replicateSecrets({ diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index a2ed85749d..d997d52f41 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -35,6 +35,7 @@ export enum SmtpTemplates { ScimUserProvisioned = "scimUserProvisioned.handlebars", PkiExpirationAlert = "pkiExpirationAlert.handlebars", IntegrationSyncFailed = "integrationSyncFailed.handlebars", + SecretSyncFailed = "secretSyncFailed.handlebars", ExternalImportSuccessful = "externalImportSuccessful.handlebars", ExternalImportFailed = "externalImportFailed.handlebars", ExternalImportStarted = "externalImportStarted.handlebars" diff --git a/backend/src/services/smtp/templates/secretSyncFailed.handlebars b/backend/src/services/smtp/templates/secretSyncFailed.handlebars new file mode 100644 index 0000000000..3e7ad7831d --- /dev/null +++ b/backend/src/services/smtp/templates/secretSyncFailed.handlebars @@ -0,0 +1,39 @@ +<html> + + <head> + <meta charset="utf-8" /> + <meta http-equiv="x-ua-compatible" content="ie=edge" /> + <title>{{syncDestination}} Sync "{{syncName}}" Failed</title> + </head> + + <body> + <h2>Infisical</h2> + + <div> + <p>{{content}}</p> + <a href="{{syncUrl}}"> + View in Infisical. + </a> + </div> + + <br /> + <div> + <p><strong>Name</strong>: {{syncName}}</p> + <p><strong>Destination</strong>: {{syncDestination}}</p> + <p><strong>Project</strong>: {{projectName}}</p> + {{#if environment}} + <p><strong>Environment</strong>: {{environment}}</p> + {{/if}} + {{#if secretPath}} + <p><strong>Secret Path</strong>: {{secretPath}}</p> + {{/if}} + </div> + + {{#if failureMessage}} + <p><b>Reason: </b>{{failureMessage}}</p> + {{/if}} + + {{emailFooter}} + </body> + +</html> \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/aws/available.mdx b/docs/api-reference/endpoints/app-connections/aws/available.mdx new file mode 100644 index 0000000000..1386c068d4 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/aws/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx index d18994f7c7..d6db40adeb 100644 --- a/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx @@ -1,4 +1,4 @@ --- title: "Get by Name" -openapi: "GET /api/v1/app-connections/aws/name/{connectionName}" +openapi: "GET /api/v1/app-connections/aws/connection-name/{connectionName}" --- diff --git a/docs/api-reference/endpoints/app-connections/github/available.mdx b/docs/api-reference/endpoints/app-connections/github/available.mdx new file mode 100644 index 0000000000..6d5596629f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/github/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx index 95ddbd6e95..cf959827bc 100644 --- a/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx +++ b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx @@ -1,4 +1,4 @@ --- title: "Get by Name" -openapi: "GET /api/v1/app-connections/github/name/{connectionName}" +openapi: "GET /api/v1/app-connections/github/connection-name/{connectionName}" --- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/create.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/create.mdx new file mode 100644 index 0000000000..2e29b8a5a3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/aws-parameter-store" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/delete.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/delete.mdx new file mode 100644 index 0000000000..2c801aba3c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/aws-parameter-store/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-id.mdx new file mode 100644 index 0000000000..aeecf16e17 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/aws-parameter-store/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-name.mdx new file mode 100644 index 0000000000..67930be3cd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/aws-parameter-store/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/import-secrets.mdx new file mode 100644 index 0000000000..217fd849c3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/list.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/list.mdx new file mode 100644 index 0000000000..8a0c2281da --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/aws-parameter-store" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/remove-secrets.mdx new file mode 100644 index 0000000000..bc617b40d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/sync-secrets.mdx new file mode 100644 index 0000000000..12b7230542 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/aws-parameter-store/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/update.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/update.mdx new file mode 100644 index 0000000000..b290ddfa4f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/aws-parameter-store/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/create.mdx b/docs/api-reference/endpoints/secret-syncs/github/create.mdx new file mode 100644 index 0000000000..d0260b8eaf --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/github" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/delete.mdx b/docs/api-reference/endpoints/secret-syncs/github/delete.mdx new file mode 100644 index 0000000000..409a65cdab --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/github/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/github/get-by-id.mdx new file mode 100644 index 0000000000..d3c6da8481 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/github/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/github/get-by-name.mdx new file mode 100644 index 0000000000..b4c17b4d81 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/github/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/list.mdx b/docs/api-reference/endpoints/secret-syncs/github/list.mdx new file mode 100644 index 0000000000..c3c0e10ab1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/github" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/github/remove-secrets.mdx new file mode 100644 index 0000000000..1c133da8ce --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/github/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/github/sync-secrets.mdx new file mode 100644 index 0000000000..e1bcf1045e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/github/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/github/update.mdx b/docs/api-reference/endpoints/secret-syncs/github/update.mdx new file mode 100644 index 0000000000..62d30327e2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/github/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/github/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/list.mdx b/docs/api-reference/endpoints/secret-syncs/list.mdx new file mode 100644 index 0000000000..d18b47f9a3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/options.mdx b/docs/api-reference/endpoints/secret-syncs/options.mdx new file mode 100644 index 0000000000..cc485111bd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v1/secret-syncs/options" +--- diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-created.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-created.png new file mode 100644 index 0000000000..009331fe6f Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-created.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-destination.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-destination.png new file mode 100644 index 0000000000..d7136cd9af Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-destination.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-details.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-details.png new file mode 100644 index 0000000000..2d4b59a3f4 Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-details.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png new file mode 100644 index 0000000000..11923e5a29 Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-review.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-review.png new file mode 100644 index 0000000000..0db843edef Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-review.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-source.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-source.png new file mode 100644 index 0000000000..4a5ec9904f Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-source.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/select-aws-parameter-store-option.png b/docs/images/secret-syncs/aws-parameter-store/select-aws-parameter-store-option.png new file mode 100644 index 0000000000..d43a797153 Binary files /dev/null and b/docs/images/secret-syncs/aws-parameter-store/select-aws-parameter-store-option.png differ diff --git a/docs/images/secret-syncs/general/secret-sync-tab.png b/docs/images/secret-syncs/general/secret-sync-tab.png new file mode 100644 index 0000000000..dad8c2426f Binary files /dev/null and b/docs/images/secret-syncs/general/secret-sync-tab.png differ diff --git a/docs/images/secret-syncs/github/github-created.png b/docs/images/secret-syncs/github/github-created.png new file mode 100644 index 0000000000..f3ab7241bd Binary files /dev/null and b/docs/images/secret-syncs/github/github-created.png differ diff --git a/docs/images/secret-syncs/github/github-destination.png b/docs/images/secret-syncs/github/github-destination.png new file mode 100644 index 0000000000..3713932bb0 Binary files /dev/null and b/docs/images/secret-syncs/github/github-destination.png differ diff --git a/docs/images/secret-syncs/github/github-details.png b/docs/images/secret-syncs/github/github-details.png new file mode 100644 index 0000000000..a8cbbbb948 Binary files /dev/null and b/docs/images/secret-syncs/github/github-details.png differ diff --git a/docs/images/secret-syncs/github/github-options.png b/docs/images/secret-syncs/github/github-options.png new file mode 100644 index 0000000000..8f2e3a4baa Binary files /dev/null and b/docs/images/secret-syncs/github/github-options.png differ diff --git a/docs/images/secret-syncs/github/github-review.png b/docs/images/secret-syncs/github/github-review.png new file mode 100644 index 0000000000..4cbed76b65 Binary files /dev/null and b/docs/images/secret-syncs/github/github-review.png differ diff --git a/docs/images/secret-syncs/github/github-source.png b/docs/images/secret-syncs/github/github-source.png new file mode 100644 index 0000000000..dd4bc3ec8b Binary files /dev/null and b/docs/images/secret-syncs/github/github-source.png differ diff --git a/docs/images/secret-syncs/github/select-github-option.png b/docs/images/secret-syncs/github/select-github-option.png new file mode 100644 index 0000000000..ebee947b4f Binary files /dev/null and b/docs/images/secret-syncs/github/select-github-option.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 65af1bcdc2..1768477645 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -67,7 +67,7 @@ Infisical supports two methods for connecting to AWS. Depending on your use case, add one or more of the following policies to your IAM Role: <Tabs> - <Tab title="Secrets Sync"> + <Tab title="Secret Sync"> <AccordionGroup> <Accordion title="AWS Secrets Manager"> Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: @@ -217,7 +217,7 @@ Infisical supports two methods for connecting to AWS. Depending on your use case, add one or more of the following policies to your IAM Role: <Tabs> - <Tab title="Secrets Sync"> + <Tab title="Secret Sync"> <AccordionGroup> <Accordion title="AWS Secrets Manager"> Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx index 18f702bb61..5d33bad582 100644 --- a/docs/integrations/app-connections/github.mdx +++ b/docs/integrations/app-connections/github.mdx @@ -23,7 +23,7 @@ Infisical supports two methods for connecting to GitHub.  - Give the application a name, a homepage URL (your self-hosted domain i.e. `https://your-domain.com`), and a callback URL (i.e. `https://your-domain.com/app-connections/github/oauth/callback`). + Give the application a name, a homepage URL (your self-hosted domain i.e. `https://your-domain.com`), and a callback URL (i.e. `https://your-domain.com/organization/app-connections/github/oauth/callback`).  @@ -116,7 +116,7 @@ Infisical supports two methods for connecting to GitHub.  Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` - and the **Authorization callback URL** to `https://your-domain.com/app-connections/github/oauth/callback`. + and the **Authorization callback URL** to `https://your-domain.com/organization/app-connections/github/oauth/callback`.  diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx new file mode 100644 index 0000000000..a9055ff1ef --- /dev/null +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -0,0 +1,139 @@ +--- +title: "AWS Parameter Store Sync" +description: "Learn how to configure an AWS Parameter Store Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions + +<Tabs> + <Tab title="Infisical UI"> + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. +  + + 2. Select the **AWS Parameter Store** option. +  + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. +  + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + <Tip> + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + </Tip> + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. +  + + - **AWS Connection**: The AWS Connection to authenticate with. + - **Region**: The AWS region to deploy secrets to. + - **Path**: The AWS Parameter Store path to deploy secrets to. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. +  + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint prior to syncing, prioritizing values present in Infisical if secrets conflict. + - **Import Secrets (Prioritize Parameter Store)**: Imports secrets from the destination endpoint prior to syncing, prioritizing values present in Parameter Store if secrets conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + + 6. Configure the **Details** of your Parameter Store Sync, then click **Next**. +  + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Parameter Store Sync configuration, then click **Create Sync**. +  + + 8. If enabled, your Parameter Store Sync will begin syncing your secrets to the destination endpoint. +  + + </Tab> + <Tab title="API"> + To create an **AWS Parameter Store Sync**, make an API request to the [Create AWS + Parameter Store Sync](/api-reference/endpoints/secret-syncs/aws-parameter-store/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/aws-parameter-store \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-parameter-store-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "region": "us-east-1", + "path": "/my-aws/path/" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-parameter-store-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "aws", + "name": "my-aws-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "aws-parameter-store", + "destinationConfig": { + "region": "us-east-1", + "path": "/my-aws/path/" + } + } + } + ``` + </Tab> +</Tabs> diff --git a/docs/integrations/secret-syncs/github.mdx b/docs/integrations/secret-syncs/github.mdx new file mode 100644 index 0000000000..4c6d52efba --- /dev/null +++ b/docs/integrations/secret-syncs/github.mdx @@ -0,0 +1,162 @@ +--- +title: "GitHub Sync" +description: "Learn how to configure a GitHub Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [GitHub Connection](/integrations/app-connections/github) + +<Tabs> + <Tab title="Infisical UI"> + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. +  + + 2. Select the **GitHub Store** option. +  + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. +  + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + <Tip> + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + </Tip> + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. +  + + - **GitHub Connection**: The GitHub Connection to authenticate with. + - **Scope**: The GitHub secret scope to sync secrets to. + - **Organization**: Sync secrets to a specific organization. + - **Repository**: Sync secrets to a specific repository. + - **Repository Environment**: Sync secrets to a specific repository's environment. + <p class="height:1px" /> + The remaining fields are determined by the selected **Scope**: + <AccordionGroup> + <Accordion title="Organization"> + - **Organization**: The organization to deploy secrets to. + - **Visibility**: Determines which organization repositories can access deployed secrets. + - **All Repositories**: All repositories of the organization. (Public repositories if not a Pro/Team account) + - **Private Repositories**: All private repositories of the organization. (Requires Pro/Team account) + - **Selected Repositories**: Only the selected Repositories. + - **Selected Repositories**: The selected repositories if **Visibility** is set to **Selected Repositories**. + </Accordion> + <Accordion title="Repository"> + - **Repository**: The repository to deploy secrets to. + </Accordion> + <Accordion title="Repository Environment"> + - **Repository**: The repository to deploy secrets to. + - **Environment**: The repository's environment to deploy secrets to. + </Accordion> + </AccordionGroup> + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. +  + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + <Note> + GitHub does not support importing secrets. + </Note> + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + + 6. Configure the **Details** of your GitHub Sync, then click **Next**. +  + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your GitHub Sync configuration, then click **Create Sync**. +  + + 8. If enabled, your GitHub Sync will begin syncing your secrets to the destination endpoint. +  + + </Tab> + <Tab title="API"> + To create an **GitHub Sync**, make an API request to the [Create GitHub Sync](/api-reference/endpoints/secret-syncs/github/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/github \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-github-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "scope": "repository", + "owner": "my-github", + "repo": "my-repository" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-github-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "github", + "name": "my-github-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "github", + "destinationConfig": { + "scope": "repository", + "owner": "my-github", + "repo": "my-repository" + } + } + } + ``` + </Tab> +</Tabs> diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx new file mode 100644 index 0000000000..cc93d80362 --- /dev/null +++ b/docs/integrations/secret-syncs/overview.mdx @@ -0,0 +1,89 @@ +--- +sidebarTitle: "Overview" +description: "Learn how to sync secrets to third-party services with Infisical." +--- + +Secret Syncs enable you to sync secrets from Infisical to third-party services using [App Connections](/integrations/app-connections/overview). + +<Note> + Secret Syncs will gradually replace Native Integrations as they become available. Native Integrations will be deprecated in the future, so opt for configuring a Secret Sync when available. +</Note> + +## Concept + +Secret Syncs are a project-level resource used to sync secrets, via an [App Connection](/integrations/app-connections/overview), from a particular project environment and folder path (source) +to a third-party service (destination). Changes to the source will automatically be propagated to the destination, ensuring +your secrets are always up-to-date. + +<br /> + +<div align="center"> + + ```mermaid + %%{init: {'flowchart': {'curve': 'linear'} } }%% + graph LR + A[App Connection] + B[Secret Sync] + C[Secret 1] + D[Secret 2] + E[Secret 3] + F[Third-Party Service] + G[Secret 1] + H[Secret 2] + I[Secret 3] + J[Project Source] + + B --> A + C --> J + D --> J + E --> J + A --> F + F --> G + F --> H + F --> I + J --> B + + classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black + classDef connection fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px + classDef secret fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + classDef sync fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + classDef service fill:#E6E6FF,stroke:#6B4E96,stroke-width:2px,color:black,rx:15px + classDef project fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px + + class A connection + class B sync + class C,D,E,G,H,I secret + class F project + class J service + ``` + +</div> + +## Workflow + +Configuring a Secret Sync requires three components: a <strong>source</strong> location to retrieve secrets from, +a <strong>destination</strong> endpoint to deploy secrets to, and <strong>configuration options</strong> to determine how your secrets +should be synced. Follow these steps to start syncing: + +<Note> + For step-by-step guides on syncing to a particular third-party service, refer to the Secret Syncs section in the Navigation Bar. +</Note> + +1. <strong>Create App Connection:</strong> If you have not already done so, create an [App Connection](/integrations/app-connections/overview) +via the UI or API for the third-party service you intend to sync secrets to. + +2. <strong>Create Secret Sync:</strong> Configure a Secret Sync in the desired project by specifying the following parameters via the UI or API: + - <strong>Source:</strong> The project environment and folder path you wish to retrieve secrets from. + - <strong>Destination:</strong> The App Connection to utilize and the destination endpoint to deploy secrets to. These can vary between services. + - <strong>Options:</strong> Customize how secrets should be synced. Examples include adding a suffix or prefix to your secrets, or importing secrets from the destination on the initial sync. + +<Info> + Some third-party services do not support importing secrets. +</Info> + +3. <strong>Utilize Sync:</strong> Any changes to the source location will now automatically be propagated to the destination endpoint. + +<Note> + Infisical is continuously expanding it's Secret Sync third-party service support. If the service you need isn't available, + you can still use our Native Integrations in the interim, or contact us at team@infisical.com to make a request . +</Note> \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 26d199fcef..23b10f5795 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -343,6 +343,22 @@ "cli/faq" ] }, + { + "group": "App Connections", + "pages": [ + "integrations/app-connections/overview", + "integrations/app-connections/aws", + "integrations/app-connections/github" + ] + }, + { + "group": "Secret Syncs", + "pages": [ + "integrations/secret-syncs/overview", + "integrations/secret-syncs/aws-parameter-store", + "integrations/secret-syncs/github" + ] + }, { "group": "Infrastructure Integrations", "pages": [ @@ -767,6 +783,67 @@ "api-reference/endpoints/identity-specific-privilege/list" ] }, + { + "group": "App Connections", + "pages": [ + "api-reference/endpoints/app-connections/list", + "api-reference/endpoints/app-connections/options", + { "group": "AWS", + "pages": [ + "api-reference/endpoints/app-connections/aws/list", + "api-reference/endpoints/app-connections/aws/available", + "api-reference/endpoints/app-connections/aws/get-by-id", + "api-reference/endpoints/app-connections/aws/get-by-name", + "api-reference/endpoints/app-connections/aws/create", + "api-reference/endpoints/app-connections/aws/update", + "api-reference/endpoints/app-connections/aws/delete" + ] + }, + { "group": "GitHub", + "pages": [ + "api-reference/endpoints/app-connections/github/list", + "api-reference/endpoints/app-connections/github/available", + "api-reference/endpoints/app-connections/github/get-by-id", + "api-reference/endpoints/app-connections/github/get-by-name", + "api-reference/endpoints/app-connections/github/create", + "api-reference/endpoints/app-connections/github/update", + "api-reference/endpoints/app-connections/github/delete" + ] + } + ] + }, + { + "group": "Secret Syncs", + "pages": [ + "api-reference/endpoints/secret-syncs/list", + "api-reference/endpoints/secret-syncs/options", + { "group": "AWS Parameter Store", + "pages": [ + "api-reference/endpoints/secret-syncs/aws-parameter-store/list", + "api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-id", + "api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-name", + "api-reference/endpoints/secret-syncs/aws-parameter-store/create", + "api-reference/endpoints/secret-syncs/aws-parameter-store/update", + "api-reference/endpoints/secret-syncs/aws-parameter-store/delete", + "api-reference/endpoints/secret-syncs/aws-parameter-store/sync-secrets", + "api-reference/endpoints/secret-syncs/aws-parameter-store/import-secrets", + "api-reference/endpoints/secret-syncs/aws-parameter-store/remove-secrets" + ] + }, + { "group": "GitHub", + "pages": [ + "api-reference/endpoints/secret-syncs/github/list", + "api-reference/endpoints/secret-syncs/github/get-by-id", + "api-reference/endpoints/secret-syncs/github/get-by-name", + "api-reference/endpoints/secret-syncs/github/create", + "api-reference/endpoints/secret-syncs/github/update", + "api-reference/endpoints/secret-syncs/github/delete", + "api-reference/endpoints/secret-syncs/github/sync-secrets", + "api-reference/endpoints/secret-syncs/github/remove-secrets" + ] + } + ] + }, { "group": "Integrations", "pages": [ diff --git a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx new file mode 100644 index 0000000000..3e232765cf --- /dev/null +++ b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; + +import { CreateSecretSyncForm } from "./forms"; +import { SecretSyncModalHeader } from "./SecretSyncModalHeader"; +import { SecretSyncSelect } from "./SecretSyncSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (secretSync: TSecretSync) => void; + selectedSync: SecretSync | null; + setSelectedSync: (selectedSync: SecretSync | null) => void; +}; + +const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { + if (selectedSync) { + return ( + <CreateSecretSyncForm + onComplete={onComplete} + onCancel={() => setSelectedSync(null)} + destination={selectedSync} + /> + ); + } + + return <SecretSyncSelect onSelect={setSelectedSync} />; +}; + +export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => { + const [selectedSync, setSelectedSync] = useState<SecretSync | null>(null); + + return ( + <Modal + {...props} + onOpenChange={(isOpen) => { + if (!isOpen) setSelectedSync(null); + onOpenChange(isOpen); + }} + > + <ModalContent + title={ + selectedSync ? ( + <SecretSyncModalHeader isConfigured={false} destination={selectedSync} /> + ) : ( + "Add Sync" + ) + } + onPointerDownOutside={(e) => e.preventDefault()} + className="max-w-2xl" + subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."} + bodyClassName="overflow-visible" + > + <Content + onComplete={() => { + setSelectedSync(null); + onOpenChange(false); + }} + selectedSync={selectedSync} + setSelectedSync={setSelectedSync} + /> + </ModalContent> + </Modal> + ); +}; diff --git a/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx b/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx new file mode 100644 index 0000000000..1daed67a38 --- /dev/null +++ b/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, Switch } from "@app/components/v2"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { TSecretSync, useDeleteSecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync?: TSecretSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComplete }: Props) => { + const deleteSync = useDeleteSecretSync(); + const [removeSecrets, setRemoveSecrets] = useState(false); + + if (!secretSync) return null; + + const { id: syncId, name, destination } = secretSync; + + const handleDeleteSecretSync = async () => { + const destinationName = SECRET_SYNC_MAP[destination].name; + + try { + await deleteSync.mutateAsync({ + syncId, + destination, + removeSecrets + }); + + createNotification({ + text: `Successfully removed ${destinationName} Sync`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to remove ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( + <DeleteActionModal + isOpen={isOpen} + onChange={onOpenChange} + title={`Are you sure want to delete ${name}?`} + deleteKey={name} + onDeleteApproved={handleDeleteSecretSync} + > + <Switch + containerClassName="mt-4" + className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-red/50" + thumbClassName="bg-mineshaft-800" + isChecked={removeSecrets} + onCheckedChange={setRemoveSecrets} + id="remove-secrets" + > + Remove Synced Secrets + </Switch> + </DeleteActionModal> + ); +}; diff --git a/frontend/src/components/secret-syncs/EditSecretSyncModal.tsx b/frontend/src/components/secret-syncs/EditSecretSyncModal.tsx new file mode 100644 index 0000000000..c7d0661d31 --- /dev/null +++ b/frontend/src/components/secret-syncs/EditSecretSyncModal.tsx @@ -0,0 +1,33 @@ +import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; +import { Modal, ModalContent } from "@app/components/v2"; +import { TSecretSync } from "@app/hooks/api/secretSyncs"; + +import { EditSecretSyncForm } from "./forms"; +import { SecretSyncModalHeader } from "./SecretSyncModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + secretSync?: TSecretSync; + fields: SecretSyncEditFields; +}; + +export const EditSecretSyncModal = ({ secretSync, onOpenChange, fields, ...props }: Props) => { + if (!secretSync) return null; + + return ( + <Modal {...props} onOpenChange={onOpenChange}> + <ModalContent + title={<SecretSyncModalHeader isConfigured destination={secretSync.destination} />} + className="max-w-2xl" + bodyClassName="overflow-visible" + > + <EditSecretSyncForm + onComplete={() => onOpenChange(false)} + fields={fields} + secretSync={secretSync} + /> + </ModalContent> + </Modal> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncImportSecretsModal.tsx b/frontend/src/components/secret-syncs/SecretSyncImportSecretsModal.tsx new file mode 100644 index 0000000000..aab8a42436 --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncImportSecretsModal.tsx @@ -0,0 +1,167 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { SECRET_SYNC_IMPORT_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { + SecretSyncImportBehavior, + TSecretSync, + useTriggerSecretSyncImportSecrets +} from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync?: TSecretSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + secretSync: TSecretSync; + onComplete: () => void; +}; + +const FormSchema = z.object({ + importBehavior: z.nativeEnum(SecretSyncImportBehavior) +}); + +type TFormData = z.infer<typeof FormSchema>; + +const Content = ({ secretSync, onComplete }: ContentProps) => { + const { id: syncId, destination } = secretSync; + const destinationName = SECRET_SYNC_MAP[destination].name; + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = useForm<TFormData>({ resolver: zodResolver(FormSchema) }); + + const triggerImportSecrets = useTriggerSecretSyncImportSecrets(); + + const handleTriggerImportSecrets = async ({ importBehavior }: TFormData) => { + try { + await triggerImportSecrets.mutateAsync({ + syncId, + destination, + importBehavior + }); + + createNotification({ + text: `Successfully triggered secret import for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger secret import for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( + <form onSubmit={handleSubmit(handleTriggerImportSecrets)}> + <p className="mb-8 text-sm text-mineshaft-200"> + Are you sure you want to import secrets from this {destinationName} destination into + Infiscal? + </p> + <Controller + name="importBehavior" + control={control} + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + tooltipClassName="max-w-lg py-3" + tooltipText={ + <div className="flex flex-col gap-3"> + <p> + Specify how Infisical should resolve the initial sync to {destinationName}. The + following options are available: + </p> + <ul className="flex list-disc flex-col gap-3 pl-4"> + {Object.values(SECRET_SYNC_IMPORT_BEHAVIOR_MAP).map((details) => { + const { name, description } = details(destinationName); + + return ( + <li key={name}> + <p className="text-mineshaft-300"> + <span className="font-medium text-bunker-200">{name}</span>: {description} + </p> + </li> + ); + })} + </ul> + </div> + } + errorText={error?.message} + isError={Boolean(error?.message)} + label="Import Behavior" + > + <Select + value={value} + onValueChange={(val) => onChange(val)} + className="w-full border border-mineshaft-500" + position="popper" + placeholder="Select an option..." + dropdownContainerClassName="max-w-none" + > + {Object.entries(SECRET_SYNC_IMPORT_BEHAVIOR_MAP).map(([key, details]) => { + const { name } = details(destinationName); + + return ( + <SelectItem value={key} key={key}> + {name} + </SelectItem> + ); + })} + </Select> + </FormControl> + )} + /> + <div className="mt-8 flex w-full items-center justify-between gap-2"> + <ModalClose asChild> + <Button colorSchema="secondary" variant="plain"> + Cancel + </Button> + </ModalClose> + <Button + type="submit" + isDisabled={isSubmitting || !isDirty} + isLoading={isSubmitting} + colorSchema="secondary" + > + Import Secrets + </Button> + </div> + </form> + ); +}; + +export const SecretSyncImportSecretsModal = ({ isOpen, onOpenChange, secretSync }: Props) => { + if (!secretSync) return null; + + const destinationName = SECRET_SYNC_MAP[secretSync.destination].name; + + return ( + <Modal isOpen={isOpen} onOpenChange={onOpenChange}> + <ModalContent + title="Import Secrets" + subTitle={`Import secrets into Infisical from this ${destinationName} Sync destination.`} + > + <Content secretSync={secretSync} onComplete={() => onOpenChange(false)} /> + </ModalContent> + </Modal> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncImportStatusBadge.tsx b/frontend/src/components/secret-syncs/SecretSyncImportStatusBadge.tsx new file mode 100644 index 0000000000..5f54d5c617 --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncImportStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faDownload, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync: TSecretSync; + className?: string; + mini?: boolean; +}; + +export const SecretSyncImportStatusBadge = ({ secretSync, className, mini }: Props) => { + const { importStatus, lastImportMessage, lastImportedAt, destination } = secretSync; + const [hide, setHide] = useState(importStatus === SecretSyncStatus.Succeeded); + const destinationName = SECRET_SYNC_MAP[destination].name; + + useEffect(() => { + if (importStatus === SecretSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [importStatus]); + + const failureMessage = useMemo(() => { + if (importStatus === SecretSyncStatus.Failed) { + if (lastImportMessage) + try { + return JSON.stringify(JSON.parse(lastImportMessage), null, 2); + } catch { + return lastImportMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [importStatus, lastImportMessage]); + + if (!importStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (importStatus) { + case SecretSyncStatus.Pending: + case SecretSyncStatus.Running: + variant = "primary"; + label = "Importing Secrets..."; + tooltipContent = `Importing secrets from ${destinationName}. This may take a moment.`; + icon = faDownload; + + break; + case SecretSyncStatus.Failed: + variant = "danger"; + label = "Failed to Import Secrets"; + icon = faTriangleExclamation; + tooltipContent = ( + <div className="flex flex-col gap-2 whitespace-normal py-1"> + {failureMessage && ( + <div> + <div className="mb-2 flex self-start text-red"> + <FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" /> + <div className="text-xs"> + {mini ? "Failed to Import Secrets" : "Failure Reason"} + </div> + </div> + <div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div> + </div> + )} + </div> + ); + + break; + case SecretSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null; + + tooltipContent = "Successfully imported secrets."; + variant = "success"; + label = "Secrets Imported"; + icon = faCheck; + } + + return ( + <Tooltip position="bottom" className="max-w-sm" content={tooltipContent}> + <div> + <Badge + className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)} + variant={variant} + > + <FontAwesomeIcon icon={icon} /> + {!mini && <span>{label}</span>} + </Badge> + </div> + </Tooltip> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncLabel.tsx b/frontend/src/components/secret-syncs/SecretSyncLabel.tsx new file mode 100644 index 0000000000..8528c9bc6b --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncLabel.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = { + label: string; + children?: ReactNode; + className?: string; + labelClassName?: string; +}; + +export const SecretSyncLabel = ({ label, children, className, labelClassName }: Props) => { + return ( + <div className={className}> + <p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p> + {children ? ( + <p className="text-sm text-mineshaft-100">{children}</p> + ) : ( + <p className="text-sm italic text-mineshaft-400/50">None</p> + )} + </div> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx new file mode 100644 index 0000000000..d8d07d9c9b --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx @@ -0,0 +1,49 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + destination: SecretSync; + isConfigured: boolean; +}; + +export const SecretSyncModalHeader = ({ destination, isConfigured }: Props) => { + const destinationDetails = SECRET_SYNC_MAP[destination]; + + return ( + <div className="flex w-full items-start gap-2"> + <img + alt={`${destinationDetails.name} logo`} + src={`/images/integrations/${destinationDetails.image}`} + className="h-12 w-12 rounded-md bg-bunker-500 p-2" + /> + <div> + <div className="flex items-center text-mineshaft-300"> + {destinationDetails.name} Sync + <a + target="_blank" + href={`https://infisical.com/docs/integrations/secret-syncs/${destination}`} + className="mb-1 ml-1" + rel="noopener noreferrer" + > + <div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100"> + <FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" /> + <span>Docs</span> + <FontAwesomeIcon + icon={faArrowUpRightFromSquare} + className="mb-[0.07rem] ml-1 text-[10px]" + /> + </div> + </a> + </div> + <p className="text-sm leading-4 text-mineshaft-400"> + {isConfigured + ? `Edit ${destinationDetails.name} Sync` + : `Sync secrets to ${destinationDetails.name}`} + </p> + </div> + </div> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncRemoveSecretsModal.tsx b/frontend/src/components/secret-syncs/SecretSyncRemoveSecretsModal.tsx new file mode 100644 index 0000000000..c8bdeee6d4 --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncRemoveSecretsModal.tsx @@ -0,0 +1,85 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { TSecretSync, useTriggerSecretSyncRemoveSecrets } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync?: TSecretSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + secretSync: TSecretSync; + onComplete: () => void; +}; + +const Content = ({ secretSync, onComplete }: ContentProps) => { + const { id: syncId, destination } = secretSync; + const destinationName = SECRET_SYNC_MAP[destination].name; + + const triggerSyncImport = useTriggerSecretSyncRemoveSecrets(); + + const handleTriggerRemoveSecrets = async () => { + try { + await triggerSyncImport.mutateAsync({ + syncId, + destination + }); + + createNotification({ + text: `Successfully triggered secret removal for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger secret removal for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( + <> + <p className="mb-8 text-sm text-mineshaft-200"> + Are you sure you want to remove synced secrets from this {destinationName} destination? + </p> + <div className="mt-8 flex w-full items-center justify-between gap-2"> + <ModalClose asChild> + <Button colorSchema="secondary" variant="plain"> + Cancel + </Button> + </ModalClose> + <Button + isDisabled={triggerSyncImport.isPending} + isLoading={triggerSyncImport.isPending} + onClick={handleTriggerRemoveSecrets} + colorSchema="secondary" + > + Remove Secrets + </Button> + </div> + </> + ); +}; + +export const SecretSyncRemoveSecretsModal = ({ isOpen, onOpenChange, secretSync }: Props) => { + if (!secretSync) return null; + + const destinationName = SECRET_SYNC_MAP[secretSync.destination].name; + + return ( + <Modal isOpen={isOpen} onOpenChange={onOpenChange}> + <ModalContent + title="Remove Secrets" + subTitle={`Remove synced secrets from this ${destinationName} Sync destination.`} + > + <Content secretSync={secretSync} onComplete={() => onOpenChange(false)} /> + </ModalContent> + </Modal> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncRemoveStatusBadge.tsx b/frontend/src/components/secret-syncs/SecretSyncRemoveStatusBadge.tsx new file mode 100644 index 0000000000..81d6eab304 --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncRemoveStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faEraser, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync: TSecretSync; + className?: string; + mini?: boolean; +}; + +export const SecretSyncRemoveStatusBadge = ({ secretSync, className, mini }: Props) => { + const { removeStatus, lastRemoveMessage, lastRemovedAt, destination } = secretSync; + const [hide, setHide] = useState(removeStatus === SecretSyncStatus.Succeeded); + const destinationName = SECRET_SYNC_MAP[destination].name; + + useEffect(() => { + if (removeStatus === SecretSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [removeStatus]); + + const failureMessage = useMemo(() => { + if (removeStatus === SecretSyncStatus.Failed) { + if (lastRemoveMessage) + try { + return JSON.stringify(JSON.parse(lastRemoveMessage), null, 2); + } catch { + return lastRemoveMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [removeStatus, lastRemoveMessage]); + + if (!removeStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (removeStatus) { + case SecretSyncStatus.Pending: + case SecretSyncStatus.Running: + variant = "primary"; + label = "Removing Secrets..."; + tooltipContent = `Removing secrets from ${destinationName}. This may take a moment.`; + icon = faEraser; + + break; + case SecretSyncStatus.Failed: + variant = "danger"; + label = "Failed to Remove Secrets"; + icon = faTriangleExclamation; + tooltipContent = ( + <div className="flex flex-col gap-2 whitespace-normal py-1"> + {failureMessage && ( + <div> + <div className="mb-2 flex self-start text-red"> + <FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" /> + <div className="text-xs"> + {mini ? "Failed to Remove Secrets" : "Failure Reason"} + </div> + </div> + <div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div> + </div> + )} + </div> + ); + + break; + case SecretSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastRemovedAt && differenceInSeconds(new Date(), lastRemovedAt) > 15) return null; + + tooltipContent = "Successfully removed secrets."; + variant = "success"; + label = "Secrets Removed"; + icon = faCheck; + } + + return ( + <Tooltip position="bottom" className="max-w-sm" content={tooltipContent}> + <div> + <Badge + className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)} + variant={variant} + > + <FontAwesomeIcon icon={icon} /> + {!mini && <span>{label}</span>} + </Badge> + </div> + </Tooltip> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncSelect.tsx b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx new file mode 100644 index 0000000000..6e3030a01e --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx @@ -0,0 +1,88 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Spinner, Tooltip } from "@app/components/v2"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { SecretSync, useSecretSyncOptions } from "@app/hooks/api/secretSyncs"; + +type Props = { + onSelect: (destination: SecretSync) => void; +}; + +export const SecretSyncSelect = ({ onSelect }: Props) => { + const { isLoading, data: secretSyncOptions } = useSecretSyncOptions(); + + if (isLoading) { + return ( + <div className="flex h-full flex-col items-center justify-center py-2.5"> + <Spinner size="lg" className="text-mineshaft-500" /> + <p className="mt-4 text-sm text-mineshaft-400">Loading options...</p> + </div> + ); + } + + return ( + <div className="grid grid-cols-4 gap-2"> + {secretSyncOptions?.map(({ destination }) => { + const { image, name } = SECRET_SYNC_MAP[destination]; + return ( + <button + type="button" + key={destination} + onClick={() => onSelect(destination)} + className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600" + > + <img + src={`/images/integrations/${image}`} + height={40} + width={40} + className="mt-auto" + alt={`${name} logo`} + /> + <div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200"> + {name} + </div> + </button> + ); + })} + <Tooltip + side="bottom" + className="max-w-sm py-4" + content={ + <> + <p className="mb-2">Infisical is constantly adding support for more services.</p> + <p> + {`If you don't see the third-party + service you're looking for,`}{" "} + <a + target="_blank" + className="underline hover:text-mineshaft-300" + href="https://infisical.com/slack" + rel="noopener noreferrer" + > + let us know on Slack + </a>{" "} + or{" "} + <a + target="_blank" + className="underline hover:text-mineshaft-300" + href="https://github.com/Infisical/infisical/discussions" + rel="noopener noreferrer" + > + make a request on GitHub + </a> + . + </p> + </> + } + > + <div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4 hover:bg-mineshaft-900/50"> + <FontAwesomeIcon className="mt-auto text-3xl" icon={faWrench} /> + <div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200"> + Coming Soon + </div> + </div> + </Tooltip> + </div> + ); +}; diff --git a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx new file mode 100644 index 0000000000..dbf543f616 --- /dev/null +++ b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx @@ -0,0 +1,47 @@ +import { + faCheck, + faExclamationTriangle, + faRotate, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge"; +import { SecretSyncStatus } from "@app/hooks/api/secretSyncs"; + +type Props = { + status: SecretSyncStatus; +} & Omit<BadgeProps, "children" | "variant">; + +export const SecretSyncStatusBadge = ({ status }: Props) => { + let variant: BadgeProps["variant"]; + let text: string; + let icon: IconDefinition; + + switch (status) { + case SecretSyncStatus.Failed: + variant = "danger"; + text = "Failed to Sync"; + icon = faExclamationTriangle; + break; + case SecretSyncStatus.Succeeded: + variant = "success"; + text = "Synced"; + icon = faCheck; + break; + case SecretSyncStatus.Pending: // no need to differentiate from user perspective + case SecretSyncStatus.Running: + default: + variant = "primary"; + text = "Syncing"; + icon = faRotate; + break; + } + + return ( + <Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" variant={variant}> + <FontAwesomeIcon icon={icon} /> + <span>{text}</span> + </Badge> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx new file mode 100644 index 0000000000..e7721f1131 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Checkbox, FormControl, Switch } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { + SecretSync, + SecretSyncInitialSyncBehavior, + TSecretSync, + useCreateSecretSync, + useSecretSyncOption +} from "@app/hooks/api/secretSyncs"; + +import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas"; +import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; +import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; +import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields"; +import { SecretSyncReviewFields } from "./SecretSyncReviewFields"; +import { SecretSyncSourceFields } from "./SecretSyncSourceFields"; + +type Props = { + onComplete: (secretSync: TSecretSync) => void; + destination: SecretSync; + onCancel: () => void; +}; + +const FORM_TABS: { name: string; key: string; fields: (keyof TSecretSyncForm)[] }[] = [ + { name: "Source", key: "source", fields: ["secretPath", "environment"] }, + { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, + { name: "Options", key: "options", fields: ["syncOptions"] }, + { name: "Details", key: "details", fields: ["name", "description"] }, + { name: "Review", key: "review", fields: [] } +]; + +export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Props) => { + const createSecretSync = useCreateSecretSync(); + const { currentWorkspace } = useWorkspace(); + const { name: destinationName } = SECRET_SYNC_MAP[destination]; + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [confirmOverwrite, setConfirmOverwrite] = useState(false); + + const { syncOption } = useSecretSyncOption(destination); + + const formMethods = useForm<TSecretSyncForm>({ + resolver: zodResolver(SecretSyncFormSchema), + defaultValues: { + destination, + isAutoSyncEnabled: true, + syncOptions: { + initialSyncBehavior: syncOption?.canImportSecrets + ? undefined + : SecretSyncInitialSyncBehavior.OverwriteDestination + } + }, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => { + try { + const secretSync = await createSecretSync.mutateAsync({ + ...formData, + connectionId: connection.id, + environment: environment.slug, + projectId: currentWorkspace.id + }); + + createNotification({ + text: `Successfully added ${destinationName} Sync`, + type: "success" + }); + onComplete(secretSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to add ${destinationName} Sync`, + text: err.message, + type: "error" + }); + } + }; + + const handlePrev = () => { + if (selectedTabIndex === 0) { + onCancel(); + return; + } + + setSelectedTabIndex((prev) => prev - 1); + }; + + const { handleSubmit, trigger, watch, control } = formMethods; + + const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); + + const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; + + const handleNext = async () => { + if (isFinalStep) { + handleSubmit(onSubmit)(); + return; + } + + const isValid = await isStepValid(selectedTabIndex); + + if (!isValid) return; + + setSelectedTabIndex((prev) => prev + 1); + }; + + const isTabEnabled = async (index: number) => { + let isEnabled = true; + for (let i = index - 1; i >= 0; i -= 1) { + // eslint-disable-next-line no-await-in-loop + isEnabled = isEnabled && (await isStepValid(i)); + } + + return isEnabled; + }; + + const initialSyncBehavior = watch("syncOptions.initialSyncBehavior"); + + return ( + <form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}> + <FormProvider {...formMethods}> + <Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}> + <Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600"> + {FORM_TABS.map((tab, index) => ( + <Tab + onClick={async (e) => { + e.preventDefault(); + const isEnabled = await isTabEnabled(index); + setSelectedTabIndex((prev) => (isEnabled ? index : prev)); + }} + className={({ selected }) => + `w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + key={tab.key} + > + {index + 1}. {tab.name} + </Tab> + ))} + </Tab.List> + <Tab.Panels> + <Tab.Panel> + <SecretSyncSourceFields /> + </Tab.Panel> + <Tab.Panel> + <SecretSyncDestinationFields /> + </Tab.Panel> + <Tab.Panel> + <SecretSyncOptionsFields /> + <Controller + control={control} + name="isAutoSyncEnabled" + render={({ field: { value, onChange }, fieldState: { error } }) => { + return ( + <FormControl + helperText={ + value + ? "Secrets will automatically be synced when changes occur in the source location." + : "Secrets will not automatically be synced when changes occur in the source location. You can still trigger syncs manually." + } + isError={Boolean(error)} + errorText={error?.message} + > + <Switch + className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/50" + id="auto-sync-enabled" + thumbClassName="bg-mineshaft-800" + onCheckedChange={onChange} + isChecked={value} + > + <p className="w-[8.4rem]">Auto-Sync {value ? "Enabled" : "Disabled"}</p> + </Switch> + </FormControl> + ); + }} + /> + </Tab.Panel> + <Tab.Panel> + <SecretSyncDetailsFields /> + </Tab.Panel> + <Tab.Panel> + <SecretSyncReviewFields /> + </Tab.Panel> + </Tab.Panels> + </Tab.Group> + </FormProvider> + {isFinalStep && + initialSyncBehavior === SecretSyncInitialSyncBehavior.OverwriteDestination && ( + <Checkbox + id="confirm-overwrite" + isChecked={confirmOverwrite} + containerClassName="-mt-5" + onCheckedChange={(isChecked) => setConfirmOverwrite(Boolean(isChecked))} + > + <p + className={`mt-5 text-wrap text-xs ${confirmOverwrite ? "text-mineshaft-200" : "text-red"}`} + > + I understand all secrets present in the configured {destinationName} destination will + be removed if they are not present within Infisical. + </p> + </Checkbox> + )} + <div className="flex w-full flex-row-reverse justify-between gap-4 pt-4"> + <Button + isDisabled={ + isFinalStep && + initialSyncBehavior === SecretSyncInitialSyncBehavior.OverwriteDestination && + !confirmOverwrite + } + onClick={handleNext} + colorSchema="secondary" + > + {isFinalStep ? "Create Sync" : "Next"} + </Button> + {selectedTabIndex > 0 && ( + <Button onClick={handlePrev} colorSchema="secondary"> + Back + </Button> + )} + </div> + </form> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx new file mode 100644 index 0000000000..d9306c671a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -0,0 +1,105 @@ +import { ReactNode } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; +import { Button, ModalClose } from "@app/components/v2"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas"; +import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; +import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; +import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields"; +import { SecretSyncSourceFields } from "./SecretSyncSourceFields"; + +type Props = { + onComplete: (secretSync: TSecretSync) => void; + secretSync: TSecretSync; + fields: SecretSyncEditFields; +}; + +export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => { + const updateSecretSync = useUpdateSecretSync(); + const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; + + const formMethods = useForm<TSecretSyncForm>({ + resolver: zodResolver(UpdateSecretSyncFormSchema), + defaultValues: { + ...secretSync, + environment: secretSync.environment ?? undefined, + secretPath: secretSync.folder?.path, + description: secretSync.description ?? "" + }, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => { + try { + const updatedSecretSync = await updateSecretSync.mutateAsync({ + syncId: secretSync.id, + ...formData, + environment: environment?.slug, + connectionId: connection.id + }); + + createNotification({ + text: `Successfully updated ${destinationName} Sync`, + type: "success" + }); + onComplete(updatedSecretSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${destinationName} Sync`, + text: err.message, + type: "error" + }); + } + }; + + let Component: ReactNode; + + switch (fields) { + case SecretSyncEditFields.Destination: + Component = <SecretSyncDestinationFields />; + break; + case SecretSyncEditFields.Options: + Component = <SecretSyncOptionsFields hideInitialSync={Boolean(secretSync.lastSyncedAt)} />; + break; + case SecretSyncEditFields.Source: + Component = <SecretSyncSourceFields />; + break; + case SecretSyncEditFields.Details: + default: + Component = <SecretSyncDetailsFields />; + break; + } + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = formMethods; + + return ( + <form onSubmit={handleSubmit(onSubmit)}> + <FormProvider {...formMethods}>{Component}</FormProvider> + <div className="flex w-full justify-between gap-4 pt-4"> + <ModalClose asChild> + <Button colorSchema="secondary" variant="plain"> + Cancel + </Button> + </ModalClose> + <Button + isLoading={isSubmitting} + isDisabled={!isDirty || isSubmitting} + type="submit" + colorSchema="secondary" + > + Update Sync + </Button> + </div> + </form> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx new file mode 100644 index 0000000000..408e4af678 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx @@ -0,0 +1,90 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SECRET_SYNC_CONNECTION_MAP } from "@app/helpers/secretSyncs"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TSecretSyncForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; +}; + +export const SecretSyncConnectionField = ({ onChange: callback }: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext<TSecretSyncForm>(); + + const destination = watch("destination"); + const app = SECRET_SYNC_CONNECTION_MAP[destination]; + + const { data: options, isLoading } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + const appName = APP_CONNECTION_MAP[SECRET_SYNC_CONNECTION_MAP[destination]].name; + + return ( + <> + <p className="mb-4 text-sm text-bunker-300"> + Specify the {appName} Connection to use to connect to {connectionName} and configure + destination parameters. + </p> + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + tooltipText="App Connections can be created from the Organization Settings page." + isError={Boolean(error)} + errorText={error?.message} + label={`${connectionName} Connection`} + > + <FilterableSelect + value={value} + onChange={(newValue) => { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isLoading} + options={options} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + </FormControl> + )} + control={control} + name="connection" + /> + {options?.length === 0 && ( + <p className="-mt-2.5 mb-2.5 text-xs text-yellow"> + <FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} /> + {canCreateConnection ? ( + <> + You do not have access to any {appName} Connections. Create one from the{" "} + <Link + to="/organization/settings" + className="underline" + search={{ selectedTab: "app-connections" }} + > + Organization Settings + </Link>{" "} + page. + </> + ) : ( + `You do not have access to any ${appName} Connections. Contact an admin to create one.` + )} + </p> + )} + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx new file mode 100644 index 0000000000..a77b0cc04b --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx @@ -0,0 +1,67 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { components, OptionProps, SingleValue } from "react-select"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { Badge, FilterableSelect, FormControl, Input } from "@app/components/v2"; +import { AWS_REGIONS } from "@app/helpers/appConnections"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +const Option = ({ isSelected, children, ...props }: OptionProps<(typeof AWS_REGIONS)[number]>) => { + return ( + <components.Option isSelected={isSelected} {...props}> + <div className="flex flex-row items-center justify-between"> + <p className="truncate">{children}</p> + <Badge variant="success" className="ml-1 mr-auto cursor-pointer"> + {props.data.slug} + </Badge> + {isSelected && ( + <FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" /> + )} + </div> + </components.Option> + ); +}; + +export const AwsParameterStoreSyncFields = () => { + const { control } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AWSParameterStore } + >(); + + return ( + <> + <SecretSyncConnectionField /> + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl isError={Boolean(error)} errorText={error?.message} label="Region"> + <FilterableSelect + value={AWS_REGIONS.find((region) => region.slug === value)} + onChange={(option) => + onChange((option as SingleValue<(typeof AWS_REGIONS)[number]>)?.slug) + } + options={AWS_REGIONS} + placeholder="Select region..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + components={{ Option }} + /> + </FormControl> + )} + control={control} + name="destinationConfig.region" + /> + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl isError={Boolean(error)} errorText={error?.message} label="Path"> + <Input value={value} onChange={onChange} placeholder="Path..." /> + </FormControl> + )} + control={control} + name="destinationConfig.path" + /> + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitHubSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitHubSyncFields.tsx new file mode 100644 index 0000000000..cabda1e669 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitHubSyncFields.tsx @@ -0,0 +1,231 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue, SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + TGitHubConnectionEnvironment, + TGitHubConnectionOrganization, + TGitHubConnectionRepository, + useGitHubConnectionListEnvironments, + useGitHubConnectionListOrganizations, + useGitHubConnectionListRepositories +} from "@app/hooks/api/appConnections/github"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { + GitHubSyncScope, + GitHubSyncVisibility +} from "@app/hooks/api/secretSyncs/types/github-sync"; + +import { TSecretSyncForm } from "../schemas"; + +export const GitHubSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.GitHub } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentScope = watch("destinationConfig.scope"); + const currentVisibility = watch("destinationConfig.visibility"); + const currentOrg = watch("destinationConfig.org"); + const currentRepo = watch("destinationConfig.repo"); + const currentOwner = watch("destinationConfig.owner"); + + const { data: repositories = [], isPending: isRepositoriesPending } = + useGitHubConnectionListRepositories(connectionId, { + enabled: Boolean(connectionId) + }); + + const { data: organizations = [], isPending: isOrganizationsPending } = + useGitHubConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId && currentScope === GitHubSyncScope.Organization) + }); + + const { data: environments = [], isPending: isEnvironmentsPending } = + useGitHubConnectionListEnvironments( + { + connectionId, + repo: currentRepo, + owner: currentOwner + }, + { + enabled: Boolean( + connectionId && + currentRepo && + currentOwner && + currentScope === GitHubSyncScope.RepositoryEnvironment + ) + } + ); + + return ( + <> + <SecretSyncConnectionField + onChange={() => { + setValue("destinationConfig.org", ""); + setValue("destinationConfig.repo", ""); + setValue("destinationConfig.owner", ""); + setValue("destinationConfig.selectedRepositoryIds", undefined); + }} + /> + <Controller + name="destinationConfig.scope" + control={control} + defaultValue={GitHubSyncScope.Repository} + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Scope"> + <Select + value={value} + onValueChange={(val) => { + onChange(val); + }} + className="w-full border border-mineshaft-500 capitalize" + position="popper" + placeholder="Select a scope..." + dropdownContainerClassName="max-w-none" + > + {Object.values(GitHubSyncScope).map((scope) => ( + <SelectItem className="capitalize" value={scope} key={scope}> + {scope.replace("-", " ")} + </SelectItem> + ))} + </Select> + </FormControl> + )} + /> + {currentScope === GitHubSyncScope.Organization && ( + <> + <Controller + name="destinationConfig.org" + control={control} + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl isError={Boolean(error)} errorText={error?.message} label="Organization"> + <FilterableSelect + isLoading={isOrganizationsPending && Boolean(connectionId)} + isDisabled={!connectionId} + value={organizations.find((org) => org.login === value) ?? null} + onChange={(option) => + onChange((option as SingleValue<TGitHubConnectionOrganization>)?.login ?? null) + } + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.login} + getOptionValue={(option) => option.login} + /> + </FormControl> + )} + /> + <Controller + name="destinationConfig.visibility" + control={control} + defaultValue={GitHubSyncVisibility.All} + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + errorText={error?.message} + isError={Boolean(error?.message)} + label="Visibility" + > + <Select + value={value} + onValueChange={(val) => { + onChange(val); + setValue("destinationConfig.selectedRepositoryIds", undefined); + }} + className="w-full border border-mineshaft-500 capitalize" + position="popper" + placeholder="Select visibility..." + dropdownContainerClassName="max-w-none" + > + {Object.values(GitHubSyncVisibility).map((scope) => ( + <SelectItem className="capitalize" value={scope} key={scope}> + {scope.replace("-", " ")} Repositories + </SelectItem> + ))} + </Select> + </FormControl> + )} + /> + {currentVisibility === GitHubSyncVisibility.Selected && ( + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + isError={Boolean(error)} + errorText={error?.message} + label="Selected Repositories" + > + <FilterableSelect + menuPlacement="top" + isLoading={isRepositoriesPending && Boolean(currentOrg)} + isDisabled={!currentOrg || !connectionId} + isMulti + value={repositories.filter((repo) => value?.includes(repo.id))} + onChange={(option) => { + const repos = option as MultiValue<TGitHubConnectionRepository>; + onChange(repos.map((repo) => repo.id)); + }} + options={repositories.filter((repo) => repo.owner.login === currentOrg)} + placeholder="Select one or more repositories..." + getOptionLabel={(option) => `${option.owner.login}/${option.name}`} + getOptionValue={(option) => option.id.toString()} + /> + </FormControl> + )} + control={control} + name="destinationConfig.selectedRepositoryIds" + /> + )} + </> + )} + {currentScope !== GitHubSyncScope.Organization && ( + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl isError={Boolean(error)} errorText={error?.message} label="Repository"> + <FilterableSelect + menuPlacement="top" + isLoading={isRepositoriesPending && Boolean(connectionId)} + isDisabled={!connectionId} + value={repositories.find((repo) => repo.name === value) ?? null} + onChange={(option) => { + const repo = option as SingleValue<TGitHubConnectionRepository>; + + onChange(repo?.name); + setValue("destinationConfig.owner", repo?.owner.login ?? ""); + setValue("destinationConfig.env", ""); + }} + options={repositories} + placeholder="Select a repository..." + getOptionLabel={(option) => `${option.owner.login}/${option.name}`} + getOptionValue={(option) => option.id.toString()} + /> + </FormControl> + )} + control={control} + name="destinationConfig.repo" + /> + )} + {currentScope === GitHubSyncScope.RepositoryEnvironment && ( + <Controller + name="destinationConfig.env" + control={control} + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl isError={Boolean(error)} errorText={error?.message} label="Environment"> + <FilterableSelect + menuPlacement="top" + isLoading={isEnvironmentsPending && Boolean(connectionId) && Boolean(currentRepo)} + isDisabled={!connectionId || !currentRepo} + value={environments.find((env) => env.name === value) ?? null} + onChange={(option) => + onChange((option as SingleValue<TGitHubConnectionEnvironment>)?.name ?? null) + } + options={environments} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + </FormControl> + )} + /> + )} + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx new file mode 100644 index 0000000000..8edca89fbf --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -0,0 +1,22 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; +import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; +import { GitHubSyncFields } from "./GitHubSyncFields"; + +export const SecretSyncDestinationFields = () => { + const { watch } = useFormContext<TSecretSyncForm>(); + + const destination = watch("destination"); + + switch (destination) { + case SecretSync.AWSParameterStore: + return <AwsParameterStoreSyncFields />; + case SecretSync.GitHub: + return <GitHubSyncFields />; + default: + throw new Error(`Unhandled Destination Config Field: ${destination}`); + } +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/index.ts b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/index.ts new file mode 100644 index 0000000000..1d68e01c68 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncDestinationFields"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDetailsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDetailsFields.tsx new file mode 100644 index 0000000000..8af512bfad --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TSecretSyncForm } from "./schemas"; + +export const SecretSyncDetailsFields = () => { + const { control } = useFormContext<TSecretSyncForm>(); + + return ( + <> + <p className="mb-4 text-sm text-bunker-300"> + Provide a name and description for this Secret Sync. + </p> + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + helperText="Must be slug-friendly" + isError={Boolean(error)} + errorText={error?.message} + label="Name" + > + <Input value={value} onChange={onChange} placeholder="my-secret-sync" /> + </FormControl> + )} + control={control} + name="name" + /> + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + isError={Boolean(error)} + isOptional + errorText={error?.message} + label="Description" + > + <TextArea + value={value} + onChange={onChange} + placeholder="Describe the purpose of this sync..." + className="!resize-none" + rows={4} + /> + </FormControl> + )} + control={control} + name="description" + /> + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields.tsx new file mode 100644 index 0000000000..6bfef2f71d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields.tsx @@ -0,0 +1,124 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FormControl, Select, SelectItem } from "@app/components/v2"; +import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { useSecretSyncOption } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "./schemas"; + +type Props = { + hideInitialSync?: boolean; +}; + +export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { + const { control, watch } = useFormContext<TSecretSyncForm>(); + + const destination = watch("destination"); + + const destinationName = SECRET_SYNC_MAP[destination].name; + + const { syncOption } = useSecretSyncOption(destination); + + return ( + <> + <p className="mb-4 text-sm text-bunker-300">Configure how secrets should be synced.</p> + {!hideInitialSync && ( + <> + <Controller + name="syncOptions.initialSyncBehavior" + control={control} + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + tooltipClassName="max-w-lg py-3" + tooltipText={ + syncOption?.canImportSecrets ? ( + <div className="flex flex-col gap-3"> + <p> + Specify how Infisical should resolve the initial sync to {destinationName}. + The following options are available: + </p> + <ul className="flex list-disc flex-col gap-3 pl-4"> + {Object.values(SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP).map((details) => { + const { name, description } = details(destinationName); + + return ( + <li key={name}> + <p className="text-mineshaft-300"> + <span className="font-medium text-bunker-200">{name}</span>:{" "} + {description} + </p> + </li> + ); + })} + </ul> + </div> + ) : undefined + } + errorText={error?.message} + isError={Boolean(error?.message)} + label="Initial Sync Behavior" + > + <Select + isDisabled={!syncOption?.canImportSecrets} + value={value} + onValueChange={(val) => onChange(val)} + className="w-full border border-mineshaft-500" + position="popper" + placeholder="Select an option..." + dropdownContainerClassName="max-w-none" + > + {Object.entries(SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP).map(([key, details]) => { + const { name } = details(destinationName); + + return ( + <SelectItem value={key} key={key}> + {name} + </SelectItem> + ); + })} + </Select> + </FormControl> + )} + /> + {!syncOption?.canImportSecrets && ( + <p className="-mt-2.5 mb-2.5 text-xs text-yellow"> + <FontAwesomeIcon className="mr-1" size="xs" icon={faTriangleExclamation} /> + {destinationName} only supports overwriting destination secrets. Secrets not present + in Infisical will be removed from the destination. + </p> + )} + </> + )} + {/* <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + isError={Boolean(error)} + isOptional + errorText={error?.message} + label="Prepend Prefix" + > + <Input className="uppercase" value={value} onChange={onChange} placeholder="INF_" /> + </FormControl> + )} + control={control} + name="syncOptions.prependPrefix" + /> + <Controller + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl + isError={Boolean(error)} + isOptional + errorText={error?.message} + label="Append Suffix" + > + <Input className="uppercase" value={value} onChange={onChange} placeholder="_INF" /> + </FormControl> + )} + control={control} + name="syncOptions.appendSuffix" + /> */} + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx new file mode 100644 index 0000000000..253cae69d6 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx @@ -0,0 +1,29 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { Badge } from "@app/components/v2"; +import { AWS_REGIONS } from "@app/helpers/appConnections"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AwsParameterStoreSyncReviewFields = () => { + const { watch } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AWSParameterStore } + >(); + + const [region, path] = watch(["destinationConfig.region", "destinationConfig.path"]); + + const awsRegion = AWS_REGIONS.find((r) => r.slug === region); + + return ( + <> + <SecretSyncLabel label="Region"> + {awsRegion?.name} + <Badge className="ml-1" variant="success"> + {awsRegion?.slug}{" "} + </Badge> + </SecretSyncLabel> + <SecretSyncLabel label="Path">{path}</SecretSyncLabel> + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx new file mode 100644 index 0000000000..513a2762f6 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitHubSyncReviewFields.tsx @@ -0,0 +1,61 @@ +import { ReactNode } from "react"; +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync"; + +export const GitHubSyncReviewFields = () => { + const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.GitHub }>(); + + const config = watch("destinationConfig"); + + let ScopeComponents: ReactNode; + + switch (config.scope) { + case GitHubSyncScope.Repository: + ScopeComponents = ( + <SecretSyncLabel label="Repository"> + {config.owner}/{config.repo} + </SecretSyncLabel> + ); + break; + case GitHubSyncScope.Organization: + ScopeComponents = ( + <> + <SecretSyncLabel label="Organization">{config.org}</SecretSyncLabel> + <SecretSyncLabel className="capitalize" label="Visibility"> + {config.visibility} + </SecretSyncLabel> + </> + ); + break; + case GitHubSyncScope.RepositoryEnvironment: + ScopeComponents = ( + <> + <SecretSyncLabel label="Repository"> + {config.owner}/{config.repo} + </SecretSyncLabel> + <SecretSyncLabel className="capitalize" label="Environment"> + {config.env} + </SecretSyncLabel> + </> + ); + + break; + default: + throw new Error( + `Unhandled GitHub Sync Review Field Scope ${(config as TGitHubSync["destinationConfig"]).scope}` + ); + } + + return ( + <> + <SecretSyncLabel className="capitalize" label="Scope"> + {config.scope.replace("-", " ")} + </SecretSyncLabel> + {ScopeComponents} + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx new file mode 100644 index 0000000000..4b198eb7e5 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -0,0 +1,93 @@ +import { ReactNode } from "react"; +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { Badge } from "@app/components/v2"; +import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields"; +import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; + +export const SecretSyncReviewFields = () => { + const { watch } = useFormContext<TSecretSyncForm>(); + + let DestinationFieldsComponent: ReactNode; + + const { + name, + description, + connection, + environment, + secretPath, + syncOptions: { + // appendSuffix, prependPrefix, + initialSyncBehavior + }, + destination, + isAutoSyncEnabled + } = watch(); + + const destinationName = SECRET_SYNC_MAP[destination].name; + + switch (destination) { + case SecretSync.AWSParameterStore: + DestinationFieldsComponent = <AwsParameterStoreSyncReviewFields />; + break; + case SecretSync.GitHub: + DestinationFieldsComponent = <GitHubSyncReviewFields />; + break; + default: + throw new Error(`Unhandled Destination Review Fields: ${destination}`); + } + + return ( + <div className="mb-4 flex flex-col gap-6"> + <div className="flex flex-col gap-3"> + <div className="w-full border-b border-mineshaft-600"> + <span className="text-sm text-mineshaft-300">Source</span> + </div> + <div className="flex flex-wrap gap-x-8 gap-y-2"> + <SecretSyncLabel label="Environment">{environment.name}</SecretSyncLabel> + <SecretSyncLabel label="Secret Path">{secretPath}</SecretSyncLabel> + </div> + </div> + <div className="flex flex-col gap-3"> + <div className="w-full border-b border-mineshaft-600"> + <span className="text-sm text-mineshaft-300">Destination</span> + </div> + <div className="flex flex-wrap gap-x-8 gap-y-2"> + <SecretSyncLabel label="Connection">{connection.name}</SecretSyncLabel> + {DestinationFieldsComponent} + </div> + </div> + <div className="flex flex-col gap-3"> + <div className="w-full border-b border-mineshaft-600"> + <span className="text-sm text-mineshaft-300">Options</span> + </div> + <div className="flex flex-wrap gap-x-8 gap-y-2"> + <SecretSyncLabel label="Auto-Sync"> + <Badge variant={isAutoSyncEnabled ? "success" : "danger"}> + {isAutoSyncEnabled ? "Enabled" : "Disabled"} + </Badge> + </SecretSyncLabel> + <SecretSyncLabel label="Initial Sync Behavior"> + {SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destinationName).name} + </SecretSyncLabel> + {/* <SecretSyncLabel label="Prepend Prefix">{prependPrefix}</SecretSyncLabel> + <SecretSyncLabel label="Append Suffix">{appendSuffix}</SecretSyncLabel> */} + </div> + </div> + <div className="flex flex-col gap-3"> + <div className="w-full border-b border-mineshaft-600"> + <span className="text-sm text-mineshaft-300">Details</span> + </div> + <div className="flex flex-wrap gap-x-8 gap-y-2"> + <SecretSyncLabel label="Name">{name}</SecretSyncLabel> + <SecretSyncLabel label="Description">{description}</SecretSyncLabel> + </div> + </div> + </div> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/index.ts b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/index.ts new file mode 100644 index 0000000000..3decaeddaf --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncReviewFields"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx new file mode 100644 index 0000000000..7cc19ae971 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx @@ -0,0 +1,55 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useWorkspace } from "@app/context"; + +import { TSecretSyncForm } from "./schemas"; + +export const SecretSyncSourceFields = () => { + const { control, watch } = useFormContext<TSecretSyncForm>(); + + const { currentWorkspace } = useWorkspace(); + + const selectedEnvironment = watch("environment"); + + return ( + <> + <p className="mb-4 text-sm text-bunker-300"> + Specify the environment and path where you would like to sync secrets from. + </p> + + <Controller + defaultValue={currentWorkspace.environments[0]} + control={control} + name="environment" + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl label="Environment" isError={Boolean(error)} errorText={error?.message}> + <FilterableSelect + value={value} + onChange={onChange} + options={currentWorkspace.environments} + placeholder="Select environment..." + getOptionLabel={(option) => option?.name} + getOptionValue={(option) => option?.id} + /> + </FormControl> + )} + /> + <Controller + defaultValue="/" + render={({ field: { value, onChange }, fieldState: { error } }) => ( + <FormControl isError={Boolean(error)} errorText={error?.message} label="Secret Path"> + <SecretPathInput + environment={selectedEnvironment?.slug} + value={value} + onChange={onChange} + /> + </FormControl> + )} + control={control} + name="secretPath" + /> + </> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/index.ts b/frontend/src/components/secret-syncs/forms/index.ts new file mode 100644 index 0000000000..a94aea6692 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/index.ts @@ -0,0 +1,2 @@ +export * from "./CreateSecretSyncForm"; +export * from "./EditSecretSyncForm"; diff --git a/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts new file mode 100644 index 0000000000..68abb77933 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AwsParameterStoreSyncDestinationSchema = z.object({ + destination: z.literal(SecretSync.AWSParameterStore), + destinationConfig: z.object({ + path: z + .string() + .trim() + .min(1, "Parameter Store Path required") + .max(2048, "Cannot exceed 2048 characters") + .regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format'), + region: z.string().min(1, "Region required") + }) +}); diff --git a/frontend/src/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts new file mode 100644 index 0000000000..ccebc946c8 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { + GitHubSyncScope, + GitHubSyncVisibility +} from "@app/hooks/api/secretSyncs/types/github-sync"; + +export const GitHubSyncDestinationSchema = z.object({ + destination: z.literal(SecretSync.GitHub), + destinationConfig: z + .discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitHubSyncScope.Organization), + org: z.string().min(1, "Organization name required"), + visibility: z.nativeEnum(GitHubSyncVisibility), + selectedRepositoryIds: z.number().array().optional() + }), + z.object({ + scope: z.literal(GitHubSyncScope.Repository), + owner: z.string().min(1, "Repository owner name required"), + repo: z.string().min(1, "Repository name required") + }), + z.object({ + scope: z.literal(GitHubSyncScope.RepositoryEnvironment), + owner: z.string().min(1, "Repository owner name required"), + repo: z.string().min(1, "Repository name required"), + env: z.string().min(1, "Environment name required") + }) + ]) + .superRefine((options, ctx) => { + if (options.scope === GitHubSyncScope.Organization) { + if ( + options.visibility === GitHubSyncVisibility.Selected && + !options.selectedRepositoryIds?.length + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select at least 1 repository", + path: ["selectedRepositoryIds"] + }); + } + } + }) +}); diff --git a/frontend/src/components/secret-syncs/forms/schemas/index.ts b/frontend/src/components/secret-syncs/forms/schemas/index.ts new file mode 100644 index 0000000000..25c9c38d84 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/index.ts @@ -0,0 +1 @@ +export * from "./secret-sync-schema"; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts new file mode 100644 index 0000000000..2ae2bc3709 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema"; +import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; +import { slugSchema } from "@app/lib/schemas"; + +import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; + +const BaseSecretSyncSchema = z.object({ + name: slugSchema({ field: "Name" }), + description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(), + connection: z.object({ name: z.string(), id: z.string().uuid() }), + environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }), + secretPath: z.string().min(1, "Secret path required"), + syncOptions: z.object({ + initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior) + // scott: removed temporarily for evaluation of template formatting + // prependPrefix: z + // .string() + // .trim() + // .transform((str) => str.toUpperCase()) + // .optional(), + // appendSuffix: z + // .string() + // .trim() + // .transform((str) => str.toUpperCase()) + // .optional() + }), + isAutoSyncEnabled: z.boolean() +}); + +const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ + AwsParameterStoreSyncDestinationSchema, + GitHubSyncDestinationSchema +]); + +export const SecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema); + +export const UpdateSecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema.partial()); + +export type TSecretSyncForm = z.infer<typeof SecretSyncFormSchema>; diff --git a/frontend/src/components/secret-syncs/github/GitHubSyncSelectedRepositoriesTooltipContent.tsx b/frontend/src/components/secret-syncs/github/GitHubSyncSelectedRepositoriesTooltipContent.tsx new file mode 100644 index 0000000000..7e1876adac --- /dev/null +++ b/frontend/src/components/secret-syncs/github/GitHubSyncSelectedRepositoriesTooltipContent.tsx @@ -0,0 +1,41 @@ +import { twMerge } from "tailwind-merge"; + +import { useGitHubConnectionListRepositories } from "@app/hooks/api/appConnections/github"; +import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync"; + +type Props = { + secretSync: TGitHubSync; +}; + +export const GitHubSyncSelectedRepositoriesTooltipContent = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + const showRepositories = + destinationConfig.scope === GitHubSyncScope.Organization && + Boolean(destinationConfig.selectedRepositoryIds?.length); + + const { data: repositories, isPending } = useGitHubConnectionListRepositories( + secretSync.connectionId, + { + enabled: showRepositories + } + ); + + if (destinationConfig.scope === GitHubSyncScope.Organization) { + return ( + <> + <span className="text-xs text-bunker-300">Repositories:</span> + <p className={twMerge("text-sm", isPending && "text-mineshaft-400")}> + {isPending + ? "Loading..." + : repositories + ?.filter((repo) => destinationConfig?.selectedRepositoryIds?.includes(repo.id)) + .map((repo) => repo.name) + .join(", ")} + </p> + </> + ); + } + + return null; +}; diff --git a/frontend/src/components/secret-syncs/github/index.ts b/frontend/src/components/secret-syncs/github/index.ts new file mode 100644 index 0000000000..df66220b33 --- /dev/null +++ b/frontend/src/components/secret-syncs/github/index.ts @@ -0,0 +1 @@ +export * from "./GitHubSyncSelectedRepositoriesTooltipContent"; diff --git a/frontend/src/components/secret-syncs/index.ts b/frontend/src/components/secret-syncs/index.ts new file mode 100644 index 0000000000..74c1eeca36 --- /dev/null +++ b/frontend/src/components/secret-syncs/index.ts @@ -0,0 +1,9 @@ +export * from "./CreateSecretSyncModal"; +export * from "./DeleteSecretSyncModal"; +export * from "./EditSecretSyncModal"; +export * from "./SecretSyncImportSecretsModal"; +export * from "./SecretSyncImportStatusBadge"; +export * from "./SecretSyncLabel"; +export * from "./SecretSyncRemoveSecretsModal"; +export * from "./SecretSyncRemoveStatusBadge"; +export * from "./SecretSyncStatusBadge"; diff --git a/frontend/src/components/secret-syncs/types/index.ts b/frontend/src/components/secret-syncs/types/index.ts new file mode 100644 index 0000000000..e016180b3e --- /dev/null +++ b/frontend/src/components/secret-syncs/types/index.ts @@ -0,0 +1,6 @@ +export enum SecretSyncEditFields { + Details = "details", + Options = "options", + Source = "source", + Destination = "destination" +} diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 2ef6b81f7c..9b7baa9210 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge"; import { Card, CardBody, CardFooter, CardTitle } from "../Card"; import { IconButton } from "../IconButton"; -export type ModalContentProps = DialogPrimitive.DialogContentProps & { +export type ModalContentProps = Omit<DialogPrimitive.DialogContentProps, "title"> & { title?: ReactNode; subTitle?: ReactNode; footerContent?: ReactNode; diff --git a/frontend/src/components/v2/Switch/Switch.tsx b/frontend/src/components/v2/Switch/Switch.tsx index ce955354ab..c781025982 100644 --- a/frontend/src/components/v2/Switch/Switch.tsx +++ b/frontend/src/components/v2/Switch/Switch.tsx @@ -9,6 +9,7 @@ export type SwitchProps = Omit<SwitchPrimitive.SwitchProps, "checked" | "disable isRequired?: boolean; isDisabled?: boolean; containerClassName?: string; + thumbClassName?: string; }; export const Switch = ({ @@ -19,6 +20,7 @@ export const Switch = ({ isDisabled, isRequired, containerClassName, + thumbClassName, ...props }: SwitchProps): JSX.Element => ( <div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}> @@ -38,7 +40,12 @@ export const Switch = ({ )} id={id} > - <SwitchPrimitive.Thumb className="block h-4 w-4 translate-x-0.5 rounded-full border-none bg-black shadow transition-all will-change-transform data-[state=checked]:translate-x-[18px]" /> + <SwitchPrimitive.Thumb + className={twMerge( + "block h-4 w-4 translate-x-0.5 rounded-full border-none bg-black shadow transition-all will-change-transform data-[state=checked]:translate-x-[18px]", + thumbClassName + )} + /> </SwitchPrimitive.Root> </div> ); diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 1ddec1377a..03e077a8de 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -64,10 +64,18 @@ export const ROUTE_PATHS = Object.freeze({ "/secret-manager/$projectId/overview", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/overview" ), + IntegrationsListPage: setRoute( + "/secret-manager/$projectId/integrations", + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/" + ), IntegrationDetailsByIDPage: setRoute( "/secret-manager/$projectId/integrations/$integrationId", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId" ), + SecretSyncDetailsByIDPage: setRoute( + "/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId", + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId" + ), Integratons: { SelectIntegrationAuth: setRoute( "/secret-manager/$projectId/integrations/select-integration-auth", diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 4480bbad82..ea5003c8ce 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -31,6 +31,18 @@ export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } +export enum OrgPermissionAppConnectionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + Connect = "connect" +} + +export type AppConnectionSubjectFields = { + connectionId: string; +}; + export type OrgPermissionSet = | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] | [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace] @@ -49,6 +61,14 @@ export type OrgPermissionSet = | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] - | [OrgPermissionActions, OrgPermissionSubjects.AppConnections]; + | [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections]; +// TODO(scott): add back once org UI refactored +// | [ +// OrgPermissionAppConnectionActions, +// ( +// | OrgPermissionSubjects.AppConnections +// | (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields) +// ) +// ]; export type TOrgPermission = MongoAbility<OrgPermissionSet>; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index b0c3aa4637..279e69889a 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,6 +24,16 @@ export enum ProjectPermissionCmekActions { Decrypt = "decrypt" } +export enum ProjectPermissionSecretSyncActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SyncSecrets = "sync-secrets", + ImportSecrets = "import-secrets", + RemoveSecrets = "remove-secrets" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -91,7 +101,8 @@ export enum ProjectPermissionSub { PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", - Cmek = "cmek" + Cmek = "cmek", + SecretSyncs = "secret-syncs" } export type SecretSubjectFields = { @@ -173,6 +184,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] + | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 9d52fb14e1..25551e3784 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -27,3 +27,35 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) throw new Error(`Unhandled App Connection Method: ${method}`); } }; + +export const AWS_REGIONS = [ + { name: "US East (Ohio)", slug: "us-east-2" }, + { name: "US East (N. Virginia)", slug: "us-east-1" }, + { name: "US West (N. California)", slug: "us-west-1" }, + { name: "US West (Oregon)", slug: "us-west-2" }, + { name: "Africa (Cape Town)", slug: "af-south-1" }, + { name: "Asia Pacific (Hong Kong)", slug: "ap-east-1" }, + { name: "Asia Pacific (Hyderabad)", slug: "ap-south-2" }, + { name: "Asia Pacific (Jakarta)", slug: "ap-southeast-3" }, + { name: "Asia Pacific (Melbourne)", slug: "ap-southeast-4" }, + { name: "Asia Pacific (Mumbai)", slug: "ap-south-1" }, + { name: "Asia Pacific (Osaka)", slug: "ap-northeast-3" }, + { name: "Asia Pacific (Seoul)", slug: "ap-northeast-2" }, + { name: "Asia Pacific (Singapore)", slug: "ap-southeast-1" }, + { name: "Asia Pacific (Sydney)", slug: "ap-southeast-2" }, + { name: "Asia Pacific (Tokyo)", slug: "ap-northeast-1" }, + { name: "Canada (Central)", slug: "ca-central-1" }, + { name: "Europe (Frankfurt)", slug: "eu-central-1" }, + { name: "Europe (Ireland)", slug: "eu-west-1" }, + { name: "Europe (London)", slug: "eu-west-2" }, + { name: "Europe (Milan)", slug: "eu-south-1" }, + { name: "Europe (Paris)", slug: "eu-west-3" }, + { name: "Europe (Spain)", slug: "eu-south-2" }, + { name: "Europe (Stockholm)", slug: "eu-north-1" }, + { name: "Europe (Zurich)", slug: "eu-central-2" }, + { name: "Middle East (Bahrain)", slug: "me-south-1" }, + { name: "Middle East (UAE)", slug: "me-central-1" }, + { name: "South America (Sao Paulo)", slug: "sa-east-1" }, + { name: "AWS GovCloud (US-East)", slug: "us-gov-east-1" }, + { name: "AWS GovCloud (US-West)", slug: "us-gov-west-1" } +]; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts new file mode 100644 index 0000000000..f46f0f2302 --- /dev/null +++ b/frontend/src/helpers/secretSyncs.ts @@ -0,0 +1,48 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + SecretSync, + SecretSyncImportBehavior, + SecretSyncInitialSyncBehavior +} from "@app/hooks/api/secretSyncs"; + +export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }> = { + [SecretSync.AWSParameterStore]: { name: "Parameter Store", image: "Amazon Web Services.png" }, + [SecretSync.GitHub]: { name: "GitHub", image: "GitHub.png" } +}; + +export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = { + [SecretSync.AWSParameterStore]: AppConnection.AWS, + [SecretSync.GitHub]: AppConnection.GitHub +}; + +export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< + SecretSyncInitialSyncBehavior, + (destinationName: string) => { name: string; description: string } +> = { + [SecretSyncInitialSyncBehavior.OverwriteDestination]: (destinationName: string) => ({ + name: "Overwrite Destination Secrets", + description: `Infisical will overwrite any secrets located in the ${destinationName} destination, removing any secrets that are not present within Infiscal. ` + }), + [SecretSyncInitialSyncBehavior.ImportPrioritizeSource]: (destinationName: string) => ({ + name: "Import Destination Secrets - Prioritize Infisical Values", + description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in Infisical over ${destinationName}.` + }), + [SecretSyncInitialSyncBehavior.ImportPrioritizeDestination]: (destinationName: string) => ({ + name: `Import Destination Secrets - Prioritize ${destinationName} Values`, + description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in ${destinationName} over Infisical.` + }) +}; + +export const SECRET_SYNC_IMPORT_BEHAVIOR_MAP: Record< + SecretSyncImportBehavior, + (destinationName: string) => { name: string; description: string } +> = { + [SecretSyncImportBehavior.PrioritizeSource]: (destinationName: string) => ({ + name: "Prioritize Infisical Values", + description: `Infisical will import any secrets present in the ${destinationName} destination, prioritizing values present in Infisical over ${destinationName}.` + }), + [SecretSyncImportBehavior.PrioritizeDestination]: (destinationName: string) => ({ + name: `Prioritize ${destinationName} Values`, + description: `Infisical will import any secrets present in the ${destinationName} destination, prioritizing values present in ${destinationName} over Infisical.` + }) +}; diff --git a/frontend/src/hooks/api/appConnections/github/index.ts b/frontend/src/hooks/api/appConnections/github/index.ts new file mode 100644 index 0000000000..2c1906d369 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/github/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/github/queries.tsx b/frontend/src/hooks/api/appConnections/github/queries.tsx new file mode 100644 index 0000000000..e042dc5e7d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/github/queries.tsx @@ -0,0 +1,105 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { + TGitHubConnectionEnvironment, + TGitHubConnectionListEnvironmentsResponse, + TGitHubConnectionListOrganizationsResponse, + TGitHubConnectionListRepositoriesResponse, + TGitHubConnectionOrganization, + TGitHubConnectionRepository, + TListGitHubConnectionEnvironments +} from "./types"; + +const githubConnectionKeys = { + all: [...appConnectionKeys.all, "github"] as const, + listRepositories: (connectionId: string) => + [...githubConnectionKeys.all, "repositories", connectionId] as const, + listOrganizations: (connectionId: string) => + [...githubConnectionKeys.all, "organizations", connectionId] as const, + listEnvironments: ({ connectionId, repo, owner }: TListGitHubConnectionEnvironments) => + [...githubConnectionKeys.all, "environments", connectionId, repo, owner] as const +}; + +export const useGitHubConnectionListRepositories = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TGitHubConnectionRepository[], + unknown, + TGitHubConnectionRepository[], + ReturnType<typeof githubConnectionKeys.listRepositories> + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: githubConnectionKeys.listRepositories(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<TGitHubConnectionListRepositoriesResponse>( + `/api/v1/app-connections/github/${connectionId}/repositories` + ); + + return data.repositories; + }, + ...options + }); +}; + +export const useGitHubConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TGitHubConnectionOrganization[], + unknown, + TGitHubConnectionOrganization[], + ReturnType<typeof githubConnectionKeys.listOrganizations> + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: githubConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<TGitHubConnectionListOrganizationsResponse>( + `/api/v1/app-connections/github/${connectionId}/organizations` + ); + + return data.organizations; + }, + ...options + }); +}; + +export const useGitHubConnectionListEnvironments = ( + { connectionId, repo, owner }: TListGitHubConnectionEnvironments, + options?: Omit< + UseQueryOptions< + TGitHubConnectionEnvironment[], + unknown, + TGitHubConnectionEnvironment[], + ReturnType<typeof githubConnectionKeys.listEnvironments> + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: githubConnectionKeys.listEnvironments({ connectionId, repo, owner }), + queryFn: async () => { + const { data } = await apiRequest.get<TGitHubConnectionListEnvironmentsResponse>( + `/api/v1/app-connections/github/${connectionId}/environments`, + { + params: { + repo, + owner + } + } + ); + + return data.environments; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/github/types.ts b/frontend/src/hooks/api/appConnections/github/types.ts new file mode 100644 index 0000000000..fab8ad7125 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/github/types.ts @@ -0,0 +1,33 @@ +export type TGitHubConnectionOrganization = { + login: string; + id: number; +}; + +export type TGitHubConnectionRepository = { + id: number; + name: string; + owner: TGitHubConnectionOrganization; +}; + +export type TGitHubConnectionEnvironment = { + id: number; + name: string; +}; + +export type TGitHubConnectionListRepositoriesResponse = { + repositories: TGitHubConnectionRepository[]; +}; + +export type TGitHubConnectionListOrganizationsResponse = { + organizations: TGitHubConnectionOrganization[]; +}; + +export type TGitHubConnectionListEnvironmentsResponse = { + environments: TGitHubConnectionEnvironment[]; +}; + +export type TListGitHubConnectionEnvironments = { + connectionId: string; + repo: string; + owner: string; +}; diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx index e18c1facd3..981a8dbe12 100644 --- a/frontend/src/hooks/api/appConnections/queries.tsx +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -7,6 +7,8 @@ import { TAppConnection, TAppConnectionMap, TAppConnectionOptions, + TAvailableAppConnection, + TAvailableAppConnectionsResponse, TGetAppConnection, TListAppConnections } from "@app/hooks/api/appConnections/types"; @@ -19,9 +21,10 @@ export const appConnectionKeys = { all: ["app-connection"] as const, options: () => [...appConnectionKeys.all, "options"] as const, list: () => [...appConnectionKeys.all, "list"] as const, + listAvailable: (app: AppConnection) => [...appConnectionKeys.all, app, "list-available"] as const, listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app], - byId: (app: AppConnection, templateId: string) => - [...appConnectionKeys.all, app, "by-id", templateId] as const + byId: (app: AppConnection, connectionId: string) => + [...appConnectionKeys.all, app, "by-id", connectionId] as const }; export const useAppConnectionOptions = ( @@ -83,6 +86,31 @@ export const useListAppConnections = ( }); }; +export const useListAvailableAppConnections = ( + app: AppConnection, + options?: Omit< + UseQueryOptions< + TAvailableAppConnection[], + unknown, + TAvailableAppConnection[], + ReturnType<typeof appConnectionKeys.listAvailable> + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.listAvailable(app), + queryFn: async () => { + const { data } = await apiRequest.get<TAvailableAppConnectionsResponse>( + `/api/v1/app-connections/${app}/available` + ); + + return data.appConnections; + }, + ...options + }); +}; + export const useListAppConnectionsByApp = <T extends AppConnection>( app: T, options?: Omit< diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index fcec4a1df5..64bd41015f 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -8,10 +8,13 @@ export * from "./github-connection"; export type TAppConnection = TAwsConnection | TGitHubConnection; +export type TAvailableAppConnection = Pick<TAppConnection, "name" | "app" | "id">; + export type TListAppConnections<T extends TAppConnection> = { appConnections: T[] }; export type TGetAppConnection<T extends TAppConnection> = { appConnection: T }; export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] }; export type TAppConnectionResponse = { appConnection: TAppConnection }; +export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppConnection[] }; export type TCreateAppConnectionDTO = Pick< TAppConnection, diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 650798bc7f..c736a564e8 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -85,7 +85,36 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.INTEGRATION_SYNCED]: "Integration sync", [EventType.CREATE_SHARED_SECRET]: "Create shared secret", [EventType.DELETE_SHARED_SECRET]: "Delete shared secret", - [EventType.READ_SHARED_SECRET]: "Read shared secret" + [EventType.READ_SHARED_SECRET]: "Read shared secret", + [EventType.CREATE_CMEK]: "Create KMS key", + [EventType.UPDATE_CMEK]: "Update KMS key", + [EventType.DELETE_CMEK]: "Delete KMS key", + [EventType.GET_CMEKS]: "List KMS keys", + [EventType.CMEK_ENCRYPT]: "Encrypt with KMS key", + [EventType.CMEK_DECRYPT]: "Decrypt with KMS key", + [EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: + "Update SSO group to organization role mapping", + [EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping", + [EventType.GET_PROJECT_TEMPLATES]: "List project templates", + [EventType.GET_PROJECT_TEMPLATE]: "Get project template", + [EventType.CREATE_PROJECT_TEMPLATE]: "Create project template", + [EventType.UPDATE_PROJECT_TEMPLATE]: "Update project template", + [EventType.DELETE_PROJECT_TEMPLATE]: "Delete project template", + [EventType.APPLY_PROJECT_TEMPLATE]: "Apply project template", + [EventType.GET_APP_CONNECTIONS]: "List App Connections", + [EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS]: "List App Connections Details", + [EventType.GET_APP_CONNECTION]: "Get App Connection", + [EventType.CREATE_APP_CONNECTION]: "Create App Connection", + [EventType.UPDATE_APP_CONNECTION]: "Update App Connection", + [EventType.DELETE_APP_CONNECTION]: "Delete App Connection", + [EventType.GET_SECRET_SYNCS]: "List Secret Syncs", + [EventType.GET_SECRET_SYNC]: "Get Secret Sync", + [EventType.CREATE_SECRET_SYNC]: "Create Secret Sync", + [EventType.UPDATE_SECRET_SYNC]: "Update Secret Sync", + [EventType.DELETE_SECRET_SYNC]: "Delete Secret Sync", + [EventType.SECRET_SYNC_SYNC_SECRETS]: "Secret Sync synced secrets", + [EventType.SECRET_SYNC_IMPORT_SECRETS]: "Secret Sync imported secrets", + [EventType.SECRET_SYNC_REMOVE_SECRETS]: "Secret Sync removed secrets" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index c2c306a965..7219a446ea 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -99,5 +99,33 @@ export enum EventType { INTEGRATION_SYNCED = "integration-synced", CREATE_SHARED_SECRET = "create-shared-secret", DELETE_SHARED_SECRET = "delete-shared-secret", - READ_SHARED_SECRET = "read-shared-secret" + READ_SHARED_SECRET = "read-shared-secret", + CREATE_CMEK = "create-cmek", + UPDATE_CMEK = "update-cmek", + DELETE_CMEK = "delete-cmek", + GET_CMEKS = "get-cmeks", + CMEK_ENCRYPT = "cmek-encrypt", + CMEK_DECRYPT = "cmek-decrypt", + UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", + GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", + GET_PROJECT_TEMPLATES = "get-project-templates", + GET_PROJECT_TEMPLATE = "get-project-template", + CREATE_PROJECT_TEMPLATE = "create-project-template", + UPDATE_PROJECT_TEMPLATE = "update-project-template", + DELETE_PROJECT_TEMPLATE = "delete-project-template", + APPLY_PROJECT_TEMPLATE = "apply-project-template", + GET_APP_CONNECTIONS = "get-app-connections", + GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details", + GET_APP_CONNECTION = "get-app-connection", + CREATE_APP_CONNECTION = "create-app-connection", + UPDATE_APP_CONNECTION = "update-app-connection", + DELETE_APP_CONNECTION = "delete-app-connection", + GET_SECRET_SYNCS = "get-secret-syncs", + GET_SECRET_SYNC = "get-secret-sync", + CREATE_SECRET_SYNC = "create-secret-sync", + UPDATE_SECRET_SYNC = "update-secret-sync", + DELETE_SECRET_SYNC = "delete-secret-sync", + SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets", + SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets", + SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets" } diff --git a/frontend/src/hooks/api/reactQuery.tsx b/frontend/src/hooks/api/reactQuery.tsx index 095ad1a7aa..d6ffc93785 100644 --- a/frontend/src/hooks/api/reactQuery.tsx +++ b/frontend/src/hooks/api/reactQuery.tsx @@ -182,7 +182,7 @@ export const queryClient = new QueryClient({ createNotification({ title: "Bad Request", type: "error", - text: `${serverResponse.message}.`, + text: `${serverResponse.message}${serverResponse.message.endsWith(".") ? "" : "."}`, copyActions: [ { value: serverResponse.reqId, diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts new file mode 100644 index 0000000000..b5211eedc0 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -0,0 +1,22 @@ +export enum SecretSync { + AWSParameterStore = "aws-parameter-store", + GitHub = "github" +} + +export enum SecretSyncStatus { + Pending = "pending", + Running = "running", + Succeeded = "succeeded", + Failed = "failed" +} + +export enum SecretSyncInitialSyncBehavior { + OverwriteDestination = "overwrite-destination", + ImportPrioritizeSource = "import-prioritize-source", + ImportPrioritizeDestination = "import-prioritize-destination" +} + +export enum SecretSyncImportBehavior { + PrioritizeSource = "prioritize-source", + PrioritizeDestination = "prioritize-destination" +} diff --git a/frontend/src/hooks/api/secretSyncs/index.ts b/frontend/src/hooks/api/secretSyncs/index.ts new file mode 100644 index 0000000000..f49a872a5a --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/index.ts @@ -0,0 +1,4 @@ +export * from "./enums"; +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/secretSyncs/mutations.tsx b/frontend/src/hooks/api/secretSyncs/mutations.tsx new file mode 100644 index 0000000000..1efab66886 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/mutations.tsx @@ -0,0 +1,118 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { secretSyncKeys } from "@app/hooks/api/secretSyncs/queries"; +import { + TCreateSecretSyncDTO, + TDeleteSecretSyncDTO, + TSecretSyncResponse, + TTriggerSecretSyncImportSecretsDTO, + TTriggerSecretSyncRemoveSecretsDTO, + TTriggerSecretSyncSyncSecretsDTO, + TUpdateSecretSyncDTO +} from "@app/hooks/api/secretSyncs/types"; + +export const useCreateSecretSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ destination, ...params }: TCreateSecretSyncDTO) => { + const { data } = await apiRequest.post<TSecretSyncResponse>( + `/api/v1/secret-syncs/${destination}`, + params + ); + + return data.secretSync; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() }) + }); +}; + +export const useUpdateSecretSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ syncId, destination, ...params }: TUpdateSecretSyncDTO) => { + const { data } = await apiRequest.patch<TSecretSyncResponse>( + `/api/v1/secret-syncs/${destination}/${syncId}`, + params + ); + + return data.secretSync; + }, + onSuccess: (_, { syncId, destination }) => { + queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() }); + queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) }); + } + }); +}; + +export const useDeleteSecretSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ syncId, destination, removeSecrets }: TDeleteSecretSyncDTO) => { + const { data } = await apiRequest.delete(`/api/v1/secret-syncs/${destination}/${syncId}`, { + params: { removeSecrets } + }); + + return data; + }, + onSuccess: (_, { syncId, destination }) => { + queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() }); + queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) }); + } + }); +}; + +export const useTriggerSecretSyncSyncSecrets = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ syncId, destination }: TTriggerSecretSyncSyncSecretsDTO) => { + const { data } = await apiRequest.post( + `/api/v1/secret-syncs/${destination}/${syncId}/sync-secrets` + ); + + return data; + }, + onSuccess: (_, { syncId, destination }) => { + queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() }); + queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) }); + } + }); +}; + +export const useTriggerSecretSyncImportSecrets = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + syncId, + destination, + importBehavior + }: TTriggerSecretSyncImportSecretsDTO) => { + const { data } = await apiRequest.post( + `/api/v1/secret-syncs/${destination}/${syncId}/import-secrets?importBehavior=${importBehavior}` + ); + + return data; + }, + onSuccess: (_, { syncId, destination }) => { + queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() }); + queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) }); + } + }); +}; + +export const useTriggerSecretSyncRemoveSecrets = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ syncId, destination }: TTriggerSecretSyncRemoveSecretsDTO) => { + const { data } = await apiRequest.post( + `/api/v1/secret-syncs/${destination}/${syncId}/remove-secrets` + ); + + return data; + }, + onSuccess: (_, { syncId, destination }) => { + queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() }); + queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/secretSyncs/queries.tsx b/frontend/src/hooks/api/secretSyncs/queries.tsx new file mode 100644 index 0000000000..dbf444e202 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/queries.tsx @@ -0,0 +1,88 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { SecretSync, TSecretSyncOption } from "@app/hooks/api/secretSyncs"; +import { + TListSecretSyncOptions, + TListSecretSyncs, + TSecretSync, + TSecretSyncResponse +} from "@app/hooks/api/secretSyncs/types"; + +export const secretSyncKeys = { + all: ["secret-sync"] as const, + options: () => [...secretSyncKeys.all, "options"] as const, + list: () => [...secretSyncKeys.all, "list"] as const, + byId: (destination: SecretSync, syncId: string) => + [...secretSyncKeys.all, destination, "by-id", syncId] as const +}; + +export const useSecretSyncOptions = ( + options?: Omit< + UseQueryOptions< + TSecretSyncOption[], + unknown, + TSecretSyncOption[], + ReturnType<typeof secretSyncKeys.options> + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: secretSyncKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get<TListSecretSyncOptions>("/api/v1/secret-syncs/options"); + + return data.secretSyncOptions; + }, + ...options + }); +}; + +export const useSecretSyncOption = (destination: SecretSync) => { + const { data: syncOptions, isPending } = useSecretSyncOptions(); + const syncOption = syncOptions?.find((option) => option.destination === destination); + + return { syncOption, isPending }; +}; + +export const useListSecretSyncs = ( + projectId: string, + options?: Omit< + UseQueryOptions<TSecretSync[], unknown, TSecretSync[], ReturnType<typeof secretSyncKeys.list>>, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: secretSyncKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get<TListSecretSyncs>("/api/v1/secret-syncs", { + params: { projectId } + }); + + return data.secretSyncs; + }, + ...options + }); +}; + +export const useGetSecretSync = ( + destination: SecretSync, + syncId: string, + options?: Omit< + UseQueryOptions<TSecretSync, unknown, TSecretSync, ReturnType<typeof secretSyncKeys.byId>>, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: secretSyncKeys.byId(destination, syncId), + queryFn: async () => { + const { data } = await apiRequest.get<TSecretSyncResponse>( + `/api/v1/secret-syncs/${destination}/${syncId}` + ); + + return data.secretSync; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts b/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts new file mode 100644 index 0000000000..26c7aed686 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TAwsParameterStoreSync = TRootSecretSync & { + destination: SecretSync.AWSParameterStore; + destinationConfig: { + path: string; + region: string; + }; + connection: { + app: AppConnection.AWS; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/github-sync.ts b/frontend/src/hooks/api/secretSyncs/types/github-sync.ts new file mode 100644 index 0000000000..8bb18be47d --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/github-sync.ts @@ -0,0 +1,42 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export enum GitHubSyncScope { + Organization = "organization", + Repository = "repository", + RepositoryEnvironment = "repository-environment" +} + +export enum GitHubSyncVisibility { + All = "all", + Private = "private", + Selected = "selected" +} + +export type TGitHubSync = TRootSecretSync & { + destination: SecretSync.GitHub; + destinationConfig: + | { + scope: GitHubSyncScope.Organization; + org: string; + visibility: GitHubSyncVisibility; + selectedRepositoryIds?: number[]; + } + | { + scope: GitHubSyncScope.Repository; + owner: string; + repo: string; + } + | { + scope: GitHubSyncScope.RepositoryEnvironment; + owner: string; + repo: string; + env: string; + }; + connection: { + app: AppConnection.GitHub; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts new file mode 100644 index 0000000000..c0b7143214 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -0,0 +1,57 @@ +import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; +import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync"; +import { TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync"; +import { DiscriminativePick } from "@app/types"; + +export type TSecretSyncOption = { + name: string; + destination: SecretSync; + canImportSecrets: boolean; +}; + +export type TSecretSync = TAwsParameterStoreSync | TGitHubSync; + +export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; + +export type TListSecretSyncOptions = { secretSyncOptions: TSecretSyncOption[] }; +export type TSecretSyncResponse = { secretSync: TSecretSync }; + +export type TCreateSecretSyncDTO = DiscriminativePick< + TSecretSync, + | "name" + | "destinationConfig" + | "description" + | "connectionId" + | "syncOptions" + | "destination" + | "isAutoSyncEnabled" +> & { environment: string; secretPath: string; projectId: string }; + +export type TUpdateSecretSyncDTO = Partial< + Omit<TCreateSecretSyncDTO, "destination" | "projectId"> +> & { + destination: SecretSync; + syncId: string; +}; + +export type TDeleteSecretSyncDTO = { + destination: SecretSync; + syncId: string; + removeSecrets: boolean; +}; + +export type TTriggerSecretSyncSyncSecretsDTO = { + destination: SecretSync; + syncId: string; +}; + +export type TTriggerSecretSyncImportSecretsDTO = { + destination: SecretSync; + syncId: string; + importBehavior: SecretSyncImportBehavior; +}; + +export type TTriggerSecretSyncRemoveSecretsDTO = { + destination: SecretSync; + syncId: string; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/root-sync.ts b/frontend/src/hooks/api/secretSyncs/types/root-sync.ts new file mode 100644 index 0000000000..947b58e4af --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/root-sync.ts @@ -0,0 +1,46 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSyncInitialSyncBehavior, SecretSyncStatus } from "@app/hooks/api/secretSyncs"; + +export type TRootSecretSync = { + id: string; + name: string; + description?: string | null; + version: number; + folderId: string | null; + connectionId: string; + createdAt: string; + updatedAt: string; + isAutoSyncEnabled: boolean; + projectId: string; + syncStatus: SecretSyncStatus | null; + lastSyncJobId: string | null; + lastSyncedAt: Date | null; + lastSyncMessage: string | null; + importStatus: SecretSyncStatus | null; + lastImportJobId: string | null; + lastImportedAt: Date | null; + lastImportMessage: string | null; + removeStatus: SecretSyncStatus | null; + lastRemoveJobId: string | null; + lastRemovedAt: Date | null; + lastRemoveMessage: string | null; + syncOptions: { + initialSyncBehavior: SecretSyncInitialSyncBehavior; + // prependPrefix?: string; + // appendSuffix?: string; + }; + connection: { + app: AppConnection; + id: string; + name: string; + }; + environment: { + id: string; + name: string; + slug: string; + } | null; + folder: { + id: string; + path: string; + } | null; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 5ad3c42217..c67b3f8c34 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -46,5 +46,4 @@ export type SubscriptionPlan = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: boolean; - appConnections: boolean; }; diff --git a/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx index 2370daa95e..a71bb17443 100644 --- a/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx @@ -44,11 +44,6 @@ export const GitHubOAuthCallbackPage = () => { // validate state if (state !== localStorage.getItem("latestCSRFToken")) { - createNotification({ - type: "error", - text: "Invalid state, redirecting..." - }); - navigate({ to: "/" }); return; } diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index 56f922b52e..5b73bcf77f 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { TPermission } from "@app/hooks/api/roles/types"; const generalPermissionSchema = z @@ -13,6 +14,16 @@ const generalPermissionSchema = z }) .optional(); +const appConnectionsPermissionSchema = z + .object({ + [OrgPermissionAppConnectionActions.Read]: z.boolean().optional(), + [OrgPermissionAppConnectionActions.Edit]: z.boolean().optional(), + [OrgPermissionAppConnectionActions.Create]: z.boolean().optional(), + [OrgPermissionAppConnectionActions.Delete]: z.boolean().optional(), + [OrgPermissionAppConnectionActions.Connect]: z.boolean().optional() + }) + .optional(); + const adminConsolePermissionSchmea = z .object({ "access-all-projects": z.boolean().optional() @@ -50,7 +61,7 @@ export const formSchema = z.object({ "organization-admin-console": adminConsolePermissionSchmea, [OrgPermissionSubjects.Kms]: generalPermissionSchema, [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, - [OrgPermissionSubjects.AppConnections]: generalPermissionSchema + "app-connections": appConnectionsPermissionSchema }) .optional() }); diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAppConnectionRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAppConnectionRow.tsx new file mode 100644 index 0000000000..7a5cb25c74 --- /dev/null +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAppConnectionRow.tsx @@ -0,0 +1,186 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "../OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue<TFormSchema>; + control: Control<TFormSchema>; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [ + { action: OrgPermissionAppConnectionActions.Read, label: "Read" }, + { action: OrgPermissionAppConnectionActions.Create, label: "Create" }, + { action: OrgPermissionAppConnectionActions.Edit, label: "Modify" }, + { action: OrgPermissionAppConnectionActions.Delete, label: "Remove" }, + { action: OrgPermissionAppConnectionActions.Connect, label: "Connect" } +] as const; + +export const OrgPermissionAppConnectionRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.app-connections" + }); + + const selectedPermissionCategory = useMemo(() => { + const actions = Object.keys(rule || {}) as Array<keyof typeof rule>; + const totalActions = PERMISSION_ACTIONS.length; + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule?.[OrgPermissionAppConnectionActions.Read]) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + switch (val) { + case Permission.FullAccess: + setValue( + "permissions.app-connections", + { + [OrgPermissionAppConnectionActions.Read]: true, + [OrgPermissionAppConnectionActions.Edit]: true, + [OrgPermissionAppConnectionActions.Create]: true, + [OrgPermissionAppConnectionActions.Delete]: true, + [OrgPermissionAppConnectionActions.Connect]: true + }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.app-connections", + { + [OrgPermissionAppConnectionActions.Read]: true, + [OrgPermissionAppConnectionActions.Edit]: false, + [OrgPermissionAppConnectionActions.Create]: false, + [OrgPermissionAppConnectionActions.Delete]: false, + [OrgPermissionAppConnectionActions.Connect]: false + }, + { shouldDirty: true } + ); + break; + + case Permission.NoAccess: + default: + setValue( + "permissions.app-connections", + { + [OrgPermissionAppConnectionActions.Read]: false, + [OrgPermissionAppConnectionActions.Edit]: false, + [OrgPermissionAppConnectionActions.Create]: false, + [OrgPermissionAppConnectionActions.Delete]: false, + [OrgPermissionAppConnectionActions.Connect]: false + }, + { shouldDirty: true } + ); + } + }; + + return ( + <> + <Tr + className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + onClick={() => setIsRowExpanded.toggle()} + > + <Td> + <FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} /> + </Td> + <Td>App Connections</Td> + <Td> + <Select + value={selectedPermissionCategory} + className="w-40 bg-mineshaft-600" + dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800" + onValueChange={handlePermissionChange} + isDisabled={!isEditable} + > + <SelectItem value={Permission.NoAccess}>No Access</SelectItem> + <SelectItem value={Permission.ReadOnly}>Read Only</SelectItem> + <SelectItem value={Permission.FullAccess}>Full Access</SelectItem> + <SelectItem value={Permission.Custom}>Custom</SelectItem> + </Select> + </Td> + </Tr> + {isRowExpanded && ( + <Tr> + <Td + colSpan={3} + className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && "border-mineshaft-500 p-8"}`} + > + <div className="grid grid-cols-3 gap-4"> + {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + <Controller + name={`permissions.app-connections.${action}`} + key={`permissions.app-connections.${action}`} + control={control} + render={({ field }) => ( + <Checkbox + isChecked={field.value} + onCheckedChange={(e) => { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.app-connections.${action}`} + > + {label} + </Checkbox> + )} + /> + ); + })} + </div> + </Td> + </Tr> + )} + </> + ); +}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index 5d359423a6..44f80c18d9 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -5,6 +5,7 @@ import { createNotification } from "@app/components/notifications"; import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; import { OrgPermissionSubjects, useOrganization } from "@app/context"; import { useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api"; +import { OrgPermissionAppConnectionRow } from "@app/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAppConnectionRow"; import { formRolePermission2API, @@ -69,8 +70,7 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "External KMS", formName: OrgPermissionSubjects.Kms }, - { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }, - { title: "App Connections", formName: OrgPermissionSubjects.AppConnections } + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } ] as const; type Props = { @@ -165,6 +165,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => { /> ); })} + <OrgPermissionAppConnectionRow + control={control} + setValue={setValue} + isEditable={isCustomRole} + /> <OrgRoleWorkspaceRow control={control} setValue={setValue} diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx index 6f775109c8..393bb4381f 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx @@ -1,14 +1,10 @@ -import { - faArrowUpRightFromSquare, - faBookOpen, - faPlus, - faWrench -} from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; +import { OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; @@ -16,24 +12,8 @@ import { AddAppConnectionModal, AppConnectionsTable } from "./components"; export const AppConnectionsTab = withPermission( () => { - const { subscription } = useSubscription(); - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const); - // TODO: remove once live - if (!subscription?.appConnections) - return ( - <div className="m-auto mt-40 flex w-full max-w-2xl flex-col items-center rounded-md bg-mineshaft-800 px-2 pt-4 text-bunker-300"> - <FontAwesomeIcon icon={faWrench} size="2xl" /> - <div className="flex flex-col items-center py-4"> - <div className="text-lg text-mineshaft-200"> - App Connections are currently unavailable. - </div> - <span className="text-mineshaft-300">Check back soon.</span> - </div> - </div> - ); - return ( <div> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> @@ -62,7 +42,7 @@ export const AppConnectionsTab = withPermission( </p> </div> <OrgPermissionCan - I={OrgPermissionActions.Create} + I={OrgPermissionAppConnectionActions.Create} a={OrgPermissionSubjects.AppConnections} > {(isAllowed) => ( @@ -91,7 +71,7 @@ export const AppConnectionsTab = withPermission( ); }, { - action: OrgPermissionActions.Read, + action: OrgPermissionAppConnectionActions.Read, subject: OrgPermissionSubjects.AppConnections } ); diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx index aa7865cbb5..f9ff8d0e57 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx @@ -132,7 +132,7 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => { {Object.values(GitHubConnectionMethod).map((method) => { return ( <SelectItem value={method} key={method}> - {methodDetails.name}{" "} + {getAppConnectionMethodDetails(method).name}{" "} {method === GitHubConnectionMethod.App ? " (Recommended)" : ""} </SelectItem> ); diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx index ea53778ba1..a733bae8ed 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx @@ -23,7 +23,8 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { useToggle } from "@app/hooks"; import { TAppConnection } from "@app/hooks/api/appConnections"; @@ -119,7 +120,7 @@ export const AppConnectionRow = ({ Copy Connection ID </DropdownMenuItem> <OrgPermissionCan - I={OrgPermissionActions.Edit} + I={OrgPermissionAppConnectionActions.Edit} a={OrgPermissionSubjects.AppConnections} > {(isAllowed: boolean) => ( @@ -133,7 +134,7 @@ export const AppConnectionRow = ({ )} </OrgPermissionCan> <OrgPermissionCan - I={OrgPermissionActions.Edit} + I={OrgPermissionAppConnectionActions.Edit} a={OrgPermissionSubjects.AppConnections} > {(isAllowed: boolean) => ( @@ -147,7 +148,7 @@ export const AppConnectionRow = ({ )} </OrgPermissionCan> <OrgPermissionCan - I={OrgPermissionActions.Delete} + I={OrgPermissionAppConnectionActions.Delete} a={OrgPermissionSubjects.AppConnections} > {(isAllowed: boolean) => ( diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx index ed9a35d39b..db5e218ec0 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx @@ -29,7 +29,6 @@ import { THead, Tr } from "@app/components/v2"; -import { useSubscription } from "@app/context"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections"; @@ -52,11 +51,7 @@ type AppConnectionFilters = { }; export const AppConnectionsTable = () => { - const { subscription } = useSubscription(); - - const { isLoading, data: appConnections = [] } = useListAppConnections({ - enabled: subscription?.appConnections - }); + const { isLoading, data: appConnections = [] } = useListAppConnections(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "deleteConnection", diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx index fc42f6a4ca..a2f751405e 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx @@ -33,7 +33,7 @@ export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection } console.error(err); createNotification({ - text: `Failed remove ${APP_CONNECTION_MAP[app].name} connection`, + text: `Failed to remove ${APP_CONNECTION_MAP[app].name} connection`, type: "error" }); } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 3a109ceb07..3a9e311fdf 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -8,6 +8,7 @@ import { import { PermissionConditionOperators, ProjectPermissionDynamicSecretActions, + ProjectPermissionSecretSyncActions, TPermissionCondition, TPermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; @@ -37,6 +38,16 @@ const DynamicSecretPolicyActionSchema = z.object({ [ProjectPermissionDynamicSecretActions.Lease]: z.boolean().optional() }); +const SecretSyncPolicyActionSchema = z.object({ + [ProjectPermissionSecretSyncActions.Read]: z.boolean().optional(), + [ProjectPermissionSecretSyncActions.Create]: z.boolean().optional(), + [ProjectPermissionSecretSyncActions.Edit]: z.boolean().optional(), + [ProjectPermissionSecretSyncActions.Delete]: z.boolean().optional(), + [ProjectPermissionSecretSyncActions.SyncSecrets]: z.boolean().optional(), + [ProjectPermissionSecretSyncActions.ImportSecrets]: z.boolean().optional(), + [ProjectPermissionSecretSyncActions.RemoveSecrets]: z.boolean().optional() +}); + const SecretRollbackPolicyActionSchema = z.object({ read: z.boolean().optional(), create: z.boolean().optional() @@ -137,7 +148,8 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]), - [ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]) + [ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]) }) .partial() .optional() @@ -331,6 +343,31 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canDelete) formVal[subject]![0].delete = true; if (canEncrypt) formVal[subject]![0].encrypt = true; if (canDecrypt) formVal[subject]![0].decrypt = true; + return; + } + + if (subject === ProjectPermissionSub.SecretSyncs) { + const canRead = action.includes(ProjectPermissionSecretSyncActions.Read); + const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit); + const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete); + const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create); + const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets); + const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets); + const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets); + + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true; + if (canEdit) formVal[subject]![0][ProjectPermissionSecretSyncActions.Edit] = true; + if (canCreate) formVal[subject]![0][ProjectPermissionSecretSyncActions.Create] = true; + if (canDelete) formVal[subject]![0][ProjectPermissionSecretSyncActions.Delete] = true; + if (canSyncSecrets) + formVal[subject]![0][ProjectPermissionSecretSyncActions.SyncSecrets] = true; + if (canImportSecrets) + formVal[subject]![0][ProjectPermissionSecretSyncActions.ImportSecrets] = true; + if (canRemoveSecrets) + formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true; } }); return formVal; @@ -670,5 +707,23 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Perform rollback", value: "create" }, { label: "View", value: "read" } ] + }, + [ProjectPermissionSub.SecretSyncs]: { + title: "Secret Syncs", + actions: [ + { label: "Read", value: ProjectPermissionSecretSyncActions.Read }, + { label: "Create", value: ProjectPermissionSecretSyncActions.Create }, + { label: "Modify", value: ProjectPermissionSecretSyncActions.Edit }, + { label: "Remove", value: ProjectPermissionSecretSyncActions.Delete }, + { label: "Trigger Syncs", value: ProjectPermissionSecretSyncActions.SyncSecrets }, + { + label: "Import Secrets from Destination", + value: ProjectPermissionSecretSyncActions.ImportSecrets + }, + { + label: "Remove Secrets from Destination", + value: ProjectPermissionSecretSyncActions.RemoveSecrets + } + ] } }; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx index 6ee64296eb..aec636da7f 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -1,220 +1,41 @@ -import { useCallback, useEffect, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "@tanstack/react-router"; -import { motion } from "framer-motion"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useSearch } from "@tanstack/react-router"; -import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { ContentLoader } from "@app/components/v2"; +import { Badge, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { - useDeleteIntegration, - useDeleteIntegrationAuths, - useGetCloudIntegrations, - useGetWorkspaceAuthorizations, - useGetWorkspaceIntegrations -} from "@app/hooks/api"; -import { IntegrationAuth } from "@app/hooks/api/types"; - -import { CloudIntegrationSection } from "./components/CloudIntegrationSection"; -import { FrameworkIntegrationSection } from "./components/FrameworkIntegrationSection"; -import { InfrastructureIntegrationSection } from "./components/InfrastructureIntegrationSection/InfrastructureIntegrationSection"; -import { IntegrationsSection } from "./components/IntegrationsSection"; -import { redirectForProviderAuth } from "./IntegrationsListPage.utils"; +import { IntegrationsListPageTabs } from "@app/types/integrations"; -enum IntegrationView { - List = "list", - New = "new" -} +import { + FrameworkIntegrationTab, + InfrastructureIntegrationTab, + NativeIntegrationsTab, + SecretSyncsTab +} from "./components"; -const Page = () => { - const { currentWorkspace } = useWorkspace(); +export const IntegrationsListPage = () => { const navigate = useNavigate(); - const { environments, id: workspaceId } = currentWorkspace; - const [view, setView] = useState<IntegrationView>(IntegrationView.New); - - const { data: cloudIntegrations, isPending: isCloudIntegrationsLoading } = - useGetCloudIntegrations(); - - const { - data: integrationAuths, - isPending: isIntegrationAuthLoading, - isFetching: isIntegrationAuthFetching - } = useGetWorkspaceAuthorizations( - workspaceId, - useCallback((data: IntegrationAuth[]) => { - const groupBy: Record<string, IntegrationAuth> = {}; - data.forEach((el) => { - groupBy[el.integration] = el; - }); - return groupBy; - }, []) - ); - - // mutation - const { - data: integrations, - isPending: isIntegrationLoading, - isFetching: isIntegrationFetching, - isFetched: isIntegrationsFetched - } = useGetWorkspaceIntegrations(workspaceId); - - const { mutateAsync: deleteIntegration } = useDeleteIntegration(); - const { - mutateAsync: deleteIntegrationAuths, - isSuccess: isDeleteIntegrationAuthSuccess, - reset: resetDeleteIntegrationAuths - } = useDeleteIntegrationAuths(); - - const isIntegrationsAuthorizedEmpty = !Object.keys(integrationAuths || {}).length; - const isIntegrationsEmpty = !integrations?.length; - // summary: this use effect is trigger when all integration auths are removed thus deactivate bot - // details: so on successfully deleting an integration auth, immediately integration list is refeteched - // After the refetch is completed check if its empty. Then set bot active and reset the submit hook for isSuccess to go back to false - useEffect(() => { - if ( - isDeleteIntegrationAuthSuccess && - !isIntegrationFetching && - !isIntegrationAuthFetching && - isIntegrationsAuthorizedEmpty && - isIntegrationsEmpty - ) { - resetDeleteIntegrationAuths(); - } - }, [ - isIntegrationFetching, - isDeleteIntegrationAuthSuccess, - isIntegrationAuthFetching, - isIntegrationsAuthorizedEmpty, - isIntegrationsEmpty - ]); - - useEffect(() => { - setView(integrations?.length ? IntegrationView.List : IntegrationView.New); - }, [isIntegrationsFetched]); - - const handleProviderIntegration = async (provider: string) => { - const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); - if (!selectedCloudIntegration) return; - - try { - redirectForProviderAuth(currentWorkspace.id, navigate, selectedCloudIntegration); - } catch (error) { - console.error(error); - } - }; - - // function to strat integration for a provider - // confirmation to user passing the bot key for provider to get secret access - const handleProviderIntegrationStart = (provider: string) => { - handleProviderIntegration(provider); - }; - - const handleIntegrationDelete = async ( - integrationId: string, - shouldDeleteIntegrationSecrets: boolean, - cb: () => void - ) => { - try { - await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets }); - if (cb) cb(); - createNotification({ - type: "success", - text: "Deleted integration" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to delete integration" - }); - } - }; - - const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => { - const integrationAuthForProvider = integrationAuths?.[provider]; - if (!integrationAuthForProvider) return; + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); - try { - await deleteIntegrationAuths({ - integration: provider, - workspaceId - }); - if (cb) cb(); - createNotification({ - type: "success", - text: "Revoked provider authentication" - }); - } catch (err) { - console.error(err); - createNotification({ - type: "error", - text: "Failed to revoke provider authentication" - }); - } + const { selectedTab } = useSearch({ + from: ROUTE_PATHS.SecretManager.IntegrationsListPage.id + }); + + const updateSelectedTab = (tab: string) => { + navigate({ + to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path, + search: (prev) => ({ ...prev, selectedTab: tab }), + params: { + projectId: currentWorkspace.id + } + }); }; - if (isIntegrationLoading || isCloudIntegrationsLoading) - return ( - <div className="flex flex-col items-center gap-2"> - <ContentLoader text={["Loading integrations..."]} /> - </div> - ); - - return ( - <div className="container relative mx-auto max-w-7xl text-white"> - <div className="relative"> - {view === IntegrationView.List ? ( - <motion.div - key="view-integrations" - transition={{ duration: 0.3 }} - initial={{ opacity: 0, translateX: 30 }} - animate={{ opacity: 1, translateX: 0 }} - exit={{ opacity: 0, translateX: 30 }} - className="w-full" - > - <IntegrationsSection - cloudIntegrations={cloudIntegrations} - onAddIntegration={() => setView(IntegrationView.New)} - isLoading={isIntegrationLoading} - integrations={integrations} - environments={environments} - onIntegrationDelete={handleIntegrationDelete} - workspaceId={workspaceId} - /> - </motion.div> - ) : ( - <motion.div - key="add-integration" - transition={{ duration: 0.3 }} - initial={{ opacity: 0, translateX: 30 }} - animate={{ opacity: 1, translateX: 0 }} - exit={{ opacity: 0, translateX: 30 }} - className="w-full" - > - <CloudIntegrationSection - onViewActiveIntegrations={ - integrations?.length ? () => setView(IntegrationView.List) : undefined - } - isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading} - cloudIntegrations={cloudIntegrations} - integrationAuths={integrationAuths} - onIntegrationStart={handleProviderIntegrationStart} - onIntegrationRevoke={handleIntegrationAuthRevoke} - /> - <FrameworkIntegrationSection /> - <InfrastructureIntegrationSection /> - </motion.div> - )} - </div> - </div> - ); -}; - -export const IntegrationsListPage = () => { - const { t } = useTranslation(); - return ( <> <Helmet> @@ -223,14 +44,90 @@ export const IntegrationsListPage = () => { <meta property="og:title" content="Manage your .env files in seconds" /> <meta name="og:description" content={t("integrations.description") as string} /> </Helmet> - <ProjectPermissionCan - renderGuardBanner - passThrough={false} - I={ProjectPermissionActions.Read} - a={ProjectPermissionSub.Integrations} - > - <Page /> - </ProjectPermissionCan> + <div className="container relative mx-auto max-w-7xl pb-12 text-white"> + <div className="mx-6 mb-8"> + <div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl"> + <h1 className="text-3xl font-semibold">Integrations</h1> + <p className="text-base text-bunker-300"> + Manage integrations with third-party services. + </p> + </div> + <div className="mx-2 mb-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5"> + <div className="mb-1 flex items-center text-sm"> + <FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1 text-primary" /> + Integrations Update + </div> + <p className="mb-2 mt-1 text-sm text-bunker-300"> + Infisical is excited to announce{" "} + <a + className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600" + href="https://infisical.com/docs/integrations/secret-syncs/overview" + target="_blank" + rel="noopener noreferrer" + > + Secret Syncs + </a> + , a new way to sync your secrets to third-party services using{" "} + <a + className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600" + href="https://infisical.com/docs/integrations/app-connections/overview" + target="_blank" + rel="noopener noreferrer" + > + App Connections + </a> + , offering deeper customization and re-configurability. + </p> + <p className="text-sm text-bunker-300"> + Existing integrations (now called Native Integrations) will continue to be supported + as we build out our Secret Sync library. + </p> + </div> + <Tabs value={selectedTab} onValueChange={updateSelectedTab}> + <TabList> + <Tab value={IntegrationsListPageTabs.SecretSyncs}> + Secret Syncs + <Badge variant="primary" className="ml-1 cursor-pointer text-xs"> + New + </Badge> + </Tab> + <Tab value={IntegrationsListPageTabs.NativeIntegrations}>Native Integrations</Tab> + <Tab value={IntegrationsListPageTabs.FrameworkIntegrations}> + Framework Integrations + </Tab> + <Tab value={IntegrationsListPageTabs.InfrastructureIntegrations}> + Infrastructure Integrations + </Tab> + </TabList> + <TabPanel value={IntegrationsListPageTabs.SecretSyncs}> + <ProjectPermissionCan + renderGuardBanner + passThrough={false} + I={ProjectPermissionActions.Read} + a={ProjectPermissionSub.SecretSyncs} + > + <SecretSyncsTab /> + </ProjectPermissionCan> + </TabPanel> + <TabPanel value={IntegrationsListPageTabs.NativeIntegrations}> + <ProjectPermissionCan + renderGuardBanner + passThrough={false} + I={ProjectPermissionActions.Read} + a={ProjectPermissionSub.Integrations} + > + <NativeIntegrationsTab /> + </ProjectPermissionCan> + </TabPanel> + <TabPanel value={IntegrationsListPageTabs.FrameworkIntegrations}> + <FrameworkIntegrationTab /> + </TabPanel> + <TabPanel value={IntegrationsListPageTabs.InfrastructureIntegrations}> + <InfrastructureIntegrationTab /> + </TabPanel> + </Tabs> + </div> + </div> </> ); }; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx index 5adb956646..6de44d95cf 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx @@ -75,12 +75,12 @@ export const CloudIntegrationSection = ({ return ( <div> - <div className="px-5"> - {currentWorkspace?.environments.length === 0 && ( + {currentWorkspace?.environments.length === 0 && ( + <div className="px-5"> <NoEnvironmentsBanner projectId={currentWorkspace.id} /> - )} - </div> - <div className="flex flex-col items-start justify-between py-4 text-xl"> + </div> + )} + <div className="m-4 mt-0 flex flex-col items-start justify-between px-2 text-xl"> {onViewActiveIntegrations && ( <Button variant="link" diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationSection/index.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationSection/index.tsx deleted file mode 100644 index 1738b8179a..0000000000 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { FrameworkIntegrationSection } from "./FrameworkIntegrationSection"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationTab/FrameworkIntegrationTab.tsx similarity index 95% rename from frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx rename to frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationTab/FrameworkIntegrationTab.tsx index 211759dccd..8c28af2410 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationTab/FrameworkIntegrationTab.tsx @@ -5,14 +5,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import frameworks from "../json/frameworkIntegrations.json"; -export const FrameworkIntegrationSection = () => { +export const FrameworkIntegrationTab = () => { const { t } = useTranslation(); const sortedFrameworks = frameworks.sort((a, b) => a.name.localeCompare(b.name)); return ( <> - <div className="mb-4 mt-12 flex flex-col items-start justify-between px-2 text-xl"> + <div className="mx-4 mb-4 flex flex-col items-start justify-between px-2 text-xl"> <h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1> <p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p> </div> diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationTab/index.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationTab/index.tsx new file mode 100644 index 0000000000..de5ab6c223 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/FrameworkIntegrationTab/index.tsx @@ -0,0 +1 @@ +export { FrameworkIntegrationTab } from "./FrameworkIntegrationTab"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationSection/index.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationSection/index.tsx deleted file mode 100644 index 8ca5a43c79..0000000000 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { InfrastructureIntegrationSection } from "./InfrastructureIntegrationSection"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationSection/InfrastructureIntegrationSection.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationTab/InfrastructureIntegrationTab.tsx similarity index 88% rename from frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationSection/InfrastructureIntegrationSection.tsx rename to frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationTab/InfrastructureIntegrationTab.tsx index 16a576359d..2fe16c3775 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationSection/InfrastructureIntegrationSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationTab/InfrastructureIntegrationTab.tsx @@ -1,17 +1,17 @@ import integrations from "../json/infrastructureIntegrations.json"; -export const InfrastructureIntegrationSection = () => { +export const InfrastructureIntegrationTab = () => { const sortedIntegrations = integrations.sort((a, b) => a.name.localeCompare(b.name)); return ( <> - <div className="mb-4 mt-12 flex flex-col items-start justify-between px-2 text-xl"> + <div className="mx-4 mb-4 flex flex-col items-start justify-between px-2 text-xl"> <h1 className="text-3xl font-semibold">Infrastructure Integrations</h1> <p className="text-base text-gray-400"> Click on of the integration to read the documentation. </p> </div> - <div className="mx-2 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4"> + <div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4"> {sortedIntegrations.map((integration) => ( <a key={`framework-integration-${integration.slug}`} diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationTab/index.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationTab/index.tsx new file mode 100644 index 0000000000..1cf3febd5e --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/InfrastructureIntegrationTab/index.tsx @@ -0,0 +1 @@ +export { InfrastructureIntegrationTab } from "./InfrastructureIntegrationTab"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/IntegrationsSection.tsx deleted file mode 100644 index cf2e0067f0..0000000000 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/IntegrationsSection.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button, Checkbox, DeleteActionModal, PageHeader } from "@app/components/v2"; -import { usePopUp, useToggle } from "@app/hooks"; -import { TCloudIntegration, TIntegration } from "@app/hooks/api/types"; - -import { IntegrationsTable } from "./components"; - -type Props = { - environments: Array<{ name: string; slug: string; id: string }>; - integrations?: TIntegration[]; - cloudIntegrations?: TCloudIntegration[]; - isLoading?: boolean; - onIntegrationDelete: ( - integrationId: string, - shouldDeleteIntegrationSecrets: boolean, - cb: () => void - ) => Promise<void>; - workspaceId: string; - onAddIntegration: () => void; -}; - -export const IntegrationsSection = ({ - integrations = [], - environments = [], - isLoading, - onIntegrationDelete, - workspaceId, - onAddIntegration, - cloudIntegrations = [] -}: Props) => { - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "deleteConfirmation", - "deleteSecretsConfirmation" - ] as const); - - const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); - - return ( - <div className="mb-8"> - <PageHeader - title="Integrations" - description="Manage integrations with third-party services." - /> - <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> - <div className="mb-4 flex items-center justify-between"> - <p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p> - <Button - colorSchema="primary" - type="submit" - leftIcon={<FontAwesomeIcon icon={faPlus} />} - onClick={onAddIntegration} - > - Add Integration - </Button> - </div> - <IntegrationsTable - cloudIntegrations={cloudIntegrations} - integrations={integrations} - isLoading={isLoading} - workspaceId={workspaceId} - environments={environments} - onDeleteIntegration={(integration) => { - setShouldDeleteSecrets.off(); - handlePopUpOpen("deleteConfirmation", integration); - }} - /> - </div> - <DeleteActionModal - isOpen={popUp.deleteConfirmation.isOpen} - title={`Are you sure want to remove ${ - (popUp?.deleteConfirmation.data as TIntegration)?.integration || " " - } integration for ${ - (popUp?.deleteConfirmation.data as TIntegration)?.app || "this project" - }?`} - onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)} - deleteKey={ - ((popUp?.deleteConfirmation?.data as TIntegration)?.integration === - "azure-app-configuration" && - (popUp?.deleteConfirmation?.data as TIntegration)?.app - ?.split("//")[1] - ?.split(".")[0]) || - (popUp?.deleteConfirmation?.data as TIntegration)?.app || - (popUp?.deleteConfirmation?.data as TIntegration)?.owner || - (popUp?.deleteConfirmation?.data as TIntegration)?.path || - (popUp?.deleteConfirmation?.data as TIntegration)?.integration || - "" - } - onDeleteApproved={async () => { - if (shouldDeleteSecrets) { - handlePopUpOpen("deleteSecretsConfirmation"); - return; - } - - await onIntegrationDelete( - (popUp?.deleteConfirmation.data as TIntegration).id, - false, - () => handlePopUpClose("deleteConfirmation") - ); - }} - > - {(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && ( - <div className="mt-4"> - <Checkbox - id="delete-integration-secrets" - checkIndicatorBg="text-white" - onCheckedChange={() => setShouldDeleteSecrets.toggle()} - > - Delete previously synced secrets from the destination - </Checkbox> - </div> - )} - </DeleteActionModal> - <DeleteActionModal - isOpen={popUp.deleteSecretsConfirmation.isOpen} - title={`Are you sure you also want to delete secrets on ${ - (popUp?.deleteConfirmation.data as TIntegration)?.integration - }?`} - subTitle="By confirming, you acknowledge that all secrets managed by this integration will be removed from the destination. This action is irreversible." - onChange={(isOpen) => handlePopUpToggle("deleteSecretsConfirmation", isOpen)} - deleteKey="confirm" - onDeleteApproved={async () => { - await onIntegrationDelete( - (popUp?.deleteConfirmation.data as TIntegration).id, - true, - () => { - handlePopUpClose("deleteSecretsConfirmation"); - handlePopUpClose("deleteConfirmation"); - } - ); - }} - /> - </div> - ); -}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/index.ts deleted file mode 100644 index d9567592c0..0000000000 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./IntegrationsTable"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/index.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/index.tsx deleted file mode 100644 index 6f40d79fa8..0000000000 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IntegrationsSection } from "./IntegrationsSection"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationDetails.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationDetails.tsx similarity index 100% rename from frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationDetails.tsx rename to frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationDetails.tsx diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationRow.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationRow.tsx similarity index 100% rename from frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationRow.tsx rename to frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationRow.tsx diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationsTable.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx similarity index 93% rename from frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationsTable.tsx rename to frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx index ea2ecd8887..a8d2cccd5d 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/IntegrationsSection/components/IntegrationsTable.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx @@ -7,7 +7,7 @@ import { faClock, faFilter, faMagnifyingGlass, - faPlug, + faRotate, faSearch, faWarning } from "@fortawesome/free-solid-svg-icons"; @@ -85,16 +85,11 @@ export const IntegrationsTable = ({ }: Props) => { const { mutate: syncIntegration } = useSyncIntegration(); - const initialFilters = useMemo( - () => ({ - environmentIds: environments.map((env) => env.id), - integrations: [...new Set(integrations.map(({ integration }) => integration))], - status: Object.values(IntegrationStatus) - }), - [environments, integrations] - ); - - const [filters, setFilters] = useState<IntegrationFilters>(initialFilters); + const [filters, setFilters] = useState<IntegrationFilters>({ + status: [], + integrations: [], + environmentIds: [] + }); const cloudIntegrationMap = useMemo(() => { return new Map( @@ -130,18 +125,33 @@ export const IntegrationsTable = ({ .filter((integration) => { const { secretPath, envId, isSynced } = integration; - if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false; - if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false) + if ( + filters.status.length && + !filters.status.includes(IntegrationStatus.Synced) && + isSynced + ) + return false; + if ( + filters.status.length && + !filters.status.includes(IntegrationStatus.NotSynced) && + isSynced === false + ) return false; if ( + filters.status.length && !filters.status.includes(IntegrationStatus.PendingSync) && typeof isSynced !== "boolean" ) return false; - if (!filters.integrations.includes(integration.integration)) return false; + if ( + filters.integrations.length && + !filters.integrations.includes(integration.integration) + ) + return false; - if (!filters.environmentIds.includes(envId)) return false; + if (filters.environmentIds.length && !filters.environmentIds.includes(envId)) + return false; return ( integration.integration @@ -216,9 +226,7 @@ export const IntegrationsTable = ({ orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; const isTableFiltered = - filters.integrations.length !== initialFilters.integrations.length || - filters.environmentIds.length !== initialFilters.environmentIds.length || - filters.status.length !== initialFilters.status.length; + filters.integrations.length || filters.environmentIds.length || filters.status.length; return ( <div> @@ -439,7 +447,7 @@ export const IntegrationsTable = ({ ? "No integrations match search..." : "This project has no integrations configured" } - icon={integrations.length ? faSearch : faPlug} + icon={integrations.length ? faSearch : faRotate} /> )} </TableContainer> diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx new file mode 100644 index 0000000000..1479ac4bde --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Checkbox, DeleteActionModal, Spinner } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp, useToggle } from "@app/hooks"; +import { + useDeleteIntegration, + useDeleteIntegrationAuths, + useGetCloudIntegrations, + useGetWorkspaceAuthorizations, + useGetWorkspaceIntegrations +} from "@app/hooks/api"; +import { IntegrationAuth } from "@app/hooks/api/integrationAuth/types"; +import { TIntegration } from "@app/hooks/api/integrations/types"; + +import { redirectForProviderAuth } from "../../IntegrationsListPage.utils"; +import { CloudIntegrationSection } from "../CloudIntegrationSection"; +import { IntegrationsTable } from "./IntegrationsTable"; + +enum IntegrationView { + List = "list", + New = "new" +} + +export const NativeIntegrationsTab = () => { + const { currentWorkspace } = useWorkspace(); + const { environments, id: workspaceId } = currentWorkspace; + const navigate = useNavigate(); + + const { data: cloudIntegrations, isPending: isCloudIntegrationsLoading } = + useGetCloudIntegrations(); + + const { + data: integrationAuths, + isPending: isIntegrationAuthLoading, + isFetching: isIntegrationAuthFetching + } = useGetWorkspaceAuthorizations( + workspaceId, + useCallback((data: IntegrationAuth[]) => { + const groupBy: Record<string, IntegrationAuth> = {}; + data.forEach((el) => { + groupBy[el.integration] = el; + }); + return groupBy; + }, []) + ); + + // mutation + const { + data: integrations, + isPending: isIntegrationLoading, + isFetching: isIntegrationFetching + } = useGetWorkspaceIntegrations(workspaceId); + + const { mutateAsync: deleteIntegration } = useDeleteIntegration(); + const { + mutateAsync: deleteIntegrationAuths, + isSuccess: isDeleteIntegrationAuthSuccess, + reset: resetDeleteIntegrationAuths + } = useDeleteIntegrationAuths(); + + const isIntegrationsAuthorizedEmpty = !Object.keys(integrationAuths || {}).length; + const isIntegrationsEmpty = !integrations?.length; + // summary: this use effect is trigger when all integration auths are removed thus deactivate bot + // details: so on successfully deleting an integration auth, immediately integration list is refeteched + // After the refetch is completed check if its empty. Then set bot active and reset the submit hook for isSuccess to go back to false + useEffect(() => { + if ( + isDeleteIntegrationAuthSuccess && + !isIntegrationFetching && + !isIntegrationAuthFetching && + isIntegrationsAuthorizedEmpty && + isIntegrationsEmpty + ) { + resetDeleteIntegrationAuths(); + } + }, [ + isIntegrationFetching, + isDeleteIntegrationAuthSuccess, + isIntegrationAuthFetching, + isIntegrationsAuthorizedEmpty, + isIntegrationsEmpty + ]); + + const handleProviderIntegration = async (provider: string) => { + const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); + if (!selectedCloudIntegration) return; + + try { + redirectForProviderAuth(currentWorkspace.id, navigate, selectedCloudIntegration); + } catch (error) { + console.error(error); + } + }; + + // function to strat integration for a provider + // confirmation to user passing the bot key for provider to get secret access + const handleProviderIntegrationStart = (provider: string) => { + handleProviderIntegration(provider); + }; + + const handleIntegrationDelete = async ( + integrationId: string, + shouldDeleteIntegrationSecrets: boolean, + cb: () => void + ) => { + try { + await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets }); + if (cb) cb(); + createNotification({ + type: "success", + text: "Deleted integration" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete integration" + }); + } + }; + + const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => { + const integrationAuthForProvider = integrationAuths?.[provider]; + if (!integrationAuthForProvider) return; + + try { + await deleteIntegrationAuths({ + integration: provider, + workspaceId + }); + if (cb) cb(); + createNotification({ + type: "success", + text: "Revoked provider authentication" + }); + } catch (err) { + console.error(err); + createNotification({ + type: "error", + text: "Failed to revoke provider authentication" + }); + } + }; + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "deleteConfirmation", + "deleteSecretsConfirmation" + ] as const); + + const [view, setView] = useState<IntegrationView>(IntegrationView.List); + + const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); + + if (isIntegrationLoading || isCloudIntegrationsLoading) + return ( + <div className="flex h-[60vh] flex-col items-center justify-center gap-2"> + <Spinner /> + </div> + ); + + return ( + <> + {view === IntegrationView.List ? ( + <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> + <div className="mb-4 flex items-center justify-between"> + <p className="text-xl font-semibold text-mineshaft-100">Native Integrations</p> + <Button + colorSchema="secondary" + type="submit" + leftIcon={<FontAwesomeIcon icon={faPlus} />} + onClick={() => setView(IntegrationView.New)} + > + Add Integration + </Button> + </div> + <IntegrationsTable + cloudIntegrations={cloudIntegrations} + integrations={integrations} + isLoading={isIntegrationLoading} + workspaceId={workspaceId} + environments={environments} + onDeleteIntegration={(integration) => { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} + /> + </div> + ) : ( + <CloudIntegrationSection + onIntegrationStart={handleProviderIntegrationStart} + onIntegrationRevoke={handleIntegrationAuthRevoke} + integrationAuths={integrationAuths} + cloudIntegrations={cloudIntegrations} + isLoading={isIntegrationAuthLoading || isCloudIntegrationsLoading} + onViewActiveIntegrations={() => setView(IntegrationView.List)} + /> + )} + <DeleteActionModal + isOpen={popUp.deleteConfirmation.isOpen} + title={`Are you sure want to remove ${ + (popUp?.deleteConfirmation.data as TIntegration)?.integration || " " + } integration for ${ + (popUp?.deleteConfirmation.data as TIntegration)?.app || "this project" + }?`} + onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)} + deleteKey={ + ((popUp?.deleteConfirmation?.data as TIntegration)?.integration === + "azure-app-configuration" && + (popUp?.deleteConfirmation?.data as TIntegration)?.app + ?.split("//")[1] + ?.split(".")[0]) || + (popUp?.deleteConfirmation?.data as TIntegration)?.app || + (popUp?.deleteConfirmation?.data as TIntegration)?.owner || + (popUp?.deleteConfirmation?.data as TIntegration)?.path || + (popUp?.deleteConfirmation?.data as TIntegration)?.integration || + "" + } + onDeleteApproved={async () => { + if (shouldDeleteSecrets) { + handlePopUpOpen("deleteSecretsConfirmation"); + return; + } + + await handleIntegrationDelete( + (popUp?.deleteConfirmation.data as TIntegration).id, + false, + () => handlePopUpClose("deleteConfirmation") + ); + }} + > + {(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && ( + <div className="mt-4"> + <Checkbox + id="delete-integration-secrets" + checkIndicatorBg="text-white" + onCheckedChange={() => setShouldDeleteSecrets.toggle()} + > + Delete previously synced secrets from the destination + </Checkbox> + </div> + )} + </DeleteActionModal> + <DeleteActionModal + isOpen={popUp.deleteSecretsConfirmation.isOpen} + title={`Are you sure you also want to delete secrets on ${ + (popUp?.deleteConfirmation.data as TIntegration)?.integration + }?`} + subTitle="By confirming, you acknowledge that all secrets managed by this integration will be removed from the destination. This action is irreversible." + onChange={(isOpen) => handlePopUpToggle("deleteSecretsConfirmation", isOpen)} + deleteKey="confirm" + onDeleteApproved={async () => { + await handleIntegrationDelete( + (popUp?.deleteConfirmation.data as TIntegration).id, + true, + () => { + handlePopUpClose("deleteSecretsConfirmation"); + handlePopUpClose("deleteConfirmation"); + } + ); + }} + /> + </> + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/index.ts new file mode 100644 index 0000000000..cc5435f44c --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/index.ts @@ -0,0 +1 @@ +export * from "./NativeIntegrationsTab"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AwsParameterStoreSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AwsParameterStoreSyncDestinationCol.tsx new file mode 100644 index 0000000000..32e1f8af82 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/AwsParameterStoreSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TAwsParameterStoreSync; +}; + +export const AwsParameterStoreSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/GitHubSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/GitHubSyncDestinationCol.tsx new file mode 100644 index 0000000000..5c1b22fb1f --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/GitHubSyncDestinationCol.tsx @@ -0,0 +1,49 @@ +import { GitHubSyncSelectedRepositoriesTooltipContent } from "@app/components/secret-syncs/github"; +import { + GitHubSyncScope, + GitHubSyncVisibility, + TGitHubSync +} from "@app/hooks/api/secretSyncs/types/github-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell, SecretSyncTableCellProps } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TGitHubSync; +}; + +export const GitHubSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + const { destinationConfig } = secretSync; + + let additionalProps: Pick< + SecretSyncTableCellProps, + "additionalTooltipContent" | "infoBadge" | "secondaryClassName" + > = {}; + + if ( + destinationConfig.scope === GitHubSyncScope.Organization && + destinationConfig.visibility === GitHubSyncVisibility.Selected + ) { + additionalProps = { + infoBadge: "secondary", + additionalTooltipContent: ( + <div className="mt-4"> + <GitHubSyncSelectedRepositoriesTooltipContent secretSync={secretSync} /> + </div> + ) + }; + } + + return ( + <SecretSyncTableCell + primaryText={primaryText} + secondaryText={secondaryText} + {...additionalProps} + secondaryClassName={ + destinationConfig.scope === GitHubSyncScope.Organization ? "capitalize" : undefined + } + /> + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx new file mode 100644 index 0000000000..fcbe00cfe9 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -0,0 +1,21 @@ +import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; + +import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDestinationCol"; +import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; + +type Props = { + secretSync: TSecretSync; +}; + +export const SecretSyncDestinationCol = ({ secretSync }: Props) => { + switch (secretSync.destination) { + case SecretSync.AWSParameterStore: + return <AwsParameterStoreSyncDestinationCol secretSync={secretSync} />; + case SecretSync.GitHub: + return <GitHubSyncDestinationCol secretSync={secretSync} />; + default: + throw new Error( + `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` + ); + } +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/index.ts new file mode 100644 index 0000000000..9d07687984 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncDestinationCol"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx new file mode 100644 index 0000000000..3ef92d09ab --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx @@ -0,0 +1,395 @@ +import { useCallback, useMemo } from "react"; +import { + faBan, + faCalendarCheck, + faCheck, + faCopy, + faDownload, + faEllipsisV, + faEraser, + faInfoCircle, + faRotate, + faToggleOff, + faToggleOn, + faTrash, + faTriangleExclamation, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + SecretSyncImportStatusBadge, + SecretSyncRemoveStatusBadge, + SecretSyncStatusBadge +} from "@app/components/secret-syncs"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { useToggle } from "@app/hooks"; +import { SecretSyncStatus, TSecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; + +import { SecretSyncDestinationCol } from "./SecretSyncDestinationCol"; +import { SecretSyncTableCell } from "./SecretSyncTableCell"; + +type Props = { + secretSync: TSecretSync; + onDelete: (secretSync: TSecretSync) => void; + onTriggerSyncSecrets: (secretSync: TSecretSync) => void; + onTriggerImportSecrets: (secretSync: TSecretSync) => void; + onTriggerRemoveSecrets: (secretSync: TSecretSync) => void; + onToggleEnable: (secretSync: TSecretSync) => void; +}; + +export const SecretSyncRow = ({ + secretSync, + onDelete, + onTriggerSyncSecrets, + onTriggerImportSecrets, + onTriggerRemoveSecrets, + onToggleEnable +}: Props) => { + const navigate = useNavigate(); + const { + id, + folder, + lastSyncMessage, + destination, + lastSyncedAt, + environment, + name, + description, + syncStatus, + isAutoSyncEnabled, + projectId + } = secretSync; + + const { syncOption } = useSecretSyncOption(destination); + + const destinationName = SECRET_SYNC_MAP[destination].name; + + const [isIdCopied, setIsIdCopied] = useToggle(false); + + const handleCopyId = useCallback(() => { + setIsIdCopied.on(); + navigator.clipboard.writeText(id); + + createNotification({ + text: "Secret Sync ID copied to clipboard", + type: "info" + }); + + const timer = setTimeout(() => setIsIdCopied.off(), 2000); + + // eslint-disable-next-line consistent-return + return () => clearTimeout(timer); + }, [isIdCopied]); + + const failureMessage = useMemo(() => { + if (syncStatus === SecretSyncStatus.Failed) { + if (lastSyncMessage) + try { + return JSON.stringify(JSON.parse(lastSyncMessage), null, 2); + } catch { + return lastSyncMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [syncStatus, lastSyncMessage]); + + const destinationDetails = SECRET_SYNC_MAP[destination]; + + return ( + <Tr + onClick={() => + navigate({ + to: ROUTE_PATHS.SecretManager.SecretSyncDetailsByIDPage.path, + params: { + syncId: id, + destination, + projectId + } + }) + } + className={twMerge( + "group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700", + syncStatus === SecretSyncStatus.Failed && "bg-red/5 hover:bg-red/10" + )} + key={`sync-${id}`} + > + <Td> + <img + alt={`${destinationDetails.name} sync`} + src={`/images/integrations/${destinationDetails.image}`} + className="min-w-[1.75rem]" + /> + </Td> + <Td className="!min-w-[8rem] max-w-0"> + <div> + <div className="flex w-full items-center"> + <p className="truncate">{name}</p> + {description && ( + <Tooltip content={description}> + <FontAwesomeIcon + icon={faInfoCircle} + size="xs" + className="ml-1 text-mineshaft-400" + /> + </Tooltip> + )} + </div> + <p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p> + </div> + </Td> + {folder && environment ? ( + <SecretSyncTableCell primaryText={folder.path} secondaryText={environment.name} /> + ) : ( + <Td> + <Tooltip content="The source location for this sync has been deleted. Configure a new source or remove this sync."> + <div className="w-min"> + <Badge + className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" + variant="primary" + > + <FontAwesomeIcon icon={faTriangleExclamation} /> + <span>Source Folder Deleted</span> + </Badge> + </div> + </Tooltip> + </Td> + )} + <SecretSyncDestinationCol secretSync={secretSync} /> + <Td> + <div className="flex items-center gap-1"> + {syncStatus && ( + <Tooltip + position="left" + className="max-w-sm" + content={ + [SecretSyncStatus.Succeeded, SecretSyncStatus.Failed].includes(syncStatus) ? ( + <div className="flex flex-col gap-2 whitespace-normal py-1"> + {lastSyncedAt && ( + <div> + <div + className={`mb-2 flex self-start ${syncStatus === SecretSyncStatus.Failed ? "text-yellow" : "text-green"}`} + > + <FontAwesomeIcon + icon={faCalendarCheck} + className="ml-1 pr-1.5 pt-0.5 text-sm" + /> + <div className="text-xs">Last Synced</div> + </div> + <div className="rounded bg-mineshaft-600 p-2 text-xs"> + {format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")} + </div> + </div> + )} + {failureMessage && ( + <div> + <div className="mb-2 flex self-start text-red"> + <FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" /> + <div className="text-xs">Failure Reason</div> + </div> + <div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div> + </div> + )} + </div> + ) : undefined + } + > + <div> + <SecretSyncStatusBadge status={syncStatus} /> + </div> + </Tooltip> + )} + {!isAutoSyncEnabled && ( + <Tooltip + className="text-xs" + content="Auto-Sync is disabled. Changes to the source location will not be automatically synced to the destination." + > + <div> + <Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300"> + <FontAwesomeIcon icon={faBan} /> + {!syncStatus && "Auto-Sync Disabled"} + </Badge> + </div> + </Tooltip> + )} + <SecretSyncImportStatusBadge mini secretSync={secretSync} /> + <SecretSyncRemoveStatusBadge mini secretSync={secretSync} /> + </div> + </Td> + <Td> + <Tooltip className="max-w-sm text-center" content="Options"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <IconButton + ariaLabel="Options" + colorSchema="secondary" + className="w-6" + variant="plain" + > + <FontAwesomeIcon icon={faEllipsisV} /> + </IconButton> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />} + onClick={(e) => { + e.stopPropagation(); + handleCopyId(); + }} + > + Copy Sync ID + </DropdownMenuItem> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.SyncSecrets} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + icon={<FontAwesomeIcon icon={faRotate} />} + onClick={(e) => { + e.stopPropagation(); + onTriggerSyncSecrets(secretSync); + }} + isDisabled={!isAllowed} + > + <Tooltip + position="left" + sideOffset={42} + content={`Manually trigger a sync for this ${destinationName} destination.`} + > + <div className="flex h-full w-full items-center justify-between gap-1"> + <span> Trigger Sync</span> + <FontAwesomeIcon + className="text-bunker-300" + size="sm" + icon={faInfoCircle} + /> + </div> + </Tooltip> + </DropdownMenuItem> + )} + </ProjectPermissionCan> + {syncOption?.canImportSecrets && ( + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.ImportSecrets} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + icon={<FontAwesomeIcon icon={faDownload} />} + onClick={(e) => { + e.stopPropagation(); + onTriggerImportSecrets(secretSync); + }} + isDisabled={!isAllowed} + > + <Tooltip + position="left" + sideOffset={42} + content={`Import secrets from this ${destinationName} destination into Infisical.`} + > + <div className="flex h-full w-full items-center justify-between gap-1"> + <span>Import Secrets</span> + <FontAwesomeIcon + className="text-bunker-300" + size="sm" + icon={faInfoCircle} + /> + </div> + </Tooltip> + </DropdownMenuItem> + )} + </ProjectPermissionCan> + )} + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.RemoveSecrets} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + icon={<FontAwesomeIcon icon={faEraser} />} + onClick={(e) => { + e.stopPropagation(); + onTriggerRemoveSecrets(secretSync); + }} + isDisabled={!isAllowed} + > + <Tooltip + position="left" + sideOffset={42} + content={`Remove secrets synced by Infisical from this ${destinationName} destination.`} + > + <div className="flex h-full w-full items-center justify-between gap-1"> + <span>Remove Secrets</span> + <FontAwesomeIcon + className="text-bunker-300" + size="sm" + icon={faInfoCircle} + /> + </div> + </Tooltip> + </DropdownMenuItem> + )} + </ProjectPermissionCan> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Edit} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + isDisabled={!isAllowed} + icon={<FontAwesomeIcon icon={isAutoSyncEnabled ? faToggleOff : faToggleOn} />} + onClick={(e) => { + e.stopPropagation(); + onToggleEnable(secretSync); + }} + > + {isAutoSyncEnabled ? "Disable" : "Enable"} Auto-Sync + </DropdownMenuItem> + )} + </ProjectPermissionCan> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Delete} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + isDisabled={!isAllowed} + icon={<FontAwesomeIcon icon={faTrash} />} + onClick={(e) => { + e.stopPropagation(); + onDelete(secretSync); + }} + > + Delete Sync + </DropdownMenuItem> + )} + </ProjectPermissionCan> + </DropdownMenuContent> + </DropdownMenu> + </Tooltip> + </Td> + </Tr> + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncTableCell.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncTableCell.tsx new file mode 100644 index 0000000000..6f42c1f0e9 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncTableCell.tsx @@ -0,0 +1,67 @@ +import { ReactNode } from "react"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Td, Tooltip } from "@app/components/v2"; + +export type SecretSyncTableCellProps = { + primaryText: string; + secondaryText?: string; + infoBadge?: "primary" | "secondary"; + additionalTooltipContent?: ReactNode; + primaryClassName?: string; + secondaryClassName?: string; +}; + +export const SecretSyncTableCell = ({ + primaryText, + secondaryText, + infoBadge, + additionalTooltipContent, + primaryClassName, + secondaryClassName +}: SecretSyncTableCellProps) => { + return ( + <Td className="!min-w-[8rem] max-w-0"> + <Tooltip + side="left" + className="max-w-2xl break-words" + content={ + <> + <p className="text-sm">{primaryText}</p> + {secondaryText && <p className="text-xs leading-3 text-bunker-300">{secondaryText}</p>} + {additionalTooltipContent} + </> + } + > + <div> + <p className={twMerge("truncate text-sm", primaryClassName)}> + {primaryText} + {infoBadge === "primary" && ( + <FontAwesomeIcon + size="xs" + icon={faInfoCircle} + className="ml-1 inline-block text-bunker-300" + /> + )} + </p> + {secondaryText && ( + <p + className={twMerge("truncate text-xs leading-4 text-bunker-300", secondaryClassName)} + > + {secondaryText} + {infoBadge === "secondary" && ( + <FontAwesomeIcon + size="xs" + icon={faInfoCircle} + className="ml-1 inline-block text-bunker-300" + /> + )} + </p> + )} + </div> + </Tooltip> + </Td> + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx new file mode 100644 index 0000000000..a5e7d270d4 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx @@ -0,0 +1,495 @@ +import { useMemo, useState } from "react"; +import { + faArrowDown, + faArrowUp, + faCheck, + faCheckCircle, + faFilter, + faMagnifyingGlass, + faRotate, + faSearch, + faWarning +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { + DeleteSecretSyncModal, + SecretSyncImportSecretsModal, + SecretSyncRemoveSecretsModal +} from "@app/components/secret-syncs"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { + SecretSync, + SecretSyncStatus, + TSecretSync, + useTriggerSecretSyncSyncSecrets, + useUpdateSecretSync +} from "@app/hooks/api/secretSyncs"; + +import { getSecretSyncDestinationColValues } from "./helpers"; +import { SecretSyncRow } from "./SecretSyncRow"; + +enum SecretSyncsOrderBy { + Destination = "destination", + Source = "source", + Name = "name", + Status = "status" +} + +type SecretSyncFilters = { + destinations: SecretSync[]; + status: SecretSyncStatus[]; + environmentIds: string[]; +}; + +const getSyncStatusOrderValue = (syncStatus: SecretSyncStatus | null) => { + switch (syncStatus) { + case SecretSyncStatus.Failed: + return 0; + case SecretSyncStatus.Pending: + case SecretSyncStatus.Running: + return 1; + case SecretSyncStatus.Succeeded: + return 2; + default: + return 3; + } +}; + +type Props = { + secretSyncs: TSecretSync[]; +}; + +const STATUS_ICON_MAP = { + [SecretSyncStatus.Succeeded]: { icon: faCheck, className: "text-green", name: "Synced" }, + [SecretSyncStatus.Failed]: { icon: faWarning, className: "text-red", name: "Not Synced" }, + [SecretSyncStatus.Pending]: { icon: faRotate, className: "text-yellow", name: "Syncing" }, + [SecretSyncStatus.Running]: { icon: faRotate, className: "text-yellow", name: "Syncing" } +}; + +export const SecretSyncsTable = ({ secretSyncs }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "deleteSync", + "importSecrets", + "removeSecrets" + ] as const); + const triggerSync = useTriggerSecretSyncSyncSecrets(); + const updateSync = useUpdateSecretSync(); + + const [filters, setFilters] = useState<SecretSyncFilters>({ + destinations: [], + status: [], + environmentIds: [] + }); + + const { currentWorkspace } = useWorkspace(); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination<SecretSyncsOrderBy>(SecretSyncsOrderBy.Name, { initPerPage: 20 }); + + const filteredSecretSyncs = useMemo( + () => + secretSyncs + .filter((secretSync) => { + const { destination, name, connection, folder, environment, syncStatus } = secretSync; + + if (filters.destinations.length && !filters.destinations.includes(destination)) + return false; + + if ( + filters.environmentIds.length && + environment?.id && + !filters.environmentIds.includes(environment.id) + ) + return false; + + if (filters.status.length && (!syncStatus || !filters.status.includes(syncStatus))) { + return false; + } + + const searchValue = search.trim().toLowerCase(); + + const destinationValues = getSecretSyncDestinationColValues(secretSync); + + return ( + SECRET_SYNC_MAP[destination].name.toLowerCase().includes(searchValue) || + name.toLowerCase().includes(searchValue) || + folder?.path.toLowerCase().includes(searchValue) || + environment?.name.toLowerCase().includes(searchValue) || + connection.name.toLowerCase().includes(searchValue) || + destinationValues.primaryText.toLowerCase().includes(searchValue) || + destinationValues.secondaryText?.toLowerCase().includes(searchValue) + ); + }) + .sort((a, b) => { + const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case SecretSyncsOrderBy.Source: + return (syncOne.folder?.path ?? "") + .toLowerCase() + .localeCompare(syncTwo.folder?.path.toLowerCase() ?? ""); + case SecretSyncsOrderBy.Destination: + return getSecretSyncDestinationColValues(syncOne) + .primaryText.toLowerCase() + .localeCompare( + getSecretSyncDestinationColValues(syncTwo).primaryText.toLowerCase() + ); + case SecretSyncsOrderBy.Status: + if (!syncOne.syncStatus && syncTwo.syncStatus) return 1; + if (syncOne.syncStatus && !syncTwo.syncStatus) return -1; + if (!syncOne.syncStatus && !syncTwo.syncStatus) return 0; + + return ( + getSyncStatusOrderValue(syncOne.syncStatus) - + getSyncStatusOrderValue(syncTwo.syncStatus) + ); + case SecretSyncsOrderBy.Name: + default: + return syncOne.name.toLowerCase().localeCompare(syncTwo.name.toLowerCase()); + } + }), + [secretSyncs, orderDirection, search, orderBy, filters] + ); + + useResetPageHelper({ + totalCount: filteredSecretSyncs.length, + offset, + setPage + }); + + const handleSort = (column: SecretSyncsOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: SecretSyncsOrderBy) => + twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: SecretSyncsOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + + const isTableFiltered = Boolean(filters.destinations.length); + + const handleDelete = (secretSync: TSecretSync) => handlePopUpOpen("deleteSync", secretSync); + + const handleTriggerImportSecrets = (secretSync: TSecretSync) => + handlePopUpOpen("importSecrets", secretSync); + + const handleTriggerRemoveSecrets = (secretSync: TSecretSync) => + handlePopUpOpen("removeSecrets", secretSync); + + const handleToggleEnableSync = async (secretSync: TSecretSync) => { + const destinationName = SECRET_SYNC_MAP[secretSync.destination].name; + + const isAutoSyncEnabled = !secretSync.isAutoSyncEnabled; + + try { + await updateSync.mutateAsync({ + syncId: secretSync.id, + destination: secretSync.destination, + isAutoSyncEnabled + }); + + createNotification({ + text: `Successfully ${isAutoSyncEnabled ? "enabled" : "disabled"} auto-sync for ${destinationName} Sync`, + type: "success" + }); + } catch { + createNotification({ + text: `Failed to ${isAutoSyncEnabled ? "enable" : "disable"} auto-sync for ${destinationName} Sync`, + type: "error" + }); + } + }; + + const handleTriggerSync = async (secretSync: TSecretSync) => { + const destinationName = SECRET_SYNC_MAP[secretSync.destination].name; + + try { + await triggerSync.mutateAsync({ + syncId: secretSync.id, + destination: secretSync.destination + }); + + createNotification({ + text: `Successfully triggered ${destinationName} Sync`, + type: "success" + }); + } catch { + createNotification({ + text: `Failed to trigger ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( + <div> + <div className="flex gap-2"> + <Input + value={search} + onChange={(e) => setSearch(e.target.value)} + leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} + placeholder="Search secret syncs..." + className="flex-1" + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <IconButton + ariaLabel="Filter secret syncs" + variant="plain" + size="sm" + className={twMerge( + "flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10", + isTableFiltered && "border-primary/50 text-primary" + )} + > + <FontAwesomeIcon icon={faFilter} /> + </IconButton> + </DropdownMenuTrigger> + <DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end"> + <DropdownMenuLabel>Status</DropdownMenuLabel> + {Object.values(SecretSyncStatus).map((status) => ( + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + status: prev.status.includes(status) + ? prev.status.filter((s) => s !== status) + : [...prev.status, status] + })); + }} + key={status} + icon={ + filters.status.includes(status) && ( + <FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> + ) + } + iconPos="right" + > + <div className="flex items-center gap-2"> + <FontAwesomeIcon + icon={STATUS_ICON_MAP[status].icon} + className={STATUS_ICON_MAP[status].className} + /> + <span className="capitalize">{STATUS_ICON_MAP[status].name}</span> + </div> + </DropdownMenuItem> + ))} + <DropdownMenuLabel>Service</DropdownMenuLabel> + {secretSyncs.length ? ( + [...new Set(secretSyncs.map(({ destination }) => destination))].map((destination) => { + const { name, image } = SECRET_SYNC_MAP[destination]; + + return ( + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + destinations: prev.destinations.includes(destination) + ? prev.destinations.filter((a) => a !== destination) + : [...prev.destinations, destination] + })); + }} + key={destination} + icon={ + filters.destinations.includes(destination) && ( + <FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> + ) + } + iconPos="right" + > + <div className="flex items-center gap-2"> + <img + alt={`${name} integration`} + src={`/images/integrations/${image}`} + className="h-4 w-4" + /> + <span>{name}</span> + </div> + </DropdownMenuItem> + ); + }) + ) : ( + <DropdownMenuItem isDisabled>No Secret Syncs Configured</DropdownMenuItem> + )} + <DropdownMenuLabel>Environment</DropdownMenuLabel> + {currentWorkspace.environments.map((env) => ( + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + environmentIds: prev.environmentIds.includes(env.id) + ? prev.environmentIds.filter((i) => i !== env.id) + : [...prev.environmentIds, env.id] + })); + }} + key={env.id} + icon={ + filters.environmentIds.includes(env.id) && ( + <FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> + ) + } + iconPos="right" + > + <span className="capitalize">{env.name}</span> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + <TableContainer className="mt-4"> + <Table> + <THead> + <Tr> + <Th className="w-2" /> + <Th className="w-1/4"> + <div className="flex items-center"> + Name + <IconButton + variant="plain" + className={getClassName(SecretSyncsOrderBy.Name)} + ariaLabel="sort" + onClick={() => handleSort(SecretSyncsOrderBy.Name)} + > + <FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Name)} /> + </IconButton> + </div> + </Th> + <Th className="w-1/3"> + <div className="flex items-center"> + Source + <IconButton + variant="plain" + className={getClassName(SecretSyncsOrderBy.Source)} + ariaLabel="sort" + onClick={() => handleSort(SecretSyncsOrderBy.Source)} + > + <FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Source)} /> + </IconButton> + </div> + </Th> + <Th className="w-1/3"> + <div className="flex items-center"> + Destination + <IconButton + variant="plain" + className={getClassName(SecretSyncsOrderBy.Destination)} + ariaLabel="sort" + onClick={() => handleSort(SecretSyncsOrderBy.Destination)} + > + <FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Destination)} /> + </IconButton> + </div> + </Th> + <Th className="min-w-[10.5rem]"> + <div className="flex items-center"> + Status + <IconButton + variant="plain" + className={getClassName(SecretSyncsOrderBy.Status)} + ariaLabel="sort" + onClick={() => handleSort(SecretSyncsOrderBy.Status)} + > + <FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Status)} /> + </IconButton> + </div> + </Th> + <Th className="w-5" /> + </Tr> + </THead> + <TBody> + {filteredSecretSyncs.slice(offset, perPage * page).map((secretSync) => ( + <SecretSyncRow + key={secretSync.id} + secretSync={secretSync} + onDelete={handleDelete} + onTriggerSyncSecrets={handleTriggerSync} + onTriggerImportSecrets={handleTriggerImportSecrets} + onTriggerRemoveSecrets={handleTriggerRemoveSecrets} + onToggleEnable={handleToggleEnableSync} + /> + ))} + </TBody> + </Table> + {Boolean(filteredSecretSyncs.length) && ( + <Pagination + count={filteredSecretSyncs.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + /> + )} + {!filteredSecretSyncs?.length && ( + <EmptyState + title={ + secretSyncs.length + ? "No syncs match search..." + : "This project has no syncs configured" + } + icon={secretSyncs.length ? faSearch : faRotate} + /> + )} + </TableContainer> + <DeleteSecretSyncModal + onOpenChange={(isOpen) => handlePopUpToggle("deleteSync", isOpen)} + isOpen={popUp.deleteSync.isOpen} + secretSync={popUp.deleteSync.data} + /> + <SecretSyncImportSecretsModal + onOpenChange={(isOpen) => handlePopUpToggle("importSecrets", isOpen)} + isOpen={popUp.importSecrets.isOpen} + secretSync={popUp.importSecrets.data} + /> + <SecretSyncRemoveSecretsModal + onOpenChange={(isOpen) => handlePopUpToggle("removeSecrets", isOpen)} + isOpen={popUp.removeSecrets.isOpen} + secretSync={popUp.removeSecrets.data} + /> + </div> + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts new file mode 100644 index 0000000000..2b79015a05 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -0,0 +1,50 @@ +import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; +import { + GitHubSyncScope, + GitHubSyncVisibility +} from "@app/hooks/api/secretSyncs/types/github-sync"; + +// This functional ensures parity across what is displayed in the destination column +// and the values used when search filtering +export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { + let primaryText: string; + let secondaryText: string | undefined; + + const { destination, destinationConfig } = secretSync; + + switch (destination) { + case SecretSync.AWSParameterStore: + primaryText = destinationConfig.path; + secondaryText = destinationConfig.region; + break; + case SecretSync.GitHub: + switch (destinationConfig.scope) { + case GitHubSyncScope.Organization: + primaryText = destinationConfig.org; + if (destinationConfig.visibility === GitHubSyncVisibility.Selected) { + secondaryText = `Organization - ${destinationConfig.selectedRepositoryIds?.length ?? 0} Repositories`; + } else { + secondaryText = `Organization - ${destinationConfig.visibility} Repositories`; + } + break; + case GitHubSyncScope.Repository: + primaryText = `${destinationConfig.owner}/${destinationConfig.repo}`; + secondaryText = "Repository"; + break; + case GitHubSyncScope.RepositoryEnvironment: + primaryText = `${destinationConfig.owner}/${destinationConfig.repo}`; + secondaryText = `Environment - ${destinationConfig.env}`; + break; + default: + throw new Error(`Unhandled GitHub Scope Destination Col Values ${destination}`); + } + break; + default: + throw new Error(`Unhandled Destination Col Values ${destination}`); + } + + return { + primaryText, + secondaryText + }; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/index.ts new file mode 100644 index 0000000000..a1d3b8a4f2 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncsTable"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx new file mode 100644 index 0000000000..f44ea439b5 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx @@ -0,0 +1,84 @@ +import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { CreateSecretSyncModal } from "@app/components/secret-syncs"; +import { Button, Spinner } from "@app/components/v2"; +import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { usePopUp } from "@app/hooks"; +import { useListSecretSyncs } from "@app/hooks/api/secretSyncs"; + +import { SecretSyncsTable } from "./SecretSyncTable"; + +export const SecretSyncsTab = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const); + + const { currentWorkspace } = useWorkspace(); + + const { data: secretSyncs = [], isPending: isSecretSyncsPending } = useListSecretSyncs( + currentWorkspace.id, + { + refetchInterval: 4000 + } + ); + + if (isSecretSyncsPending) + return ( + <div className="flex h-[60vh] flex-col items-center justify-center gap-2"> + <Spinner /> + </div> + ); + + return ( + <> + <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> + <div className="mb-4 flex items-center justify-between"> + <div> + <div className="flex items-start gap-1"> + <p className="text-xl font-semibold text-mineshaft-100">Secret Syncs</p> + <a + href="https://infisical.com/docs/integrations/secret-syncs/overview" + target="_blank" + rel="noopener noreferrer" + > + <div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100"> + <FontAwesomeIcon icon={faBookOpen} className="mr-1.5" /> + <span>Docs</span> + <FontAwesomeIcon + icon={faArrowUpRightFromSquare} + className="mb-[0.07rem] ml-1.5 text-[10px]" + /> + </div> + </a> + </div> + <p className="text-sm text-bunker-300"> + Use App Connections to sync secrets to third-party services. + </p> + </div> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Create} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed) => ( + <Button + colorSchema="secondary" + type="submit" + leftIcon={<FontAwesomeIcon icon={faPlus} />} + onClick={() => handlePopUpOpen("addSync")} + isDisabled={!isAllowed} + > + Add Sync + </Button> + )} + </ProjectPermissionCan> + </div> + <SecretSyncsTable secretSyncs={secretSyncs} /> + </div> + <CreateSecretSyncModal + isOpen={popUp.addSync.isOpen} + onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)} + /> + </> + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/index.ts new file mode 100644 index 0000000000..74c03e1e4c --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncsTab"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/index.ts new file mode 100644 index 0000000000..ba1b8d3d33 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/index.ts @@ -0,0 +1,4 @@ +export * from "./FrameworkIntegrationTab"; +export * from "./InfrastructureIntegrationTab"; +export * from "./NativeIntegrationsTab"; +export * from "./SecretSyncsTab"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx index bb0085aea0..5a176b2f95 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx @@ -1,11 +1,22 @@ import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { IntegrationsListPageTabs } from "@app/types/integrations"; import { IntegrationsListPage } from "./IntegrationsListPage"; +const IntegrationsListPageQuerySchema = z.object({ + selectedTab: z + .nativeEnum(IntegrationsListPageTabs) + .catch(IntegrationsListPageTabs.NativeIntegrations) +}); + export const Route = createFileRoute( "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/" )({ component: IntegrationsListPage, + validateSearch: zodValidator(IntegrationsListPageQuerySchema), beforeLoad: ({ context }) => { return { breadcrumbs: [ diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx new file mode 100644 index 0000000000..1a67e98a0c --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx @@ -0,0 +1,154 @@ +import { Helmet } from "react-helmet"; +import { faBan, faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useParams } from "@tanstack/react-router"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { EditSecretSyncModal } from "@app/components/secret-syncs"; +import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; +import { Button, ContentLoader, EmptyState } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { usePopUp } from "@app/hooks"; +import { SecretSync, useGetSecretSync } from "@app/hooks/api/secretSyncs"; +import { IntegrationsListPageTabs } from "@app/types/integrations"; + +import { + SecretSyncActionTriggers, + SecretSyncAuditLogsSection, + SecretSyncDestinationSection, + SecretSyncDetailsSection, + SecretSyncOptionsSection, + SecretSyncSourceSection +} from "./components"; + +const PageContent = () => { + const navigate = useNavigate(); + const { destination, syncId, projectId } = useParams({ + from: ROUTE_PATHS.SecretManager.SecretSyncDetailsByIDPage.id, + select: (params) => ({ + ...params, + destination: params.destination as SecretSync + }) + }); + + const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["editSync"] as const); + + const { data: secretSync, isPending } = useGetSecretSync(destination, syncId, { + refetchInterval: 4000 + }); + + if (isPending) { + return ( + <div className="flex h-full w-full items-center justify-center"> + <ContentLoader /> + </div> + ); + } + + if (!secretSync) { + return ( + <div className="flex h-full w-full items-center justify-center px-20"> + <EmptyState + className="max-w-2xl rounded-md text-center" + icon={faBan} + title={`Could not find ${SECRET_SYNC_MAP[destination].name ?? "Secret"} Sync with ID ${syncId}`} + /> + </div> + ); + } + + const destinationDetails = SECRET_SYNC_MAP[secretSync.destination]; + + const handleEditDetails = () => handlePopUpOpen("editSync", SecretSyncEditFields.Details); + + const handleEditSource = () => handlePopUpOpen("editSync", SecretSyncEditFields.Source); + + // const handleEditOptions = () => handlePopUpOpen("editSync", SecretSyncEditFields.Options); + + const handleEditDestination = () => handlePopUpOpen("editSync", SecretSyncEditFields.Destination); + + return ( + <> + <div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white"> + <div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6"> + <Button + variant="link" + type="submit" + leftIcon={<FontAwesomeIcon icon={faChevronLeft} />} + onClick={() => { + navigate({ + to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path, + params: { + projectId + }, + search: { + selectedTab: IntegrationsListPageTabs.SecretSyncs + } + }); + }} + className="mb-4" + > + Secret Syncs + </Button> + <div className="mb-6 flex w-full items-center gap-3"> + <img + alt={`${destinationDetails.name} sync`} + src={`/images/integrations/${destinationDetails.image}`} + className="ml-1 mt-3 w-16" + /> + <div> + <p className="text-3xl font-semibold text-white">{secretSync.name}</p> + <p className="leading-3 text-bunker-300">{destinationDetails.name} Sync</p> + </div> + <SecretSyncActionTriggers secretSync={secretSync} /> + </div> + <div className="flex justify-center"> + <div className="mr-4 flex w-72 flex-col gap-4"> + <SecretSyncDetailsSection secretSync={secretSync} onEditDetails={handleEditDetails} /> + <SecretSyncSourceSection secretSync={secretSync} onEditSource={handleEditSource} /> + <SecretSyncOptionsSection + secretSync={secretSync} + // onEditOptions={handleEditOptions} + /> + </div> + <div className="flex flex-1 flex-col gap-4"> + <SecretSyncDestinationSection + secretSync={secretSync} + onEditDestination={handleEditDestination} + /> + <SecretSyncAuditLogsSection secretSync={secretSync} /> + </div> + </div> + </div> + </div> + <EditSecretSyncModal + isOpen={popUp.editSync.isOpen} + onOpenChange={(isOpen) => handlePopUpToggle("editSync", isOpen)} + fields={popUp.editSync.data} + secretSync={secretSync} + /> + </> + ); +}; + +export const SecretSyncDetailsByIDPage = () => { + return ( + <> + <Helmet> + <title>Secret Sync | Infisical</title> + <link rel="icon" href="/infisical.ico" /> + </Helmet> + <ProjectPermissionCan + renderGuardBanner + passThrough={false} + I={ProjectPermissionSecretSyncActions.Read} + a={ProjectPermissionSub.SecretSyncs} + > + <PageContent /> + </ProjectPermissionCan> + </> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx new file mode 100644 index 0000000000..bc4f6d2b88 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx @@ -0,0 +1,312 @@ +import { useCallback } from "react"; +import { + faBan, + faCheck, + faCopy, + faDownload, + faEllipsisV, + faEraser, + faInfoCircle, + faRotate, + faToggleOff, + faToggleOn, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DeleteSecretSyncModal, + SecretSyncImportSecretsModal, + SecretSyncImportStatusBadge, + SecretSyncRemoveSecretsModal, + SecretSyncRemoveStatusBadge +} from "@app/components/secret-syncs"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { usePopUp, useToggle } from "@app/hooks"; +import { + TSecretSync, + useSecretSyncOption, + useTriggerSecretSyncSyncSecrets, + useUpdateSecretSync +} from "@app/hooks/api/secretSyncs"; +import { IntegrationsListPageTabs } from "@app/types/integrations"; + +type Props = { + secretSync: TSecretSync; +}; + +export const SecretSyncActionTriggers = ({ secretSync }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "importSecrets", + "removeSecrets", + "deleteSync" + ] as const); + + const navigate = useNavigate(); + + const triggerSyncSecrets = useTriggerSecretSyncSyncSecrets(); + const updateSync = useUpdateSecretSync(); + + const { destination } = secretSync; + + const destinationName = SECRET_SYNC_MAP[destination].name; + const { syncOption } = useSecretSyncOption(destination); + + const [isIdCopied, setIsIdCopied] = useToggle(false); + + const handleCopyId = useCallback(() => { + setIsIdCopied.on(); + navigator.clipboard.writeText(secretSync.id); + + createNotification({ + text: "Secret Sync ID copied to clipboard", + type: "info" + }); + + const timer = setTimeout(() => setIsIdCopied.off(), 2000); + + // eslint-disable-next-line consistent-return + return () => clearTimeout(timer); + }, [isIdCopied]); + + const handleToggleEnableSync = async () => { + const isAutoSyncEnabled = !secretSync.isAutoSyncEnabled; + + try { + await updateSync.mutateAsync({ + syncId: secretSync.id, + destination: secretSync.destination, + isAutoSyncEnabled + }); + + createNotification({ + text: `Successfully ${isAutoSyncEnabled ? "enabled" : "disabled"} auto-sync for ${destinationName} Sync`, + type: "success" + }); + } catch { + createNotification({ + text: `Failed to ${isAutoSyncEnabled ? "enable" : "disable"} auto-sync for ${destinationName} Sync`, + type: "error" + }); + } + }; + + const handleTriggerSync = async () => { + try { + await triggerSyncSecrets.mutateAsync({ + syncId: secretSync.id, + destination: secretSync.destination + }); + + createNotification({ + text: `Successfully triggered ${destinationName} Sync`, + type: "success" + }); + } catch { + createNotification({ + text: `Failed to trigger ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( + <> + <div className="ml-auto mt-4 flex flex-wrap items-center justify-end gap-2"> + <SecretSyncImportStatusBadge secretSync={secretSync} /> + <SecretSyncRemoveStatusBadge secretSync={secretSync} /> + {secretSync.isAutoSyncEnabled ? ( + <Badge + variant="success" + className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" + > + <FontAwesomeIcon icon={faRotate} /> + <span>Auto-Sync Enabled</span> + </Badge> + ) : ( + <Tooltip + className="text-xs" + content="Auto-Sync is disabled. Changes to the source location will not be automatically synced to the destination." + > + <div> + <Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300"> + <FontAwesomeIcon icon={faBan} /> + <span>Auto-Sync Disabled</span> + </Badge> + </div> + </Tooltip> + )} + <div> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.SyncSecrets} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <Button + variant="outline_bg" + leftIcon={<FontAwesomeIcon icon={faRotate} />} + onClick={handleTriggerSync} + className="h-9 rounded-r-none bg-mineshaft-500" + isDisabled={!isAllowed} + > + Trigger Sync + </Button> + )} + </ProjectPermissionCan> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <IconButton + ariaLabel="add-folder-or-import" + variant="outline_bg" + className="h-9 w-10 rounded-l-none border-l-2 border-mineshaft border-l-mineshaft-700 bg-mineshaft-500" + > + <FontAwesomeIcon icon={faEllipsisV} /> + </IconButton> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />} + onClick={(e) => { + e.stopPropagation(); + handleCopyId(); + }} + > + Copy Sync ID + </DropdownMenuItem> + {syncOption?.canImportSecrets && ( + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.ImportSecrets} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + icon={<FontAwesomeIcon icon={faDownload} />} + onClick={() => handlePopUpOpen("importSecrets")} + isDisabled={!isAllowed} + > + <Tooltip + position="left" + sideOffset={42} + content={`Import secrets from this ${destinationName} destination into Infisical.`} + > + <div className="flex h-full w-full items-center justify-between gap-1"> + <span>Import Secrets</span> + <FontAwesomeIcon + className="text-bunker-300" + size="sm" + icon={faInfoCircle} + /> + </div> + </Tooltip> + </DropdownMenuItem> + )} + </ProjectPermissionCan> + )} + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.RemoveSecrets} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + icon={<FontAwesomeIcon icon={faEraser} />} + onClick={() => handlePopUpOpen("removeSecrets")} + isDisabled={!isAllowed} + > + <Tooltip + position="left" + sideOffset={42} + content={`Remove secrets synced by Infisical from this ${destinationName} destination.`} + > + <div className="flex h-full w-full items-center justify-between gap-1"> + <span>Remove Secrets</span> + <FontAwesomeIcon + className="text-bunker-300" + size="sm" + icon={faInfoCircle} + /> + </div> + </Tooltip> + </DropdownMenuItem> + )} + </ProjectPermissionCan> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Edit} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + isDisabled={!isAllowed} + icon={ + <FontAwesomeIcon + icon={secretSync.isAutoSyncEnabled ? faToggleOff : faToggleOn} + /> + } + onClick={handleToggleEnableSync} + > + {secretSync.isAutoSyncEnabled ? "Disable" : "Enable"} Auto-Sync + </DropdownMenuItem> + )} + </ProjectPermissionCan> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Delete} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed: boolean) => ( + <DropdownMenuItem + isDisabled={!isAllowed} + icon={<FontAwesomeIcon icon={faTrash} />} + onClick={() => handlePopUpOpen("deleteSync")} + > + Delete Sync + </DropdownMenuItem> + )} + </ProjectPermissionCan> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + <SecretSyncImportSecretsModal + onOpenChange={(isOpen) => handlePopUpToggle("importSecrets", isOpen)} + isOpen={popUp.importSecrets.isOpen} + secretSync={secretSync} + /> + <SecretSyncRemoveSecretsModal + onOpenChange={(isOpen) => handlePopUpToggle("removeSecrets", isOpen)} + isOpen={popUp.removeSecrets.isOpen} + secretSync={secretSync} + /> + <DeleteSecretSyncModal + onOpenChange={(isOpen) => handlePopUpToggle("deleteSync", isOpen)} + isOpen={popUp.deleteSync.isOpen} + secretSync={secretSync} + onComplete={() => + navigate({ + to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path, + params: { + projectId: secretSync.projectId + }, + search: { + selectedTab: IntegrationsListPageTabs.SecretSyncs + } + }) + } + /> + </> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx new file mode 100644 index 0000000000..c13b5e1322 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx @@ -0,0 +1,85 @@ +import { faFingerprint } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { useSubscription } from "@app/context"; +import { EventType } from "@app/hooks/api/auditLogs/enums"; +import { TSecretSync } from "@app/hooks/api/secretSyncs"; +import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection"; + +const INTEGRATION_EVENTS = [ + EventType.SECRET_SYNC_SYNC_SECRETS, + EventType.SECRET_SYNC_REMOVE_SECRETS, + EventType.SECRET_SYNC_IMPORT_SECRETS +]; + +type Props = { + secretSync: TSecretSync; +}; + +export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => { + const { subscription } = useSubscription(); + + const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30; + + return ( + <div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> + <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> + <h3 className="font-semibold text-mineshaft-100">Sync Logs</h3> + {subscription.auditLogs && ( + <p className="text-xs text-bunker-300"> + Displaying audit logs from the last {auditLogsRetentionDays} days + </p> + )} + </div> + {subscription.auditLogs ? ( + <LogsSection + refetchInterval={4000} + remappedHeaders={{ + Metadata: "Sync Status" + }} + showFilters={false} + presets={{ + eventMetadata: { syncId: secretSync.id }, + startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)), + eventType: INTEGRATION_EVENTS + }} + filterClassName="bg-mineshaft-900 static" + /> + ) : ( + <div className="flex h-full items-center justify-center rounded-lg bg-mineshaft-800 text-sm text-mineshaft-200"> + <div className="flex flex-col items-center gap-4 py-20"> + <FontAwesomeIcon size="2x" icon={faFingerprint} /> + <p> + Please{" "} + {subscription && subscription.slug !== null ? ( + <Link to="/organization/billing" target="_blank" rel="noopener noreferrer"> + <a + className="cursor-pointer underline transition-all hover:text-white" + target="_blank" + > + upgrade your subscription + </a> + </Link> + ) : ( + <a + href="https://infisical.com/scheduledemo" + target="_blank" + rel="noopener noreferrer" + > + <a + className="cursor-pointer underline transition-all hover:text-white" + target="_blank" + > + upgrade your subscription + </a> + </a> + )}{" "} + to view sync logs. + </p> + </div> + </div> + )} + </div> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection.tsx new file mode 100644 index 0000000000..3ec11c6dd6 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection.tsx @@ -0,0 +1,28 @@ +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { Badge } from "@app/components/v2"; +import { AWS_REGIONS } from "@app/helpers/appConnections"; +import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync"; + +type Props = { + secretSync: TAwsParameterStoreSync; +}; + +export const AwsParameterStoreSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { path, region } + } = secretSync; + + const awsRegion = AWS_REGIONS.find((r) => r.slug === region); + + return ( + <> + <SecretSyncLabel label="Region"> + {awsRegion?.name} + <Badge className="ml-1" variant="success"> + {awsRegion?.slug}{" "} + </Badge> + </SecretSyncLabel> + <SecretSyncLabel label="Path">{path}</SecretSyncLabel> + </> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx new file mode 100644 index 0000000000..298fd3670f --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection.tsx @@ -0,0 +1,75 @@ +import { ReactNode } from "react"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { GitHubSyncSelectedRepositoriesTooltipContent } from "@app/components/secret-syncs/github"; +import { Tooltip } from "@app/components/v2"; +import { + GitHubSyncScope, + GitHubSyncVisibility, + TGitHubSync +} from "@app/hooks/api/secretSyncs/types/github-sync"; + +type Props = { + secretSync: TGitHubSync; +}; + +export const GitHubSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + let Components: ReactNode; + switch (destinationConfig.scope) { + case GitHubSyncScope.Organization: + Components = ( + <> + <SecretSyncLabel label="Organization">{destinationConfig.org}</SecretSyncLabel> + <SecretSyncLabel label="Visibility" className="capitalize"> + {destinationConfig.visibility} Repositories + </SecretSyncLabel> + {destinationConfig.visibility === GitHubSyncVisibility.Selected && ( + <SecretSyncLabel label="Selected Repositories"> + {destinationConfig.selectedRepositoryIds?.length ?? 0} Repositories + <Tooltip + side="bottom" + content={<GitHubSyncSelectedRepositoriesTooltipContent secretSync={secretSync} />} + > + <FontAwesomeIcon size="xs" className="ml-1 text-bunker-300" icon={faInfoCircle} /> + </Tooltip> + </SecretSyncLabel> + )} + </> + ); + break; + case GitHubSyncScope.Repository: + Components = ( + <SecretSyncLabel label="Repository"> + {destinationConfig.owner}/{destinationConfig.repo} + </SecretSyncLabel> + ); + break; + case GitHubSyncScope.RepositoryEnvironment: + Components = ( + <> + <SecretSyncLabel label="Repository"> + {destinationConfig.owner}/{destinationConfig.repo} + </SecretSyncLabel> + <SecretSyncLabel label="Environment">{destinationConfig.env}</SecretSyncLabel> + </> + ); + break; + default: + throw new Error( + `Uhandled GitHub Sync Destination Section Scope ${secretSync.destinationConfig.scope}` + ); + } + + return ( + <> + <SecretSyncLabel className="capitalize" label="Scope"> + {destinationConfig.scope.replace("-", " ")} + </SecretSyncLabel> + {Components} + </> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx new file mode 100644 index 0000000000..fdc87c97c2 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -0,0 +1,64 @@ +import { ReactNode } from "react"; +import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { IconButton } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; +import { AwsParameterStoreSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection"; +import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection"; + +type Props = { + secretSync: TSecretSync; + onEditDestination: VoidFunction; +}; + +export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: Props) => { + const { destination, connection } = secretSync; + + const app = APP_CONNECTION_MAP[connection.app].name; + + let DestinationComponents: ReactNode; + switch (secretSync.destination) { + case SecretSync.AWSParameterStore: + DestinationComponents = <AwsParameterStoreSyncDestinationSection secretSync={secretSync} />; + break; + case SecretSync.GitHub: + DestinationComponents = <GitHubSyncDestinationSection secretSync={secretSync} />; + break; + default: + throw new Error(`Unhandled Destination Section components: ${destination}`); + } + + return ( + <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> + <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> + <h3 className="font-semibold text-mineshaft-100">Destination Configuration</h3> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Edit} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed) => ( + <IconButton + variant="plain" + colorSchema="secondary" + isDisabled={!isAllowed} + ariaLabel="Edit sync destination" + onClick={onEditDestination} + > + <FontAwesomeIcon icon={faEdit} /> + </IconButton> + )} + </ProjectPermissionCan> + </div> + <div className="flex w-full flex-wrap gap-8"> + <SecretSyncLabel label={`${app} Connection`}>{connection.name}</SecretSyncLabel> + {DestinationComponents} + </div> + </div> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/index.ts b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/index.ts new file mode 100644 index 0000000000..e92a18aac3 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncDestinatonSection"; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx new file mode 100644 index 0000000000..5491d53ab4 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { IconButton } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync: TSecretSync; + onEditDetails: VoidFunction; +}; + +export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) => { + const { syncStatus, lastSyncMessage, lastSyncedAt, name, description } = secretSync; + + const failureMessage = useMemo(() => { + if (syncStatus === SecretSyncStatus.Failed) { + if (lastSyncMessage) + try { + return JSON.stringify(JSON.parse(lastSyncMessage), null, 2); + } catch { + return lastSyncMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [syncStatus, lastSyncMessage]); + + return ( + <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> + <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> + <h3 className="font-semibold text-mineshaft-100">Details</h3> + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Edit} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed) => ( + <IconButton + variant="plain" + colorSchema="secondary" + isDisabled={!isAllowed} + ariaLabel="Edit sync details" + onClick={onEditDetails} + > + <FontAwesomeIcon icon={faEdit} /> + </IconButton> + )} + </ProjectPermissionCan> + </div> + <div> + <div className="space-y-3"> + <SecretSyncLabel label="Name">{name}</SecretSyncLabel> + <SecretSyncLabel label="Description">{description}</SecretSyncLabel> + {lastSyncedAt && ( + <SecretSyncLabel label="Last Synced"> + {format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")} + </SecretSyncLabel> + )} + {syncStatus === SecretSyncStatus.Failed && failureMessage && ( + <SecretSyncLabel labelClassName="text-red" label="Last Sync Error"> + <p className="break-words rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</p> + </SecretSyncLabel> + )} + </div> + </div> + </div> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx new file mode 100644 index 0000000000..0e536614f8 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx @@ -0,0 +1,57 @@ +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP } from "@app/helpers/secretSyncs"; +import { TSecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync: TSecretSync; + // onEditOptions: VoidFunction; +}; + +export const SecretSyncOptionsSection = ({ + secretSync + // onEditOptions +}: Props) => { + const { + destination, + syncOptions: { + // appendSuffix, + // prependPrefix, + initialSyncBehavior + } + } = secretSync; + + return ( + <div> + <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> + <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> + <h3 className="font-semibold text-mineshaft-100">Sync Options</h3> + {/* <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Edit} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed) => ( + <IconButton + variant="plain" + colorSchema="secondary" + isDisabled={!isAllowed} + ariaLabel="Edit sync options" + onClick={onEditOptions} + > + <FontAwesomeIcon icon={faEdit} /> + </IconButton> + )} + </ProjectPermissionCan> */} + </div> + <div> + <div className="space-y-3"> + <SecretSyncLabel label="Initial Sync Behavior"> + {SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name} + </SecretSyncLabel> + {/* <SecretSyncLabel label="Prefix">{prependPrefix}</SecretSyncLabel> + <SecretSyncLabel label="Suffix">{appendSuffix}</SecretSyncLabel> */} + </div> + </div> + </div> + </div> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx new file mode 100644 index 0000000000..92eff2e326 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx @@ -0,0 +1,65 @@ +import { faEdit, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { Badge, IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { TSecretSync } from "@app/hooks/api/secretSyncs"; + +type Props = { + secretSync: TSecretSync; + onEditSource: VoidFunction; +}; + +export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) => { + const { folder, environment } = secretSync; + + return ( + <div> + <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> + <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> + <h3 className="font-semibold text-mineshaft-100">Source</h3> + <div> + {(!folder || !environment) && ( + <Tooltip content="The source location for this sync has been deleted. Configure a new source or remove this sync."> + <div className="mr-1 inline-block w-min"> + <Badge + className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" + variant="primary" + > + <FontAwesomeIcon icon={faTriangleExclamation} /> + <span>Folder Deleted</span> + </Badge> + </div> + </Tooltip> + )} + <ProjectPermissionCan + I={ProjectPermissionSecretSyncActions.Edit} + a={ProjectPermissionSub.SecretSyncs} + > + {(isAllowed) => ( + <IconButton + variant="plain" + colorSchema="secondary" + isDisabled={!isAllowed} + ariaLabel="Edit sync source" + onClick={onEditSource} + > + <FontAwesomeIcon icon={faEdit} /> + </IconButton> + )} + </ProjectPermissionCan> + </div> + </div> + <div> + <div className="space-y-3"> + <SecretSyncLabel label="Environment">{environment?.name}</SecretSyncLabel> + <SecretSyncLabel label="Path">{folder?.path}</SecretSyncLabel> + </div> + </div> + </div> + </div> + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/index.ts b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/index.ts new file mode 100644 index 0000000000..bfef41a355 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/index.ts @@ -0,0 +1,6 @@ +export * from "./SecretSyncActionTriggers"; +export * from "./SecretSyncAuditLogsSection"; +export * from "./SecretSyncDestinationSection"; +export * from "./SecretSyncDetailsSection"; +export * from "./SecretSyncOptionsSection"; +export * from "./SecretSyncSourceSection"; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/route.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/route.tsx new file mode 100644 index 0000000000..7fcade4859 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/route.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, linkOptions } from "@tanstack/react-router"; + +import { SecretSyncDetailsByIDPage } from "./SecretSyncDetailsByIDPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId" +)({ + component: SecretSyncDetailsByIDPage, + beforeLoad: ({ context, params }) => { + return { + breadcrumbs: [ + ...context.breadcrumbs, + { + label: "Integrations", + link: linkOptions({ + to: "/secret-manager/$projectId/integrations", + params + }) + }, + { + label: "Secret Sync" + } + ] + }; + } +}); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 89d4a2a0bb..3267f192c9 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -169,6 +169,7 @@ import { Route as secretManagerIntegrationsAwsSecretManagerAuthorizePageRouteImp import { Route as secretManagerIntegrationsAwsParameterStoreConfigurePageRouteImport } from './pages/secret-manager/integrations/AwsParameterStoreConfigurePage/route' import { Route as secretManagerIntegrationsAwsParameterStoreAuthorizePageRouteImport } from './pages/secret-manager/integrations/AwsParameterStoreAuthorizePage/route' import { Route as secretManagerIntegrationsVercelOauthCallbackPageRouteImport } from './pages/secret-manager/integrations/VercelOauthCallbackPage/route' +import { Route as secretManagerSecretSyncDetailsByIDPageRouteImport } from './pages/secret-manager/SecretSyncDetailsByIDPage/route' import { Route as secretManagerIntegrationsNetlifyOauthCallbackPageRouteImport } from './pages/secret-manager/integrations/NetlifyOauthCallbackPage/route' import { Route as secretManagerIntegrationsHerokuOauthCallbackPageRouteImport } from './pages/secret-manager/integrations/HerokuOauthCallbackPage/route' import { Route as secretManagerIntegrationsGitlabOauthCallbackPageRouteImport } from './pages/secret-manager/integrations/GitlabOauthCallbackPage/route' @@ -1463,6 +1464,14 @@ const secretManagerIntegrationsVercelOauthCallbackPageRouteRoute = AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLayoutIntegrationsRoute, } as any) +const secretManagerSecretSyncDetailsByIDPageRouteRoute = + secretManagerSecretSyncDetailsByIDPageRouteImport.update({ + id: '/secret-syncs/$destination/$syncId', + path: '/secret-syncs/$destination/$syncId', + getParentRoute: () => + AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLayoutIntegrationsRoute, + } as any) + const secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute = secretManagerIntegrationsNetlifyOauthCallbackPageRouteImport.update({ id: '/netlify/oauth2/callback', @@ -2751,6 +2760,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof secretManagerIntegrationsNetlifyOauthCallbackPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLayoutIntegrationsImport } + '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId': { + id: '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId' + path: '/secret-syncs/$destination/$syncId' + fullPath: '/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId' + preLoaderRoute: typeof secretManagerSecretSyncDetailsByIDPageRouteImport + parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLayoutIntegrationsImport + } '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback': { id: '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback' path: '/vercel/oauth2/callback' @@ -3020,6 +3036,7 @@ interface AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManag secretManagerIntegrationsGitlabOauthCallbackPageRouteRoute: typeof secretManagerIntegrationsGitlabOauthCallbackPageRouteRoute secretManagerIntegrationsHerokuOauthCallbackPageRouteRoute: typeof secretManagerIntegrationsHerokuOauthCallbackPageRouteRoute secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute: typeof secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute + secretManagerSecretSyncDetailsByIDPageRouteRoute: typeof secretManagerSecretSyncDetailsByIDPageRouteRoute secretManagerIntegrationsVercelOauthCallbackPageRouteRoute: typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute } @@ -3177,6 +3194,8 @@ const AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdSecretManagerLa secretManagerIntegrationsHerokuOauthCallbackPageRouteRoute, secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute: secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute, + secretManagerSecretSyncDetailsByIDPageRouteRoute: + secretManagerSecretSyncDetailsByIDPageRouteRoute, secretManagerIntegrationsVercelOauthCallbackPageRouteRoute: secretManagerIntegrationsVercelOauthCallbackPageRouteRoute, } @@ -3629,6 +3648,7 @@ export interface FileRoutesByFullPath { '/secret-manager/$projectId/integrations/gitlab/oauth2/callback': typeof secretManagerIntegrationsGitlabOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/heroku/oauth2/callback': typeof secretManagerIntegrationsHerokuOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/netlify/oauth2/callback': typeof secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute + '/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId': typeof secretManagerSecretSyncDetailsByIDPageRouteRoute '/secret-manager/$projectId/integrations/vercel/oauth2/callback': typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute } @@ -3793,6 +3813,7 @@ export interface FileRoutesByTo { '/secret-manager/$projectId/integrations/gitlab/oauth2/callback': typeof secretManagerIntegrationsGitlabOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/heroku/oauth2/callback': typeof secretManagerIntegrationsHerokuOauthCallbackPageRouteRoute '/secret-manager/$projectId/integrations/netlify/oauth2/callback': typeof secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute + '/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId': typeof secretManagerSecretSyncDetailsByIDPageRouteRoute '/secret-manager/$projectId/integrations/vercel/oauth2/callback': typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute } @@ -3972,6 +3993,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/gitlab/oauth2/callback': typeof secretManagerIntegrationsGitlabOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/heroku/oauth2/callback': typeof secretManagerIntegrationsHerokuOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/netlify/oauth2/callback': typeof secretManagerIntegrationsNetlifyOauthCallbackPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId': typeof secretManagerSecretSyncDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback': typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute } @@ -4143,6 +4165,7 @@ export interface FileRouteTypes { | '/secret-manager/$projectId/integrations/gitlab/oauth2/callback' | '/secret-manager/$projectId/integrations/heroku/oauth2/callback' | '/secret-manager/$projectId/integrations/netlify/oauth2/callback' + | '/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId' | '/secret-manager/$projectId/integrations/vercel/oauth2/callback' fileRoutesByTo: FileRoutesByTo to: @@ -4306,6 +4329,7 @@ export interface FileRouteTypes { | '/secret-manager/$projectId/integrations/gitlab/oauth2/callback' | '/secret-manager/$projectId/integrations/heroku/oauth2/callback' | '/secret-manager/$projectId/integrations/netlify/oauth2/callback' + | '/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId' | '/secret-manager/$projectId/integrations/vercel/oauth2/callback' id: | '__root__' @@ -4483,6 +4507,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/gitlab/oauth2/callback' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/heroku/oauth2/callback' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/netlify/oauth2/callback' + | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback' fileRoutesById: FileRoutesById } @@ -5047,6 +5072,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/gitlab/oauth2/callback", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/heroku/oauth2/callback", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/netlify/oauth2/callback", + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback" ] }, @@ -5426,6 +5452,10 @@ export const routeTree = rootRoute "filePath": "secret-manager/integrations/NetlifyOauthCallbackPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations" }, + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId": { + "filePath": "secret-manager/SecretSyncDetailsByIDPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations" + }, "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback": { "filePath": "secret-manager/integrations/VercelOauthCallbackPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 4fc7483619..b695f0c3a7 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -41,6 +41,10 @@ const secretManagerRoutes = route("/secret-manager/$projectId", [ route("/integrations", [ index("secret-manager/IntegrationsListPage/route.tsx"), route("/$integrationId", "secret-manager/IntegrationsDetailsByIDPage/route.tsx"), + route( + "/secret-syncs/$destination/$syncId", + "secret-manager/SecretSyncDetailsByIDPage/route.tsx" + ), route( "/aws-parameter-store/authorize", "secret-manager/integrations/AwsParameterStoreAuthorizePage/route.tsx" diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts new file mode 100644 index 0000000000..dfa6e38d5d --- /dev/null +++ b/frontend/src/types/integrations.ts @@ -0,0 +1,6 @@ +export enum IntegrationsListPageTabs { + NativeIntegrations = "native-integrations", + FrameworkIntegrations = "framework-integrations", + InfrastructureIntegrations = "infrastructure-integrations", + SecretSyncs = "secret-syncs" +}