diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index dde9b4e35..6b032c6f0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2348,6 +2348,9 @@ export const AppConnections = { RAILWAY: { apiToken: "The API token used to authenticate with Railway." }, + NORTHFLANK: { + apiToken: "The API token used to authenticate with Northflank." + }, CHECKLY: { apiKey: "The API key used to authenticate with Checkly." }, @@ -2620,6 +2623,12 @@ export const SecretSyncs = { siteName: "The name of the Netlify site to sync secrets to.", siteId: "The ID of the Netlify site to sync secrets to.", context: "The Netlify context to sync secrets to." + }, + NORTHFLANK: { + projectId: "The ID of the Northflank project to sync secrets to.", + projectName: "The name of the Northflank project to sync secrets to.", + secretGroupId: "The ID of the Northflank secret group to sync secrets to.", + secretGroupName: "The name of the Northflank secret group to sync secrets to." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index c799ef0f0..a3250c6a9 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -88,6 +88,10 @@ import { NetlifyConnectionListItemSchema, SanitizedNetlifyConnectionSchema } from "@app/services/app-connection/netlify"; +import { + NorthflankConnectionListItemSchema, + SanitizedNorthflankConnectionSchema +} from "@app/services/app-connection/northflank"; import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta"; import { PostgresConnectionListItemSchema, @@ -160,6 +164,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, + ...SanitizedNorthflankConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, @@ -203,6 +208,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, + NorthflankConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 2e3da4420..d8bcdce23 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -29,6 +29,7 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; +import { registerNorthflankConnectionRouter } from "./northflank-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; @@ -83,6 +84,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Northflank, + server, + sanitizedResponseSchema: SanitizedNorthflankConnectionSchema, + createSchema: CreateNorthflankConnectionSchema, + updateSchema: UpdateNorthflankConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const projects = await server.services.appConnection.northflank.listProjects(connectionId, req.permission); + return { projects }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/projects/:projectId/secret-groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + projectId: z.string() + }), + response: { + 200: z.object({ + secretGroups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, projectId } = req.params; + const secretGroups = await server.services.appConnection.northflank.listSecretGroups( + connectionId, + projectId, + req.permission + ); + return { secretGroups }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index e778dbd7c..2acf2dfc9 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -23,6 +23,7 @@ import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; +import { registerNorthflankSyncRouter } from "./northflank-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; @@ -64,6 +65,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Northflank, + server, + responseSchema: NorthflankSyncSchema, + createSchema: CreateNorthflankSyncSchema, + updateSchema: UpdateNorthflankSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 1bfa32eeb..e6fd39e34 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -46,6 +46,7 @@ import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; import { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; +import { NorthflankSyncListItemSchema, NorthflankSyncSchema } from "@app/services/secret-sync/northflank"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; @@ -85,6 +86,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, + NorthflankSyncSchema, BitbucketSyncSchema, LaravelForgeSyncSchema ]); @@ -119,6 +121,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, + NorthflankSyncListItemSchema, BitbucketSyncListItemSchema, LaravelForgeSyncListItemSchema ]); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 54b70c7d3..1e731ed77 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -38,7 +38,8 @@ export enum AppConnection { Netlify = "netlify", Okta = "okta", Redis = "redis", - LaravelForge = "laravel-forge" + LaravelForge = "laravel-forge", + Northflank = "northflank" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 21989c6ae..efc3deb99 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -113,6 +113,11 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify"; +import { + getNorthflankConnectionListItem, + NorthflankConnectionMethod, + validateNorthflankConnectionCredentials +} from "./northflank"; import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; @@ -203,6 +208,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), + getNorthflankConnectionListItem(), getOktaConnectionListItem(), getRedisConnectionListItem() ] @@ -332,8 +338,9 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -376,6 +383,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case BitbucketConnectionMethod.ApiToken: case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: + case NorthflankConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return "API Token"; @@ -472,6 +480,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, + [AppConnection.Northflank]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported, [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index c01d9d1b4..c684765bd 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -40,7 +40,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.DigitalOcean]: "DigitalOcean App Platform", [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta", - [AppConnection.Redis]: "Redis" + [AppConnection.Redis]: "Redis", + [AppConnection.Northflank]: "Northflank" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -83,5 +84,6 @@ export const APP_CONNECTION_PLAN_MAP: Record { + return { + name: "Northflank" as const, + app: AppConnection.Northflank as const, + methods: Object.values(NorthflankConnectionMethod) + }; +}; + +export const validateNorthflankConnectionCredentials = async (config: TNorthflankConnectionConfig) => { + const { credentials } = config; + + try { + await request.get(`${NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate Northflank credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: `Failed to validate Northflank credentials - verify API token is correct` + }); + } + + return credentials; +}; + +export const listProjects = async (appConnection: TNorthflankConnection): Promise => { + const { credentials } = appConnection; + + try { + const { + data: { + data: { projects } + } + } = await request.get<{ data: { projects: TNorthflankProject[] } }>(`${NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + }); + + return projects; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Northflank projects: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Northflank projects", + error + }); + } +}; + +export const listSecretGroups = async ( + appConnection: TNorthflankConnection, + projectId: string +): Promise => { + const { credentials } = appConnection; + + try { + const { + data: { + data: { secrets } + } + } = await request.get<{ data: { secrets: TNorthflankSecretGroup[] } }>( + `${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets`, + { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + } + ); + + return secrets; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Northflank secret groups: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Northflank secret groups", + error + }); + } +}; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts new file mode 100644 index 000000000..95be757f6 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { NorthflankConnectionMethod } from "./northflank-connection-enums"; + +export const NorthflankConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.NORTHFLANK.apiToken) +}); + +const BaseNorthflankConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Northflank) +}); + +export const NorthflankConnectionSchema = BaseNorthflankConnectionSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: NorthflankConnectionApiTokenCredentialsSchema +}); + +export const SanitizedNorthflankConnectionSchema = z.discriminatedUnion("method", [ + BaseNorthflankConnectionSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: NorthflankConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateNorthflankConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(NorthflankConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.Northflank).method), + credentials: NorthflankConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Northflank).credentials + ) + }) +]); + +export const CreateNorthflankConnectionSchema = ValidateNorthflankConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Northflank) +); + +export const UpdateNorthflankConnectionSchema = z + .object({ + credentials: NorthflankConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Northflank).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Northflank)); + +export const NorthflankConnectionListItemSchema = z.object({ + name: z.literal("Northflank"), + app: z.literal(AppConnection.Northflank), + methods: z.nativeEnum(NorthflankConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/northflank/northflank-connection-service.ts b/backend/src/services/app-connection/northflank/northflank-connection-service.ts new file mode 100644 index 000000000..faf248bbc --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-service.ts @@ -0,0 +1,50 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listProjects as getNorthflankProjects, + listSecretGroups as getNorthflankSecretGroups +} from "./northflank-connection-fns"; +import { TNorthflankConnection, TNorthflankSecretGroup } from "./northflank-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const northflankConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Northflank, connectionId, actor); + try { + const projects = await getNorthflankProjects(appConnection); + + return projects; + } catch (error) { + logger.error({ error, connectionId, actor: actor.type }, "Failed to establish connection with Northflank"); + return []; + } + }; + + const listSecretGroups = async ( + connectionId: string, + projectId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.Northflank, connectionId, actor); + try { + const secretGroups = await getNorthflankSecretGroups(appConnection, projectId); + + return secretGroups; + } catch (error) { + logger.error({ error, connectionId, projectId, actor: actor.type }, "Failed to list Northflank secret groups"); + return []; + } + }; + + return { + listProjects, + listSecretGroups + }; +}; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-types.ts b/backend/src/services/app-connection/northflank/northflank-connection-types.ts new file mode 100644 index 000000000..c007e27d3 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateNorthflankConnectionSchema, + NorthflankConnectionSchema, + ValidateNorthflankConnectionCredentialsSchema +} from "./northflank-connection-schemas"; + +export type TNorthflankConnection = z.infer; + +export type TNorthflankConnectionInput = z.infer & { + app: AppConnection.Northflank; +}; + +export type TValidateNorthflankConnectionCredentialsSchema = typeof ValidateNorthflankConnectionCredentialsSchema; + +export type TNorthflankConnectionConfig = DiscriminativePick< + TNorthflankConnection, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TNorthflankProject = { + id: string; + name: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; +}; diff --git a/backend/src/services/secret-sync/northflank/index.ts b/backend/src/services/secret-sync/northflank/index.ts new file mode 100644 index 000000000..7fab276cf --- /dev/null +++ b/backend/src/services/secret-sync/northflank/index.ts @@ -0,0 +1,4 @@ +export * from "./northflank-sync-constants"; +export * from "./northflank-sync-fns"; +export * from "./northflank-sync-schemas"; +export * from "./northflank-sync-types"; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts b/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts new file mode 100644 index 000000000..d4b851217 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const NORTHFLANK_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Northflank", + destination: SecretSync.Northflank, + connection: AppConnection.Northflank, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts b/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts new file mode 100644 index 000000000..396aa9ac8 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts @@ -0,0 +1,165 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SecretSyncError } from "../secret-sync-errors"; +import { TNorthflankSyncWithCredentials } from "./northflank-sync-types"; + +const NORTHFLANK_API_URL = "https://api.northflank.com"; + +const buildNorthflankAPIErrorMessage = (error: unknown): string => { + let errorMessage = "Northflank API returned an error."; + + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as AxiosError; + + if (axiosError.response?.data) { + // This is the shape of the error response from the Northflank API + const responseData = axiosError.response.data as { + error?: { message?: string; details?: Record }; + message?: string; + }; + const errorParts = []; + + if (responseData.error?.message) { + errorParts.push(responseData.error.message); + } else if (responseData.message) { + errorParts.push(responseData.message); + } + + if (responseData.error?.details) { + const { details } = responseData.error; + + // Flatten the details object into a string + Object.entries(details).forEach(([field, fieldErrors]) => { + if (Array.isArray(fieldErrors)) { + fieldErrors.forEach((fieldError) => errorParts.push(`${field}: ${fieldError}`)); + } else { + errorParts.push(`${field}: ${String(fieldErrors)}`); + } + }); + } + + errorMessage += ` ${errorParts.join(". ")}`; + } + } + + return errorMessage; +}; + +const getNorthflankSecrets = async (secretSync: TNorthflankSyncWithCredentials): Promise> => { + const { + destinationConfig: { projectId, secretGroupId }, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + const { + data: { + data: { + secrets: { variables } + } + } + } = await request.get<{ + data: { + secrets: { + variables: Record; + }; + }; + }>(`${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets/${secretGroupId}/details`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + return variables; + } catch (error: unknown) { + throw new SecretSyncError({ + error, + message: `Failed to fetch Northflank secrets. ${buildNorthflankAPIErrorMessage(error)}` + }); + } +}; + +const updateNorthflankSecrets = async ( + secretSync: TNorthflankSyncWithCredentials, + variables: Record +): Promise => { + const { + destinationConfig: { projectId, secretGroupId }, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.patch( + `${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets/${secretGroupId}`, + { + secrets: { + variables + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + } catch (error: unknown) { + throw new SecretSyncError({ + error, + message: `Failed to update Northflank secrets. ${buildNorthflankAPIErrorMessage(error)}` + }); + } +}; + +export const NorthflankSyncFns = { + syncSecrets: async (secretSync: TNorthflankSyncWithCredentials, secretMap: TSecretMap): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + + const updatedVariables: Record = {}; + + for (const [key, value] of Object.entries(northflankSecrets)) { + const shouldKeep = + !secretMap[key] && // this prevents duplicates from infisical secrets, because we add all of them to the updateVariables in the next loop + (secretSync.syncOptions.disableSecretDeletion || + !matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)); + + if (shouldKeep) { + updatedVariables[key] = value; + } + } + + for (const [key, { value }] of Object.entries(secretMap)) { + updatedVariables[key] = value; + } + + await updateNorthflankSecrets(secretSync, updatedVariables); + }, + + getSecrets: async (secretSync: TNorthflankSyncWithCredentials): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + return Object.fromEntries(Object.entries(northflankSecrets).map(([key, value]) => [key, { value }])); + }, + + removeSecrets: async (secretSync: TNorthflankSyncWithCredentials, secretMap: TSecretMap): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + + const updatedVariables: Record = {}; + + for (const [key, value] of Object.entries(northflankSecrets)) { + if (!(key in secretMap)) { + updatedVariables[key] = value; + } + } + + await updateNorthflankSecrets(secretSync, updatedVariables); + } +}; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts new file mode 100644 index 000000000..55cdeae23 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const NorthflankSyncDestinationConfigSchema = z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.projectId), + projectName: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.projectName), + secretGroupId: z + .string() + .trim() + .min(1, "Secret Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.secretGroupId), + secretGroupName: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.secretGroupName) +}); + +const NorthflankSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const NorthflankSyncSchema = BaseSecretSyncSchema(SecretSync.Northflank, NorthflankSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: NorthflankSyncDestinationConfigSchema +}); + +export const CreateNorthflankSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Northflank, + NorthflankSyncOptionsConfig +).extend({ + destinationConfig: NorthflankSyncDestinationConfigSchema +}); + +export const UpdateNorthflankSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Northflank, + NorthflankSyncOptionsConfig +).extend({ + destinationConfig: NorthflankSyncDestinationConfigSchema.optional() +}); + +export const NorthflankSyncListItemSchema = z.object({ + name: z.literal("Northflank"), + connection: z.literal(AppConnection.Northflank), + destination: z.literal(SecretSync.Northflank), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-types.ts b/backend/src/services/secret-sync/northflank/northflank-sync-types.ts new file mode 100644 index 000000000..019ae2843 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TNorthflankConnection } from "@app/services/app-connection/northflank"; + +import { + CreateNorthflankSyncSchema, + NorthflankSyncListItemSchema, + NorthflankSyncSchema +} from "./northflank-sync-schemas"; + +export type TNorthflankSyncListItem = z.infer; + +export type TNorthflankSync = z.infer; + +export type TNorthflankSyncInput = z.infer; + +export type TNorthflankSyncWithCredentials = TNorthflankSync & { + connection: TNorthflankConnection; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 235b3db3a..f04247684 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,6 +28,7 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", + Northflank = "northflank", Bitbucket = "bitbucket", LaravelForge = "laravel-forge" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 85fc27250..3068e803b 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -52,6 +52,7 @@ import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; +import { NORTHFLANK_SYNC_LIST_OPTION, NorthflankSyncFns } from "./northflank"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -93,6 +94,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, + [SecretSync.Northflank]: NORTHFLANK_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION }; @@ -278,6 +280,8 @@ export const SecretSyncFns = { return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Netlify: return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Northflank: + return NorthflankSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: @@ -395,6 +399,9 @@ export const SecretSyncFns = { case SecretSync.Netlify: secretMap = await NetlifySyncFns.getSecrets(secretSync); break; + case SecretSync.Northflank: + secretMap = await NorthflankSyncFns.getSecrets(secretSync); + break; case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; @@ -492,6 +499,8 @@ export const SecretSyncFns = { return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Netlify: return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Northflank: + return NorthflankSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 0ec8aede0..8110cced9 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -32,6 +32,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", + [SecretSync.Northflank]: "Northflank", [SecretSync.Bitbucket]: "Bitbucket", [SecretSync.LaravelForge]: "Laravel Forge" }; @@ -66,6 +67,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, + [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.LaravelForge]: AppConnection.LaravelForge }; @@ -100,6 +102,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, + [SecretSync.Northflank]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, [SecretSync.LaravelForge]: SecretSyncPlanType.Regular }; @@ -143,6 +146,7 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Checkly]: ["groupName", "accountName"], [SecretSync.DigitalOceanAppPlatform]: ["appName"], [SecretSync.Netlify]: ["accountName", "siteName"], + [SecretSync.Northflank]: [], [SecretSync.Bitbucket]: [], [SecretSync.LaravelForge]: [] }; @@ -203,6 +207,7 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record + Check out the configuration docs for [Northflank Connections](/integrations/app-connections/northflank) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/northflank/delete.mdx b/docs/api-reference/endpoints/app-connections/northflank/delete.mdx new file mode 100644 index 000000000..1c3518ea5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx new file mode 100644 index 000000000..e6e24e39e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx new file mode 100644 index 000000000..e3ca69b31 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/northflank/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/list.mdx b/docs/api-reference/endpoints/app-connections/northflank/list.mdx new file mode 100644 index 000000000..fbaf08ea6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/northflank" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/update.mdx b/docs/api-reference/endpoints/app-connections/northflank/update.mdx new file mode 100644 index 000000000..4554bd714 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx new file mode 100644 index 000000000..47ae2f4b4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/northflank" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx new file mode 100644 index 000000000..12e5c6e44 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx new file mode 100644 index 000000000..7cad153e8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx new file mode 100644 index 000000000..487462dde --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/northflank/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx new file mode 100644 index 000000000..0294f4dad --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx new file mode 100644 index 000000000..a1926710f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/northflank" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx new file mode 100644 index 000000000..161ddac54 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx new file mode 100644 index 000000000..82ce1a96a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx new file mode 100644 index 000000000..2f743e82f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index dda9adcfc..2792e11c9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -130,6 +130,7 @@ "integrations/app-connections/mssql", "integrations/app-connections/mysql", "integrations/app-connections/netlify", + "integrations/app-connections/northflank", "integrations/app-connections/oci", "integrations/app-connections/okta", "integrations/app-connections/oracledb", @@ -552,6 +553,7 @@ "integrations/secret-syncs/humanitec", "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", + "integrations/secret-syncs/northflank", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", "integrations/secret-syncs/render", @@ -1848,6 +1850,18 @@ "api-reference/endpoints/app-connections/netlify/delete" ] }, + { + "group": "Northflank", + "pages": [ + "api-reference/endpoints/app-connections/northflank/list", + "api-reference/endpoints/app-connections/northflank/available", + "api-reference/endpoints/app-connections/northflank/get-by-id", + "api-reference/endpoints/app-connections/northflank/get-by-name", + "api-reference/endpoints/app-connections/northflank/create", + "api-reference/endpoints/app-connections/northflank/update", + "api-reference/endpoints/app-connections/northflank/delete" + ] + }, { "group": "OCI", "pages": [ @@ -2306,6 +2320,20 @@ "api-reference/endpoints/secret-syncs/netlify/remove-secrets" ] }, + { + "group": "Northflank", + "pages": [ + "api-reference/endpoints/secret-syncs/northflank/list", + "api-reference/endpoints/secret-syncs/northflank/get-by-id", + "api-reference/endpoints/secret-syncs/northflank/get-by-name", + "api-reference/endpoints/secret-syncs/northflank/create", + "api-reference/endpoints/secret-syncs/northflank/update", + "api-reference/endpoints/secret-syncs/northflank/delete", + "api-reference/endpoints/secret-syncs/northflank/sync-secrets", + "api-reference/endpoints/secret-syncs/northflank/import-secrets", + "api-reference/endpoints/secret-syncs/northflank/remove-secrets" + ] + }, { "group": "OCI", "pages": [ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-form.png b/docs/images/app-connections/northflank/northflank-app-connection-form.png new file mode 100644 index 000000000..226346517 Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-form.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-generated.png b/docs/images/app-connections/northflank/northflank-app-connection-generated.png new file mode 100644 index 000000000..89038637a Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-generated.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-option.png b/docs/images/app-connections/northflank/northflank-app-connection-option.png new file mode 100644 index 000000000..17106494a Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-option.png differ diff --git a/docs/images/app-connections/northflank/step-1.png b/docs/images/app-connections/northflank/step-1.png new file mode 100644 index 000000000..fe97a4a30 Binary files /dev/null and b/docs/images/app-connections/northflank/step-1.png differ diff --git a/docs/images/app-connections/northflank/step-2.png b/docs/images/app-connections/northflank/step-2.png new file mode 100644 index 000000000..e77d1e740 Binary files /dev/null and b/docs/images/app-connections/northflank/step-2.png differ diff --git a/docs/images/app-connections/northflank/step-3.png b/docs/images/app-connections/northflank/step-3.png new file mode 100644 index 000000000..739c4e48b Binary files /dev/null and b/docs/images/app-connections/northflank/step-3.png differ diff --git a/docs/images/app-connections/northflank/step-4-1.png b/docs/images/app-connections/northflank/step-4-1.png new file mode 100644 index 000000000..cd4a5dfaf Binary files /dev/null and b/docs/images/app-connections/northflank/step-4-1.png differ diff --git a/docs/images/app-connections/northflank/step-4-2.png b/docs/images/app-connections/northflank/step-4-2.png new file mode 100644 index 000000000..52789f9d1 Binary files /dev/null and b/docs/images/app-connections/northflank/step-4-2.png differ diff --git a/docs/images/app-connections/northflank/step-5.png b/docs/images/app-connections/northflank/step-5.png new file mode 100644 index 000000000..d8e9c817b Binary files /dev/null and b/docs/images/app-connections/northflank/step-5.png differ diff --git a/docs/images/app-connections/northflank/step-6.png b/docs/images/app-connections/northflank/step-6.png new file mode 100644 index 000000000..457a60cdf Binary files /dev/null and b/docs/images/app-connections/northflank/step-6.png differ diff --git a/docs/images/app-connections/northflank/step-7.png b/docs/images/app-connections/northflank/step-7.png new file mode 100644 index 000000000..76480665f Binary files /dev/null and b/docs/images/app-connections/northflank/step-7.png differ diff --git a/docs/images/secret-syncs/northflank/configure-destination.png b/docs/images/secret-syncs/northflank/configure-destination.png new file mode 100644 index 000000000..08dcb96bf Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-destination.png differ diff --git a/docs/images/secret-syncs/northflank/configure-details.png b/docs/images/secret-syncs/northflank/configure-details.png new file mode 100644 index 000000000..edbfa0dac Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-details.png differ diff --git a/docs/images/secret-syncs/northflank/configure-source.png b/docs/images/secret-syncs/northflank/configure-source.png new file mode 100644 index 000000000..530613f03 Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-source.png differ diff --git a/docs/images/secret-syncs/northflank/configure-sync-options.png b/docs/images/secret-syncs/northflank/configure-sync-options.png new file mode 100644 index 000000000..6e03b1f5f Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/northflank/review-configuration.png b/docs/images/secret-syncs/northflank/review-configuration.png new file mode 100644 index 000000000..59df97a80 Binary files /dev/null and b/docs/images/secret-syncs/northflank/review-configuration.png differ diff --git a/docs/images/secret-syncs/northflank/select-option.png b/docs/images/secret-syncs/northflank/select-option.png new file mode 100644 index 000000000..0ee9ea2fa Binary files /dev/null and b/docs/images/secret-syncs/northflank/select-option.png differ diff --git a/docs/images/secret-syncs/northflank/sync-created.png b/docs/images/secret-syncs/northflank/sync-created.png new file mode 100644 index 000000000..388d78f4d Binary files /dev/null and b/docs/images/secret-syncs/northflank/sync-created.png differ diff --git a/docs/integrations/app-connections/northflank.mdx b/docs/integrations/app-connections/northflank.mdx new file mode 100644 index 000000000..0435c3e95 --- /dev/null +++ b/docs/integrations/app-connections/northflank.mdx @@ -0,0 +1,125 @@ +--- +title: "Northflank Connection" +description: "Learn how to configure a Northflank Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://northflank.com/docs/v1/api/use-the-api) to connect with Northflank. + + + Infisical recommends creating a specific API role for the app connection and only giving access to projects that will use the integration. + + +## Create a Northflank API Token + + + + Navigate to your team page and click **Create token**. + + ![Create API Role](/images/app-connections/northflank/step-1.png) + + Click on **Create API role**. + + ![Create API Role](/images/app-connections/northflank/step-2.png) + + Select all the projects you want this role to have access to, or leave this unchecked if you want to give access to all projects. + + ![Create API Role](/images/app-connections/northflank/step-3.png) + + Add the **Projects** -> **Manage** -> **Read** permission. + + ![Create API Role](/images/app-connections/northflank/step-4-1.png) + + Add the **Config & Secrets** -> **Secret Groups** -> **List**, **Update** and **Read Values** permissions. + + ![Create API Role](/images/app-connections/northflank/step-4-2.png) + + Scroll to the bottom and save the API role. + + + Click on the **API** -> **Tokens** menu on the left and then click the **Create API token** button. + + ![Create API Token](/images/app-connections/northflank/step-5.png) + + Give a name to the API token and click the **Use role** button for the new API role you just created. + + ![Create API Token](/images/app-connections/northflank/step-6.png) + + Click the **View API token** icon to view and copy your token. + + ![Create API Token](/images/app-connections/northflank/step-7.png) + + + +## Create a Northflank Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Northflank Connection** from the list of integrations. + + ![Select Northflank Connection](/images/app-connections/northflank/northflank-app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + + ![Northflank Connection Modal](/images/app-connections/northflank/northflank-app-connection-form.png) + + + After submitting the form, your **Northflank Connection** will be successfully created and ready to use with your Infisical project. + + ![Northflank Connection Created](/images/app-connections/northflank/northflank-app-connection-generated.png) + + + + + + To create a Northflank Connection via API, send a request to the [Create Northflank Connection](/api-reference/endpoints/app-connections/northflank/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/northflank \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-northflank-connection", + "method": "api-token", + "projectId": "abcdef12-3456-7890-abcd-ef1234567890", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-northflank-connection", + "description": null, + "projectId": "abcdef12-3456-7890-abcd-ef1234567890", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-01-23T10:15:00.000Z", + "updatedAt": "2025-01-23T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "northflank", + "method": "api-token", + "credentials": {} + } + } + ``` + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs/northflank.mdx b/docs/integrations/secret-syncs/northflank.mdx new file mode 100644 index 000000000..66075c967 --- /dev/null +++ b/docs/integrations/secret-syncs/northflank.mdx @@ -0,0 +1,160 @@ +--- +title: "Northflank Sync" +description: "Learn how to configure a Northflank Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Northflank Connection](/integrations/app-connections/northflank) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Northflank](/images/secret-syncs/northflank/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/northflank/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/northflank/configure-destination.png) + + - **Northflank Connection**: The Northflank Connection to authenticate with. + - **Project**: The Northflank project to sync secrets to. + - **Secret Group**: The Northflank secret group to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/northflank/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Destination Secrets - Prioritize Infisical Values**: Imports any secrets present in the Northflank destination prior to syncing, prioritizing values from Infisical over Northflank when keys conflict. + - **Import Destination Secrets - Prioritize Northflank Values**: Imports any secrets present in the Northflank destination prior to syncing, prioritizing values from Northflank over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Northflank Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/northflank/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Northflank Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/northflank/review-configuration.png) + + + If enabled, your Northflank Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/northflank/sync-created.png) + + + + + To create a **Northflank Sync**, make an API request to the [Create Northflank Sync](/api-reference/endpoints/secret-syncs/northflank/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/northflank \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-northflank-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "INFISICAL_{{secretKey}}" + }, + "destinationConfig": { + "projectId": "my-project-id", + "secretGroupId": "my-secret-group-id" + } + }' + ``` + + ### Sample response + + ```json Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-northflank-sync", + "description": "an example sync", + "isAutoSyncEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "INFISICAL_{{secretKey}}", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "northflank", + "name": "my-northflank-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "northflank", + "destinationConfig": { + "projectId": "my-project-id", + "secretGroupId": "my-secret-group-id" + } + } + } + ``` + + \ No newline at end of file diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index cfc65d1fd..7761d4bfc 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -47,6 +47,7 @@ export const AppConnectionsBrowser = () => { {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Northflank", "slug": "northflank", "path": "/integrations/app-connections/northflank", "description": "Learn how to connect your Northflank projects to pull secrets from Infisical.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index 3598bf68e..d71ef1a80 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -37,7 +37,8 @@ export const SecretSyncsBrowser = () => { {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, - {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"} + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"}, + {"name": "Northflank", "slug": "northflank", "path": "/integrations/secret-syncs/northflank", "description": "Learn how to sync secrets from Infisical to Northflank projects.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx new file mode 100644 index 000000000..2392e4394 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx @@ -0,0 +1,123 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TNorthflankProject, + TNorthflankSecretGroup, + useNorthflankConnectionListProjects, + useNorthflankConnectionListSecretGroups +} from "@app/hooks/api/appConnections/northflank"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const NorthflankSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Northflank } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const projectId = useWatch({ name: "destinationConfig.projectId", control }); + + const { data: projects = [], isPending: isProjectsLoading } = useNorthflankConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: secretGroups = [], isPending: isSecretGroupsLoading } = + useNorthflankConnectionListSecretGroups(connectionId, projectId, { + enabled: Boolean(connectionId) && Boolean(projectId) + }); + + return ( + <> + { + setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.secretGroupId", ""); + setValue("destinationConfig.secretGroupName", ""); + }} + /> + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.projectName", v?.name ?? ""); + setValue("destinationConfig.secretGroupId", ""); + setValue("destinationConfig.secretGroupName", ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + ( + +
+ Don't see the secret group you're looking for?{" "} + +
+ + } + > + sg.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.secretGroupName", v?.name ?? ""); + }} + options={secretGroups} + placeholder="Select a secret group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 61ba54369..ffad9aa42 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -25,6 +25,7 @@ import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { LaravelForgeSyncFields } from "./LaravelForgeSyncFields"; import { NetlifySyncFields } from "./NetlifySyncFields"; +import { NorthflankSyncFields } from "./NorthflankSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; @@ -103,6 +104,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.LaravelForge: return ; + case SecretSync.Northflank: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index f66cadaea..a7fb037de 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -68,6 +68,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Supabase: case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: + case SecretSync.Northflank: case SecretSync.Bitbucket: case SecretSync.LaravelForge: AdditionalSyncOptionsFieldsComponent = null; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx new file mode 100644 index 000000000..a37597722 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx @@ -0,0 +1,20 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NorthflankSyncReviewFields = () => { + const { watch } = useFormContext(); + const projectName = watch("destinationConfig.projectName"); + const projectId = watch("destinationConfig.projectId"); + const secretGroupName = watch("destinationConfig.secretGroupName"); + const secretGroupId = watch("destinationConfig.secretGroupId"); + + return ( + <> + {projectName || projectId} + {secretGroupName || secretGroupId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 8c8ea523b..4cc9c7259 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -37,6 +37,7 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { LaravelForgeSyncReviewFields } from "./LaravelForgeSyncReviewFields"; import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; +import { NorthflankSyncReviewFields } from "./NorthflankSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; @@ -167,6 +168,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Netlify: DestinationFieldsComponent = ; break; + case SecretSync.Northflank: + DestinationFieldsComponent = ; + break; case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts new file mode 100644 index 000000000..1554da63b --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NorthflankSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: z.object({ + projectId: z.string().trim().min(1, "Project ID is required"), + projectName: z.string().trim().optional(), + secretGroupId: z.string().trim().min(1, "Secret Group ID is required"), + secretGroupName: z.string().trim().optional() + }) + }) +); 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 5ebc38184..146c862dc 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 @@ -22,6 +22,7 @@ import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { LaravelForgeSyncDestinationSchema } from "./laravel-forge-sync-destination-schema"; import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; +import { NorthflankSyncDestinationSchema } from "./northflank-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -62,6 +63,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, NetlifySyncDestinationSchema, + NorthflankSyncDestinationSchema, BitbucketSyncDestinationSchema, LaravelForgeSyncDestinationSchema ]); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 9ee03c3c0..351357d20 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -50,6 +50,7 @@ import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/type import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-connection"; import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; +import { NorthflankConnectionMethod } from "@app/hooks/api/appConnections/types/northflank-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -121,6 +122,7 @@ export const APP_CONNECTION_MAP: Record< name: "Netlify", image: "Netlify.png" }, + [AppConnection.Northflank]: { name: "Northflank", image: "Northflank.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, [AppConnection.LaravelForge]: { @@ -164,6 +166,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case BitbucketConnectionMethod.ApiToken: case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: + case NorthflankConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 12cae6ea6..a96af15d4 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -114,6 +114,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, + [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.LaravelForge]: AppConnection.LaravelForge }; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 66fed8a5d..4af1dbb27 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -36,6 +36,7 @@ export enum AppConnection { Supabase = "supabase", DigitalOcean = "digital-ocean", Netlify = "netlify", + Northflank = "northflank", Okta = "okta", Redis = "redis", LaravelForge = "laravel-forge" diff --git a/frontend/src/hooks/api/appConnections/northflank/index.ts b/frontend/src/hooks/api/appConnections/northflank/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/northflank/queries.tsx b/frontend/src/hooks/api/appConnections/northflank/queries.tsx new file mode 100644 index 000000000..bf63cd976 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TNorthflankProject, TNorthflankSecretGroup } from "./types"; + +const northflankConnectionKeys = { + all: [...appConnectionKeys.all, "northflank"] as const, + listProjects: (connectionId: string) => + [...northflankConnectionKeys.all, "projects", connectionId] as const, + listSecretGroups: (connectionId: string, projectId: string) => + [...northflankConnectionKeys.all, "secret-groups", connectionId, projectId] as const +}; + +export const useNorthflankConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TNorthflankProject[], + unknown, + TNorthflankProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: northflankConnectionKeys.listProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: TNorthflankProject[] }>( + `/api/v1/app-connections/northflank/${connectionId}/projects` + ); + + return data.projects; + }, + ...options + }); +}; + +export const useNorthflankConnectionListSecretGroups = ( + connectionId: string, + projectId: string, + options?: Omit< + UseQueryOptions< + TNorthflankSecretGroup[], + unknown, + TNorthflankSecretGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: northflankConnectionKeys.listSecretGroups(connectionId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ secretGroups: TNorthflankSecretGroup[] }>( + `/api/v1/app-connections/northflank/${connectionId}/projects/${projectId}/secret-groups` + ); + + return data.secretGroups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/northflank/types.ts b/frontend/src/hooks/api/appConnections/northflank/types.ts new file mode 100644 index 000000000..061d2179f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/types.ts @@ -0,0 +1,9 @@ +export type TNorthflankProject = { + id: string; + name: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 797e7a7c6..4d4425ef6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -168,6 +168,10 @@ export type TLaravelForgeConnectionOption = TAppConnectionOptionBase & { app: AppConnection.LaravelForge; }; +export type TNorthflankConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Northflank; +}; + export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; @@ -213,6 +217,7 @@ export type TAppConnectionOption = | TSupabaseConnectionOption | TDigitalOceanConnectionOption | TNetlifyConnectionOption + | TNorthflankConnectionOption | TOktaConnectionOption | TAzureAdCsConnectionOption | TLaravelForgeConnectionOption; @@ -254,6 +259,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Supabase]: TSupabaseConnectionOption; [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; [AppConnection.Netlify]: TNetlifyConnectionOption; + [AppConnection.Northflank]: TNorthflankConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index fd840d5de..d82ad90ec 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -27,6 +27,7 @@ import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; import { TNetlifyConnection } from "./netlify-connection"; +import { TNorthflankConnection } from "./northflank-connection"; import { TOCIConnection } from "./oci-connection"; import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; @@ -66,6 +67,8 @@ export * from "./laravel-forge-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; +export * from "./netlify-connection"; +export * from "./northflank-connection"; export * from "./oci-connection"; export * from "./okta-connection"; export * from "./oracledb-connection"; @@ -119,6 +122,7 @@ export type TAppConnection = | TSupabaseConnection | TDigitalOceanConnection | TNetlifyConnection + | TNorthflankConnection | TOktaConnection | TRedisConnection; diff --git a/frontend/src/hooks/api/appConnections/types/northflank-connection.ts b/frontend/src/hooks/api/appConnections/types/northflank-connection.ts new file mode 100644 index 000000000..9e29693e4 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/northflank-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum NorthflankConnectionMethod { + ApiToken = "api-token" +} + +export type TNorthflankConnection = TRootAppConnection & { app: AppConnection.Northflank } & { + method: NorthflankConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index efcc04b6d..149759d33 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -28,6 +28,7 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", + Northflank = "northflank", Bitbucket = "bitbucket", LaravelForge = "laravel-forge" } diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 3cab195bd..bd2e5af4f 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -23,6 +23,7 @@ import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TLaravelForgeSync } from "./laravel-forge-sync"; import { TNetlifySync } from "./netlify-sync"; +import { TNorthflankSync } from "./northflank-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; import { TRenderSync } from "./render-sync"; @@ -70,6 +71,7 @@ export type TSecretSync = | TSupabaseSync | TDigitalOceanAppPlatformSync | TNetlifySync + | TNorthflankSync | TBitbucketSync | TLaravelForgeSync; diff --git a/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts b/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts new file mode 100644 index 000000000..e1fff68fc --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts @@ -0,0 +1,19 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TNorthflankSync = TRootSecretSync & { + destination: SecretSync.Northflank; + destinationConfig: { + projectId: string; + projectName?: string; + secretGroupId: string; + secretGroupName?: string; + }; + + connection: { + app: AppConnection.Northflank; + name: string; + id: string; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index c4115a4b7..c09a58960 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -36,6 +36,7 @@ import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { NetlifyConnectionForm } from "./NetlifyConnectionForm"; +import { NorthflankConnectionForm } from "./NorthflankConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OktaConnectionForm } from "./OktaConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; @@ -169,6 +170,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Netlify: return ; + case AppConnection.Northflank: + return ; case AppConnection.Okta: return ; case AppConnection.Redis: @@ -330,6 +333,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.DigitalOcean: return ; + case AppConnection.Northflank: + return ; case AppConnection.Okta: return ; case AppConnection.Redis: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NorthflankConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NorthflankConnectionForm.tsx new file mode 100644 index 000000000..2b837ce19 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NorthflankConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + NorthflankConnectionMethod, + TNorthflankConnection +} from "@app/hooks/api/appConnections/types/northflank-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TNorthflankConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Northflank) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const NorthflankConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Northflank, + method: NorthflankConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx index ad0ed433d..9b8fdddf1 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx @@ -72,6 +72,7 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => { return (