diff --git a/backend/src/db/migrations/20241219210911_secret-sync.ts b/backend/src/db/migrations/20241219210911_secret-sync.ts index 4858cc58b..d5d00673b 100644 --- a/backend/src/db/migrations/20241219210911_secret-sync.ts +++ b/backend/src/db/migrations/20241219210911_secret-sync.ts @@ -14,8 +14,12 @@ export async function up(knex: Knex): Promise { t.integer("version").defaultTo(1).notNullable(); t.jsonb("destinationConfig").notNullable(); t.jsonb("syncOptions").notNullable(); - t.uuid("folderId").notNullable(); - t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + // 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); diff --git a/backend/src/db/schemas/secret-syncs.ts b/backend/src/db/schemas/secret-syncs.ts index 1f78360b5..995a0f97a 100644 --- a/backend/src/db/schemas/secret-syncs.ts +++ b/backend/src/db/schemas/secret-syncs.ts @@ -16,7 +16,8 @@ export const SecretSyncsSchema = z.object({ version: z.number().default(1), destinationConfig: z.unknown(), syncOptions: z.unknown(), - folderId: z.string().uuid(), + projectId: z.string(), + folderId: z.string().uuid().nullable().optional(), connectionId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index b12996854..c8ee03a99 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; -import { OrgPermissionSchema } from "@app/ee/services/permission/org-permission"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -25,7 +24,8 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { ), name: z.string().trim(), description: z.string().trim().nullish(), - permissions: OrgPermissionSchema.array() + // TODO(scott): once UI refactored permissions: OrgPermissionSchema.array() + permissions: z.any().array() }), response: { 200: z.object({ @@ -97,7 +97,8 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .optional(), name: z.string().trim().optional(), description: z.string().trim().nullish(), - permissions: OrgPermissionSchema.array().optional() + // TODO(scott): once UI refactored permissions: OrgPermissionSchema.array().optional() + permissions: z.any().array().optional() }), response: { 200: z.object({ diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 0e3a5f6b9..3dc7daddc 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -34,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", @@ -145,7 +155,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] - | [ProjectPermissionActions, ProjectPermissionSub.SecretSyncs] + | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] @@ -396,7 +406,7 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( "Describe what action an entity can take." ) }) @@ -514,8 +524,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, - ProjectPermissionSub.SshCertificateTemplates, - ProjectPermissionSub.SecretSyncs + ProjectPermissionSub.SshCertificateTemplates ].forEach((el) => { can( [ @@ -553,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; }; @@ -719,10 +740,13 @@ const buildMemberPermissionRules = () => { can( [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete + ProjectPermissionSecretSyncActions.Create, + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSecretSyncActions.RemoveSecrets ], ProjectPermissionSub.SecretSyncs ); @@ -760,7 +784,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); + can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); return rules; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index bba2d8931..c9145304e 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1688,7 +1688,8 @@ export const SecretSyncs = { }; }, DELETE: (destination: SecretSync) => ({ - syncId: `The ID of the ${SECRET_SYNC_NAME_MAP[destination]} Sync to be deleted.` + 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.` diff --git a/backend/src/lib/fn/string.ts b/backend/src/lib/fn/string.ts index 12ca292ea..1dc2bbfed 100644 --- a/backend/src/lib/fn/string.ts +++ b/backend/src/lib/fn/string.ts @@ -16,7 +16,3 @@ export const prefixWithSlash = (str: string) => { }; export const startsWithVowel = (str: string) => /^[aeiou]/i.test(str); - -export const wrapWithSlashes = (str: string) => { - return `${str.startsWith("/") ? "" : "/"}${str}${str.endsWith("/") ? "" : `/`}`; -}; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index 960a0339f..41a87feb5 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -143,7 +143,7 @@ export const registerAppConnectionEndpoints = value === "true") + .describe(SecretSyncs.DELETE(destination).removeSecrets) + }), response: { 200: z.object({ secretSync: responseSchema }) } @@ -272,9 +279,10 @@ export const registerSyncSecretsEndpoints = { const { syncId } = req.params; + const { removeSecrets } = req.query; const secretSync = (await server.services.secretSync.deleteSecretSync( - { destination, syncId }, + { destination, syncId, removeSecrets }, req.permission )) as T; @@ -285,7 +293,8 @@ export const registerSyncSecretsEndpoints = => { + getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise => { const { destinationConfig } = secretSync; const ssm = await getSSM(secretSync); 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 index 363f6eb69..8a297c887 100644 --- 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 @@ -1,7 +1,6 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; -import { wrapWithSlashes } from "@app/lib/fn"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { @@ -14,23 +13,10 @@ const AwsParameterStoreSyncDestinationConfigSchema = z.object({ region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.REGION), path: z .string() - .min(1, "Parameter Store Path Required") - .transform(wrapWithSlashes) - .superRefine((val, ctx) => { - if (!/^\/([/]|(([\w-]+\/)+))?$/.test(val)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid Parameter Store Path - must follow "/example/path/" format` - }); - } - - if (val.length > 2048) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid Parameter Store Path - cannot exceed 2048 characters` - }); - } - }) + .trim() + .min(1, "Parameter Store Path required") + .max(2048, "Cannot exceed 2048 characters") + .regex(/^\/([/]|(([\w-]+\/)+))?$/) .describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.PATH) }); diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 8e0759d11..02f408400 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -6,20 +6,12 @@ import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; -import { TGitHubSyncWithCredentials } from "./github-sync-types"; - -interface GitHubSecret { - name: string; - created_at: string; - updated_at: string; - visibility?: "all" | "private" | "selected"; - selected_repositories_url?: string | undefined; -} +import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithCredentials } from "./github-sync-types"; // TODO: rate limit handling const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => { - let encryptedSecrets: GitHubSecret[]; + let encryptedSecrets: TGitHubSecret[]; const { destinationConfig } = secretSync; @@ -52,17 +44,8 @@ const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithC return encryptedSecrets; }; -interface GitHubPublicKey { - key_id: string; - key: string; - id?: number | undefined; - url?: string | undefined; - title?: string | undefined; - created_at?: string | undefined; -} - const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => { - let publicKey: GitHubPublicKey; + let publicKey: TGitHubPublicKey; const { destinationConfig } = secretSync; @@ -100,7 +83,11 @@ const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredenti return publicKey; }; -const deleteSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, encryptedSecret: GitHubSecret) => { +const deleteSecret = async ( + client: Octokit, + secretSync: TGitHubSyncWithCredentials, + encryptedSecret: TGitHubSecret +) => { const { destinationConfig } = secretSync; switch (destinationConfig.scope) { @@ -132,13 +119,7 @@ const deleteSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredenti } }; -interface GitHubSecretPayload { - key_id: string; - secret_name: string; - encrypted_value: string; -} - -const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: GitHubSecretPayload) => { +const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: TGitHubSecretPayload) => { const { destinationConfig } = secretSync; switch (destinationConfig.scope) { @@ -210,7 +191,7 @@ export const GithubSyncFns = { } }); }, - importSecrets: async (secretSync: TGitHubSyncWithCredentials) => { + getSecrets: async (secretSync: TGitHubSyncWithCredentials) => { throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); }, removeSecrets: async (secretSync: TGitHubSyncWithCredentials, affixedSecretMap: TSecretMap) => { diff --git a/backend/src/services/secret-sync/github/github-sync-schemas.ts b/backend/src/services/secret-sync/github/github-sync-schemas.ts index 0302312c9..37a294a1b 100644 --- a/backend/src/services/secret-sync/github/github-sync-schemas.ts +++ b/backend/src/services/secret-sync/github/github-sync-schemas.ts @@ -32,24 +32,24 @@ const GitHubSyncDestinationConfigSchema = z }) ]) .superRefine((options, ctx) => { - if (options.scope !== GitHubSyncScope.Organization) return; + 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.visibility === GitHubSyncVisibility.Selected) { - if (!options.selectedRepositoryIds?.length) + if (options.selectedRepositoryIds?.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Select at least 1 repository", + message: `Selected repositories is only supported for visibility "Selected"`, path: ["selectedRepositoryIds"] }); - return; - } - - if (options.selectedRepositoryIds?.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Selected repositories is only supported for visibility "Selected"`, - path: ["selectedRepositoryIds"] - }); + } } }); diff --git a/backend/src/services/secret-sync/github/github-sync-types.ts b/backend/src/services/secret-sync/github/github-sync-types.ts index a08548de2..c917a9fa4 100644 --- a/backend/src/services/secret-sync/github/github-sync-types.ts +++ b/backend/src/services/secret-sync/github/github-sync-types.ts @@ -13,3 +13,26 @@ export type TGitHubSyncListItem = z.infer; 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/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index 333690067..8d99f8637 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -13,16 +13,15 @@ type SecretSyncFindFilter = Parameters>[0]; const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: SecretSyncFindFilter; tx?: Knex }) => { const query = (tx || db.replicaNode())(TableName.SecretSync) - .join(TableName.SecretFolder, `${TableName.SecretSync}.folderId`, `${TableName.SecretFolder}.id`) - .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .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( - // evironment + // 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"), - db.ref("projectId").withSchema(TableName.Environment), // entire connection db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), @@ -53,7 +52,7 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre const expandSecretSync = ( secretSync: Awaited>[number], - folder: Awaited>[number] + folder?: Awaited>[number] ) => { const { envId, @@ -75,7 +74,7 @@ const expandSecretSync = ( return { ...el, connectionId, - environment: { id: envId, name: envName, slug: envSlug }, + environment: envId ? { id: envId, name: envName, slug: envSlug } : null, connection: { app: connectionApp, id: connectionId, @@ -88,10 +87,12 @@ const expandSecretSync = ( updatedAt: connectionUpdatedAt, version: connectionVersion }, - folder: { - id: folder!.id, - path: folder!.path - } + folder: folder + ? { + id: folder.id, + path: folder.path + } + : null }; }; @@ -111,7 +112,9 @@ export const secretSyncDALFactory = ( if (secretSync) { // TODO (scott): replace with cached folder path once implemented - const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]); + const [folderWithPath] = secretSync.folderId + ? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]) + : []; return expandSecretSync(secretSync, folderWithPath); } } catch (error) { @@ -132,7 +135,9 @@ export const secretSyncDALFactory = ( }))!; // TODO (scott): replace with cached folder path once implemented - const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]); + 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" }); @@ -152,7 +157,9 @@ export const secretSyncDALFactory = ( }))!; // TODO (scott): replace with cached folder path once implemented - const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]); + 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" }); @@ -165,7 +172,9 @@ export const secretSyncDALFactory = ( if (secretSync) { // TODO (scott): replace with cached folder path once implemented - const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]); + const [folderWithPath] = secretSync.folderId + ? await folderDAL.findSecretPathByFolderIds(secretSync.projectId, [secretSync.folderId]) + : []; return expandSecretSync(secretSync, folderWithPath); } } catch (error) { @@ -181,7 +190,7 @@ export const secretSyncDALFactory = ( const foldersWithPath = await folderDAL.findSecretPathByFolderIds( secretSyncs[0].projectId, - secretSyncs.map((sync) => sync.folderId) + secretSyncs.filter((sync) => Boolean(sync.folderId)).map((sync) => sync.folderId!) ); // TODO (scott): replace with cached folder path once implemented @@ -191,7 +200,9 @@ export const secretSyncDALFactory = ( if (folder) folderRecord[folder.id] = folder; }); - return secretSyncs.map((secretSync) => expandSecretSync(secretSync, folderRecord[secretSync.folderId])); + return secretSyncs.map((secretSync) => + expandSecretSync(secretSync, secretSync.folderId ? folderRecord[secretSync.folderId] : undefined) + ); } catch (error) { throw new DatabaseError({ error, name: "Find - Secret Sync" }); } 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 000000000..3b8f77d13 --- /dev/null +++ b/backend/src/services/secret-sync/secret-sync-errors.ts @@ -0,0 +1,14 @@ +export class SecretSyncError extends Error { + name: string; + + error: unknown; + + secretKey?: string; + + constructor({ name, error, secretKey }: { name?: string; error?: unknown; secretKey?: string } = {}) { + super(); + this.name = name || "SecretSyncError"; + this.error = error; + this.secretKey = secretKey; + } +} diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index ba33ae68b..eec022c7a 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,9 +1,12 @@ +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, @@ -59,8 +62,6 @@ const stripAffixes = (secretSync: TSecretSyncWithCredentials, unprocessedSecretM return secretMap; }; -// TODO(scott): ideally do this in a map to reduce code but requires typescript trickery... - export const SecretSyncFns = { syncSecrets: (secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap): Promise => { const affixedSecretMap = addAffixes(secretSync, secretMap); @@ -76,14 +77,14 @@ export const SecretSyncFns = { ); } }, - importSecrets: async (secretSync: TSecretSyncWithCredentials): Promise => { + getSecrets: async (secretSync: TSecretSyncWithCredentials): Promise => { let secretMap: TSecretMap; switch (secretSync.destination) { case SecretSync.AWSParameterStore: - secretMap = await AwsParameterStoreSyncFns.importSecrets(secretSync); + secretMap = await AwsParameterStoreSyncFns.getSecrets(secretSync); break; case SecretSync.GitHub: - secretMap = await GithubSyncFns.importSecrets(secretSync); + secretMap = await GithubSyncFns.getSecrets(secretSync); break; default: throw new Error( @@ -108,3 +109,18 @@ export const SecretSyncFns = { } } }; + +export const parseSyncErrorMessage = (err: unknown): string => { + if (err instanceof SecretSyncError) { + return JSON.stringify({ + secretKey: err.secretKey, + error: 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-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index a695cfec9..31856b671 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -31,7 +31,7 @@ import { SecretSyncImportBehavior, SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums"; -import { SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns"; +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, @@ -74,7 +74,7 @@ type TSecretSyncQueueFactoryDep = { | "deleteMany" >; secretImportDAL: Pick; - secretSyncDAL: Pick; + secretSyncDAL: Pick; auditLogService: Pick; projectMembershipDAL: Pick; projectDAL: TProjectDALFactory; @@ -94,19 +94,6 @@ 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, @@ -178,12 +165,12 @@ export const secretSyncQueueFactory = ({ }); const $getSecrets = async (secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, includeImports = true) => { - const { - projectId, - folderId, - environment: { slug: environmentSlug }, - folder: { path: secretPath } - } = secretSync; + const { projectId, folderId, environment, folder } = secretSync; + + if (!folderId || !environment || !folder) + throw new Error( + "Invalid Secret Sync source configuration: folder no longer exists. Please update source environment and secret path." + ); const secretMap: TSecretMap = {}; @@ -210,8 +197,8 @@ export const secretSyncQueueFactory = ({ const secretKey = secret.key; const secretValue = decryptSecretValue(secret.encryptedValue); const expandedSecretValue = await expandSecretReferences({ - environment: environmentSlug, - secretPath, + environment: environment.slug, + secretPath: folder.path, skipMultilineEncoding: secret.skipMultilineEncoding, value: secretValue }); @@ -260,7 +247,7 @@ export const secretSyncQueueFactory = ({ const queueSecretSyncSyncSecretsById = async (payload: TQueueSecretSyncSyncSecretsByIdDTO) => queueService.queue(QueueName.AppConnectionSecretSync, QueueJobs.SecretSyncSyncSecrets, payload, { - delay: getRequeueDelay(payload.failedToAcquireLockCount), + delay: payload.failedToAcquireLockCount ? 1000 : 0, // we don't want to delay initial job attempts: 5, backoff: { type: "exponential", @@ -309,9 +296,14 @@ export const secretSyncQueueFactory = ({ secretSync: TSecretSyncWithCredentials, importBehavior: SecretSyncImportBehavior ): Promise => { - const { projectId, environment } = secretSync; + const { projectId, environment, folder } = secretSync; - const importedSecrets = await SecretSyncFns.importSecrets(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 {}; @@ -345,7 +337,7 @@ export const secretSyncQueueFactory = ({ if (secretsToCreate.length) { await $createManySecretsRawFn({ projectId, - path: secretSync.folder.path, + path: folder.path, environment: environment.slug, secrets: secretsToCreate }); @@ -354,7 +346,7 @@ export const secretSyncQueueFactory = ({ if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination && secretsToUpdate.length) { await $updateManySecretsRawFn({ projectId, - path: secretSync.folder.path, + path: folder.path, environment: environment.slug, secrets: secretsToUpdate }); @@ -444,13 +436,7 @@ export const secretSyncQueueFactory = ({ }); } - syncMessage = - // eslint-disable-next-line no-nested-ternary - (err instanceof AxiosError - ? err?.response?.data - ? JSON.stringify(err?.response?.data) - : err?.message - : (err as Error)?.message) || "An unknown error occurred."; + syncMessage = parseSyncErrorMessage(err); // re-throw so job fails throw err; @@ -566,13 +552,7 @@ export const secretSyncQueueFactory = ({ }); } - importMessage = - // eslint-disable-next-line no-nested-ternary - (err instanceof AxiosError - ? err?.response?.data - ? JSON.stringify(err?.response?.data) - : err?.message - : (err as Error)?.message) || "An unknown error occurred."; + importMessage = parseSyncErrorMessage(err); // re-throw so job fails throw err; @@ -629,7 +609,7 @@ export const secretSyncQueueFactory = ({ const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { const { - data: { syncId, auditLogInfo } + data: { syncId, auditLogInfo, deleteSyncOnComplete } } = job; const secretSync = await secretSyncDAL.findById(syncId); @@ -691,13 +671,7 @@ export const secretSyncQueueFactory = ({ }); } - removeMessage = - // eslint-disable-next-line no-nested-ternary - (err instanceof AxiosError - ? err?.response?.data - ? JSON.stringify(err?.response?.data) - : err?.message - : (err as Error)?.message) || "An unknown error occurred."; + removeMessage = parseSyncErrorMessage(err); // re-throw so job fails throw err; @@ -731,19 +705,23 @@ export const secretSyncQueueFactory = ({ }); if (isSuccess || isFinalAttempt) { - 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 + 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 + }); + } } } } @@ -806,8 +784,8 @@ export const secretSyncQueueFactory = ({ syncDestination, content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`, failureMessage, - secretPath: folder.path, - environment: environment.name, + secretPath: folder?.path, + environment: environment?.name, projectName: project.name, syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}` } diff --git a/backend/src/services/secret-sync/secret-sync-schemas.ts b/backend/src/services/secret-sync/secret-sync-schemas.ts index 5ca615bcc..5b166df0c 100644 --- a/backend/src/services/secret-sync/secret-sync-schemas.ts +++ b/backend/src/services/secret-sync/secret-sync-schemas.ts @@ -43,8 +43,8 @@ export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig? name: z.string(), id: z.string().uuid() }), - environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }), - folder: z.object({ id: z.string(), path: z.string() }) + 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) => diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index ac5709e9f..9bf4ba494 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -2,7 +2,11 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +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"; @@ -65,15 +69,14 @@ export const secretSyncServiceFactory = ({ projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); - - const folders = await folderDAL.findByProjectId(projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); const secretSyncs = await secretSyncDAL.find({ ...(destination && { destination }), - $in: { - folderId: folders.map((folder) => folder.id) - } + projectId }); return secretSyncs as TSecretSync[]; @@ -96,7 +99,10 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -134,7 +140,10 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -163,7 +172,7 @@ export const secretSyncServiceFactory = ({ throw new BadRequestError({ message: "Project version does not support Secret Syncs" }); ForbiddenError.from(projectPermission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretSyncActions.Create, ProjectPermissionSub.SecretSyncs ); @@ -187,17 +196,13 @@ export const secretSyncServiceFactory = ({ // validates permission to connect and app is valid for sync destination await appConnectionService.connectAppConnectionById(destinationApp, params.connectionId, actor); - const projectFolders = await folderDAL.findByProjectId(folder.projectId); - const secretSync = await secretSyncDAL.transaction(async (tx) => { const isConflictingName = Boolean( ( await secretSyncDAL.find( { name: params.name, - $in: { - folderId: projectFolders.map((f) => f.id) - } + projectId }, tx ) @@ -212,7 +217,8 @@ export const secretSyncServiceFactory = ({ const sync = await secretSyncDAL.create({ folderId: folder.id, ...params, - ...(params.isEnabled && { syncStatus: SecretSyncStatus.Pending }) + ...(params.isEnabled && { syncStatus: SecretSyncStatus.Pending }), + projectId }); return sync; @@ -243,7 +249,10 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretSyncs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSub.SecretSyncs + ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -251,12 +260,17 @@ export const secretSyncServiceFactory = ({ }); const updatedSecretSync = await secretSyncDAL.transaction(async (tx) => { + let { folderId } = secretSync; + if ( - (secretPath && secretPath !== secretSync.folder.path) || - (environment && environment !== secretSync.environment.slug) + (secretPath && secretPath !== secretSync.folder?.path) || + (environment && environment !== secretSync.environment?.slug) ) { - const updatedEnvironment = environment ?? secretSync.environment.slug; - const updatedSecretPath = secretPath ?? secretSync.folder.path; + 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, @@ -272,19 +286,17 @@ export const secretSyncServiceFactory = ({ 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 projectFolders = await folderDAL.findByProjectId(secretSync.projectId); - const isConflictingName = Boolean( ( await secretSyncDAL.find( { name: params.name, - $in: { - folderId: projectFolders.map((f) => f.id) - } + projectId: secretSync.projectId }, tx ) @@ -301,7 +313,8 @@ export const secretSyncServiceFactory = ({ const updatedSync = await secretSyncDAL.updateById(syncId, { ...params, - ...(isEnabled && { syncStatus: SecretSyncStatus.Pending }) + ...(isEnabled && folderId && { syncStatus: SecretSyncStatus.Pending }), + folderId }); return updatedSync; @@ -312,7 +325,10 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; - const deleteSecretSync = async ({ destination, syncId }: TDeleteSecretSyncDTO, actor: OrgServiceActor) => { + const deleteSecretSync = async ( + { destination, syncId, removeSecrets }: TDeleteSecretSyncDTO, + actor: OrgServiceActor + ) => { const secretSync = await secretSyncDAL.findById(syncId); if (!secretSync) @@ -329,13 +345,41 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretSyncs); + 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; @@ -361,13 +405,21 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); + 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) @@ -408,13 +460,21 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); + 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) @@ -449,13 +509,21 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSyncs); + 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) diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 4fd9b35ef..035d1651e 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -62,6 +62,7 @@ export type TUpdateSecretSyncDTO = Partial; @@ -110,6 +111,7 @@ export type TTriggerSecretSyncImportSecretsByIdDTO = { export type TQueueSecretSyncRemoveSecretsByIdDTO = { syncId: string; auditLogInfo?: AuditLogInfo; + deleteSyncOnComplete?: boolean; }; export type TTriggerSecretSyncRemoveSecretsByIdDTO = { diff --git a/backend/src/services/smtp/templates/secretSyncFailed.handlebars b/backend/src/services/smtp/templates/secretSyncFailed.handlebars index 3b8344e04..3e7ad7831 100644 --- a/backend/src/services/smtp/templates/secretSyncFailed.handlebars +++ b/backend/src/services/smtp/templates/secretSyncFailed.handlebars @@ -21,8 +21,12 @@

Name: {{syncName}}

Destination: {{syncDestination}}

Project: {{projectName}}

-

Environment: {{environment}}

-

Secret Path: {{secretPath}}

+ {{#if environment}} +

Environment: {{environment}}

+ {{/if}} + {{#if secretPath}} +

Secret Path: {{secretPath}}

+ {{/if}} {{#if failureMessage}} 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 d18994f7c..d6db40ade 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/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx index 95ddbd6e9..cf959827b 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/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/aws-parameter-store/get-by-name.mdx index 6ec1b538a..67930be3c 100644 --- 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 @@ -1,4 +1,4 @@ --- title: "Get by Name" -openapi: "GET /api/v1/secret-syncs/aws-parameter-store/name/{syncName}" +openapi: "GET /api/v1/secret-syncs/aws-parameter-store/sync-name/{syncName}" --- 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 index d2d1f1154..b4c17b4d8 100644 --- a/docs/api-reference/endpoints/secret-syncs/github/get-by-name.mdx +++ b/docs/api-reference/endpoints/secret-syncs/github/get-by-name.mdx @@ -1,4 +1,4 @@ --- title: "Get by Name" -openapi: "GET /api/v1/secret-syncs/github/name/{syncName}" +openapi: "GET /api/v1/secret-syncs/github/sync-name/{syncName}" --- diff --git a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx index b88e7b480..3e232765c 100644 --- a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx +++ b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx @@ -51,6 +51,7 @@ export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => { "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" diff --git a/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx b/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx index 20843a920..1daed67a3 100644 --- a/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx +++ b/frontend/src/components/secret-syncs/DeleteSecretSyncModal.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; + import { createNotification } from "@app/components/notifications"; -import { DeleteActionModal } from "@app/components/v2"; +import { DeleteActionModal, Switch } from "@app/components/v2"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync, useDeleteSecretSync } from "@app/hooks/api/secretSyncs"; @@ -12,6 +14,7 @@ type Props = { export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComplete }: Props) => { const deleteSync = useDeleteSecretSync(); + const [removeSecrets, setRemoveSecrets] = useState(false); if (!secretSync) return null; @@ -23,7 +26,8 @@ export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComp try { await deleteSync.mutateAsync({ syncId, - destination + destination, + removeSecrets }); createNotification({ @@ -37,7 +41,7 @@ export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComp console.error(err); createNotification({ - text: `Failed remove ${destinationName} Sync`, + text: `Failed to remove ${destinationName} Sync`, type: "error" }); } @@ -50,6 +54,17 @@ export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComp title={`Are you sure want to delete ${name}?`} deleteKey={name} onDeleteApproved={handleDeleteSecretSync} - /> + > + + Remove Synced Secrets + + ); }; diff --git a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx index 7a5422e44..dbf543f61 100644 --- a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx @@ -21,7 +21,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => { switch (status) { case SecretSyncStatus.Failed: variant = "danger"; - text = "Failed"; + text = "Failed to Sync"; icon = faExclamationTriangle; break; case SecretSyncStatus.Succeeded: @@ -39,7 +39,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => { } return ( - + {text} diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 83d88ad63..4f36fae1b 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -8,7 +8,7 @@ import { Button, ModalClose } from "@app/components/v2"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs"; -import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas"; +import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas"; import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields"; @@ -25,10 +25,11 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; const formMethods = useForm({ - resolver: zodResolver(SecretSyncFormSchema), + resolver: zodResolver(UpdateSecretSyncFormSchema), defaultValues: { ...secretSync, - secretPath: secretSync.folder.path, + environment: secretSync.environment ?? undefined, + secretPath: secretSync.folder?.path, description: secretSync.description ?? "" }, reValidateMode: "onChange" @@ -39,7 +40,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => const updatedSecretSync = await updateSecretSync.mutateAsync({ syncId: secretSync.id, ...formData, - environment: environment.slug + environment: environment?.slug }); createNotification({ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx index 64763a672..408e4af67 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncConnectionField.tsx @@ -4,7 +4,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link } from "@tanstack/react-router"; import { FilterableSelect, FormControl } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context"; +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"; @@ -27,7 +28,7 @@ export const SecretSyncConnectionField = ({ onChange: callback }: Props) => { const connectionName = APP_CONNECTION_MAP[app].name; const canCreateConnection = permission.can( - OrgPermissionActions.Create, + OrgPermissionAppConnectionActions.Create, OrgPermissionSubjects.AppConnections ); 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 index 7cafbf1b4..b6d2974c8 100644 --- 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 @@ -7,22 +7,10 @@ export const AwsParameterStoreSyncDestinationSchema = z.object({ destinationConfig: z.object({ path: z .string() + .trim() .min(1, "Parameter Store Path required") - .superRefine((val, ctx) => { - if (!/^\/([/]|(([\w-]+\/)+))?$/.test(val)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid path - must follow "/example/path/" format' - }); - } - - if (val.length > 2048) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Cannot exceed 2048 characters" - }); - } - }), + .max(2048, "Cannot exceed 2048 characters") + .regex(/^\/([/]|(([\w-]+\/)+))?$/), 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 index a47fb5be1..ccebc946c 100644 --- 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 @@ -29,17 +29,17 @@ export const GitHubSyncDestinationSchema = z.object({ }) ]) .superRefine((options, ctx) => { - if (options.scope !== GitHubSyncScope.Organization) return; - - if ( - options.visibility === GitHubSyncVisibility.Selected && - !options.selectedRepositoryIds?.length - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Select at least 1 repository", - path: ["selectedRepositoryIds"] - }); + 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/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 3016d97e1..c06bc710e 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -28,11 +28,13 @@ const BaseSecretSyncSchema = z.object({ isEnabled: z.boolean() }); -export const SecretSyncFormSchema = z - .discriminatedUnion("destination", [ - AwsParameterStoreSyncDestinationSchema, - GitHubSyncDestinationSchema - ]) - .and(BaseSecretSyncSchema); +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; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 3cf16507a..ea5003c8c 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -61,7 +61,7 @@ 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, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 1ad50582a..279e69889 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", @@ -174,7 +184,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] - | [ProjectPermissionActions, ProjectPermissionSub.SecretSyncs] + | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 56c253da8..f46f0f230 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -39,10 +39,10 @@ export const SECRET_SYNC_IMPORT_BEHAVIOR_MAP: Record< > = { [SecretSyncImportBehavior.PrioritizeSource]: (destinationName: string) => ({ name: "Prioritize Infisical Values", - description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in Infisical over ${destinationName}.` + 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 prior to syncing, prioritizing values present in ${destinationName} over Infisical.` + 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/secretSyncs/mutations.tsx b/frontend/src/hooks/api/secretSyncs/mutations.tsx index 4aae33810..1efab6688 100644 --- a/frontend/src/hooks/api/secretSyncs/mutations.tsx +++ b/frontend/src/hooks/api/secretSyncs/mutations.tsx @@ -48,8 +48,10 @@ export const useUpdateSecretSync = () => { export const useDeleteSecretSync = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ syncId, destination }: TDeleteSecretSyncDTO) => { - const { data } = await apiRequest.delete(`/api/v1/secret-syncs/${destination}/${syncId}`); + mutationFn: async ({ syncId, destination, removeSecrets }: TDeleteSecretSyncDTO) => { + const { data } = await apiRequest.delete(`/api/v1/secret-syncs/${destination}/${syncId}`, { + params: { removeSecrets } + }); return data; }, diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index fb1a9fdae..033c00840 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -37,6 +37,7 @@ export type TUpdateSecretSyncDTO = Partial< export type TDeleteSecretSyncDTO = { destination: SecretSync; syncId: string; + removeSecrets: boolean; }; export type TTriggerSecretSyncSyncSecretsDTO = { diff --git a/frontend/src/hooks/api/secretSyncs/types/root-sync.ts b/frontend/src/hooks/api/secretSyncs/types/root-sync.ts index 313abc9a7..716173dd7 100644 --- a/frontend/src/hooks/api/secretSyncs/types/root-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/root-sync.ts @@ -6,7 +6,7 @@ export type TRootSecretSync = { name: string; description?: string | null; version: number; - folderId: string; + folderId: string | null; connectionId: string; createdAt: string; updatedAt: string; @@ -38,9 +38,9 @@ export type TRootSecretSync = { id: string; name: string; slug: string; - }; + } | null; folder: { id: string; path: string; - }; + } | null; }; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index 56f922b52..5b73bcf77 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 000000000..7a5cb25c7 --- /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; + control: Control; +}; + +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; + 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 ( + <> + setIsRowExpanded.toggle()} + > + + + + App Connections + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.app-connections.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index 5d359423a..44f80c18d 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) => { /> ); })} + {(isAllowed) => ( @@ -70,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/AppConnectionRow.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx index ea53778ba..a733bae8e 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 {(isAllowed: boolean) => ( @@ -133,7 +134,7 @@ export const AppConnectionRow = ({ )} {(isAllowed: boolean) => ( @@ -147,7 +148,7 @@ export const AppConnectionRow = ({ )} {(isAllowed: boolean) => ( 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 fc42f6a4c..a2f751405 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 aaf028254..3a9e311fd 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() @@ -138,7 +149,7 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]), - [ProjectPermissionSub.SecretSyncs]: GeneralPolicyActionSchema.array().default([]) + [ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]) }) .partial() .optional() @@ -219,8 +230,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Tags, ProjectPermissionSub.SecretRotation, - ProjectPermissionSub.Kms, - ProjectPermissionSub.SecretSyncs + ProjectPermissionSub.Kms ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -333,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; @@ -676,10 +711,19 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { [ProjectPermissionSub.SecretSyncs]: { title: "Secret Syncs", actions: [ - { label: "Read", value: "read" }, - { label: "Create", value: "create" }, - { label: "Modify", value: "edit" }, - { label: "Remove", value: "delete" } + { 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/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/index.ts index e69de29bb..9d0768798 100644 --- 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 @@ -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 index 7c131c1be..faa9321db 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx @@ -12,6 +12,7 @@ import { faToggleOff, faToggleOn, faTrash, + faTriangleExclamation, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -38,12 +39,13 @@ import { Tr } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +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 "@app/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol"; +import { SecretSyncDestinationCol } from "./SecretSyncDestinationCol"; import { SecretSyncTableCell } from "./SecretSyncTableCell"; type Props = { @@ -66,7 +68,7 @@ export const SecretSyncRow = ({ const navigate = useNavigate(); const { id, - folder: { path: secretPath }, + folder, lastSyncMessage, destination, lastSyncedAt, @@ -130,7 +132,7 @@ export const SecretSyncRow = ({ 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", - !isEnabled && "bg-mineshaft-400/15 opacity-50" + !isEnabled && "bg-mineshaft-400/15 opacity-50 hover:opacity-100" )} key={`sync-${id}`} > @@ -158,7 +160,23 @@ export const SecretSyncRow = ({

{destinationDetails.name}

- + {folder && environment ? ( + + ) : ( + + +
+ + + Source Folder Deleted + +
+
+ + )}
@@ -242,64 +260,100 @@ export const SecretSyncRow = ({ > Copy Sync ID - } - onClick={(e) => { - e.stopPropagation(); - onTriggerSyncSecrets(secretSync); - }} - > - -
- Trigger Sync - -
-
-
- {syncOption?.canImportSecrets && ( - } - onClick={(e) => { - e.stopPropagation(); - onTriggerImportSecrets(secretSync); - }} - > - -
- Import Secrets - -
-
-
- )} - } - onClick={(e) => { - e.stopPropagation(); - onTriggerRemoveSecrets(secretSync); - }} - > - -
- Remove Secrets - -
-
-
+ {(isAllowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onTriggerSyncSecrets(secretSync); + }} + isDisabled={!isAllowed} + > + +
+ Trigger Sync + +
+
+
+ )} +
+ {syncOption?.canImportSecrets && ( + + {(isAllowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onTriggerImportSecrets(secretSync); + }} + isDisabled={!isAllowed} + > + +
+ Import Secrets + +
+
+
+ )} +
+ )} + + {(isAllowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onTriggerRemoveSecrets(secretSync); + }} + isDisabled={!isAllowed} + > + +
+ Remove Secrets + +
+
+
+ )} +
+ {(isAllowed: boolean) => ( @@ -316,7 +370,7 @@ export const SecretSyncRow = ({ )} {(isAllowed: boolean) => ( 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 index 766ebe36d..e3264f66a 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx @@ -43,6 +43,7 @@ import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { SecretSync, + SecretSyncStatus, TSecretSync, useTriggerSecretSyncSyncSecrets, useUpdateSecretSync @@ -71,6 +72,20 @@ enum SecretSyncStatusCol { Disabled = "disabled" } +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[]; }; @@ -124,7 +139,11 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => { if (filters.destinations.length && !filters.destinations.includes(destination)) return false; - if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id)) + if ( + filters.environmentIds.length && + environment?.id && + !filters.environmentIds.includes(environment.id) + ) return false; const status = isEnabled ? syncStatus : SecretSyncStatusCol.Disabled; @@ -143,8 +162,8 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => { return ( SECRET_SYNC_MAP[destination].name.toLowerCase().includes(searchValue) || name.toLowerCase().includes(searchValue) || - folder.path.toLowerCase().includes(searchValue) || - environment.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) @@ -155,9 +174,9 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => { switch (orderBy) { case SecretSyncsOrderBy.Source: - return syncOne.folder.path + return (syncOne.folder?.path ?? "") .toLowerCase() - .localeCompare(syncTwo.folder.path.toLowerCase()); + .localeCompare(syncTwo.folder?.path.toLowerCase() ?? ""); case SecretSyncsOrderBy.Destination: return getSecretSyncDestinationColValues(syncOne) .primaryText.toLowerCase() @@ -165,9 +184,17 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => { getSecretSyncDestinationColValues(syncTwo).primaryText.toLowerCase() ); case SecretSyncsOrderBy.Status: - return syncOne.connection.name - .toLowerCase() - .localeCompare(syncTwo.connection.name.toLowerCase()); + if (!syncOne.isEnabled && syncTwo.isEnabled) return 1; + if (syncOne.isEnabled && !syncTwo.isEnabled) return -1; + + 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()); diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx index a8d86ba7e..08eba28c4 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx @@ -4,7 +4,8 @@ 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 { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +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"; @@ -56,7 +57,7 @@ export const SecretSyncsTab = () => {

{(isAllowed) => ( diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx index 250b5b9ba..9ff26af3a 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/route.tsx @@ -7,7 +7,9 @@ import { IntegrationsListPageTabs } from "@app/types/integrations"; import { IntegrationsListPage } from "./IntegrationsListPage"; const IntegrationsListPageQuerySchema = z.object({ - selectedTab: z.string().catch(IntegrationsListPageTabs.NativeIntegrations) + selectedTab: z + .nativeEnum(IntegrationsListPageTabs) + .catch(IntegrationsListPageTabs.NativeIntegrations) }); export const Route = createFileRoute( diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx index ec0e35de9..03b5d3e4d 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx @@ -8,7 +8,8 @@ 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +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"; @@ -140,7 +141,7 @@ export const SecretSyncDetailsByIDPage = () => { diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx index 7a7d175cd..108b72a22 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncActionTriggers.tsx @@ -33,7 +33,8 @@ import { Tooltip } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +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 { @@ -129,14 +130,22 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
- + {(isAllowed: boolean) => ( + + )} + { Copy Sync ID {syncOption?.canImportSecrets && ( - } - onClick={() => handlePopUpOpen("importSecrets")} + - -
- Import Secrets - -
-
-
+ {(isAllowed: boolean) => ( + } + onClick={() => handlePopUpOpen("importSecrets")} + isDisabled={!isAllowed} + > + +
+ Import Secrets + +
+
+
+ )} + )} - } - onClick={() => handlePopUpOpen("removeSecrets")} - > - -
- Remove Secrets - -
-
-
+ {(isAllowed: boolean) => ( + } + onClick={() => handlePopUpOpen("removeSecrets")} + isDisabled={!isAllowed} + > + +
+ Remove Secrets + +
+
+
+ )} +
+ {(isAllowed: boolean) => ( @@ -206,7 +239,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => { )} {(isAllowed: boolean) => ( diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index d44777110..fdc87c97c 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -5,7 +5,8 @@ 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +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"; @@ -38,7 +39,7 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:

Destination Configuration

{(isAllowed) => ( diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx index 90d581776..b8432c709 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDetailsSection.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { faBan, faEdit } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; @@ -5,7 +6,8 @@ import { format } from "date-fns"; import { ProjectPermissionCan } from "@app/components/permissions"; import { SecretSyncLabel, SecretSyncStatusBadge } from "@app/components/secret-syncs"; import { Badge, IconButton } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs"; type Props = { @@ -16,12 +18,26 @@ type Props = { export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) => { const { syncStatus, lastSyncMessage, lastSyncedAt, name, description, isEnabled } = 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 (

Details

{(isAllowed) => ( @@ -56,9 +72,9 @@ export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) = {format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")} )} - {syncStatus === SecretSyncStatus.Failed && lastSyncMessage && ( + {syncStatus === SecretSyncStatus.Failed && failureMessage && ( -

{lastSyncMessage}

+

{failureMessage}

)}
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx index bf9ae7376..b44a4b4da 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection.tsx @@ -4,7 +4,8 @@ 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync } from "@app/hooks/api/secretSyncs"; @@ -26,7 +27,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =

Sync Options

{(isAllowed) => ( diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx index 18485a7b0..92eff2e32 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncSourceSection.tsx @@ -1,10 +1,11 @@ -import { faEdit } from "@fortawesome/free-solid-svg-icons"; +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 { IconButton } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +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 = { @@ -20,27 +21,42 @@ export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) =>

Source

- - {(isAllowed) => ( - - - +
+ {(!folder || !environment) && ( + +
+ + + Folder Deleted + +
+
)} - + + {(isAllowed) => ( + + + + )} + +
- {environment.name} - {folder.path} + {environment?.name} + {folder?.path}