diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2d5d24e55..584f480ad 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2285,6 +2285,10 @@ export const AppConnections = { }, CHECKLY: { apiKey: "The API key used to authenticate with Checkly." + }, + SUPABASE: { + accessKey: "The Key used to access Supabase.", + instanceUrl: "The URL used to access Supabase." } } }; @@ -2494,6 +2498,10 @@ export const SecretSyncs = { }, CHECKLY: { accountId: "The ID of the Checkly account to sync secrets to." + }, + SUPABASE: { + projectId: "The ID of the Supabase project to sync secrets to.", + projectName: "The name of the Supabase project 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 62d4ab979..7c1b52edd 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 @@ -83,6 +83,10 @@ import { RenderConnectionListItemSchema, SanitizedRenderConnectionSchema } from "@app/services/app-connection/render/render-connection-schema"; +import { + SanitizedSupabaseConnectionSchema, + SupabaseConnectionListItemSchema +} from "@app/services/app-connection/supabase"; import { SanitizedTeamCityConnectionSchema, TeamCityConnectionListItemSchema @@ -133,7 +137,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedBitbucketConnectionSchema.options, ...SanitizedZabbixConnectionSchema.options, ...SanitizedRailwayConnectionSchema.options, - ...SanitizedChecklyConnectionSchema.options + ...SanitizedChecklyConnectionSchema.options, + ...SanitizedSupabaseConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -169,7 +174,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ BitbucketConnectionListItemSchema, ZabbixConnectionListItemSchema, RailwayConnectionListItemSchema, - ChecklyConnectionListItemSchema + ChecklyConnectionListItemSchema, + SupabaseConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { 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 67a420cb7..287a406f6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -28,6 +28,7 @@ import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; import { registerRenderConnectionRouter } from "./render-connection-router"; +import { registerSupabaseConnectionRouter } from "./supabase-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; @@ -70,5 +71,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Supabase, + server, + sanitizedResponseSchema: SanitizedSupabaseConnectionSchema, + createSchema: CreateSupabaseConnectionSchema, + updateSchema: UpdateSupabaseConnectionSchema + }); + + // 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.supabase.listProjects(connectionId, req.permission); + + return { projects }; + } + }); +}; 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 67b865d31..8e8f696b7 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; +import { registerSupabaseSyncRouter } from "./supabase-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; @@ -53,7 +54,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/supabase-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/supabase-sync-router.ts new file mode 100644 index 000000000..c4343f283 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/supabase-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateSupabaseSyncSchema, + SupabaseSyncSchema, + UpdateSupabaseSyncSchema +} from "@app/services/secret-sync/supabase"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerSupabaseSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Supabase, + server, + responseSchema: SupabaseSyncSchema, + createSchema: CreateSupabaseSyncSchema, + updateSchema: UpdateSupabaseSyncSchema + }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index e47ff08a9..233ce0ea8 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -31,7 +31,8 @@ export enum AppConnection { Zabbix = "zabbix", Railway = "railway", Bitbucket = "bitbucket", - Checkly = "checkly" + Checkly = "checkly", + Supabase = "supabase" } 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 c75745edb..10bab521e 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -95,6 +95,11 @@ import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postg import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; import { RenderConnectionMethod } from "./render/render-connection-enums"; import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns"; +import { + getSupabaseConnectionListItem, + SupabaseConnectionMethod, + validateSupabaseConnectionCredentials +} from "./supabase"; import { getTeamCityConnectionListItem, TeamCityConnectionMethod, @@ -148,7 +153,8 @@ export const listAppConnectionOptions = () => { getZabbixConnectionListItem(), getRailwayConnectionListItem(), getBitbucketConnectionListItem(), - getChecklyConnectionListItem() + getChecklyConnectionListItem(), + getSupabaseConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -232,7 +238,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -292,6 +299,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case RenderConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey: return "API Key"; + case SupabaseConnectionMethod.AccessToken: + return "Access Token"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); @@ -355,7 +364,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Zabbix]: platformManagedCredentialsNotSupported, [AppConnection.Railway]: platformManagedCredentialsNotSupported, [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported, - [AppConnection.Checkly]: platformManagedCredentialsNotSupported + [AppConnection.Checkly]: platformManagedCredentialsNotSupported, + [AppConnection.Supabase]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 519a4e5ea..8a85020d8 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -33,7 +33,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Zabbix]: "Zabbix", [AppConnection.Railway]: "Railway", [AppConnection.Bitbucket]: "Bitbucket", - [AppConnection.Checkly]: "Checkly" + [AppConnection.Checkly]: "Checkly", + [AppConnection.Supabase]: "Supabase" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -69,5 +70,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -264,6 +271,7 @@ export type TAppConnectionInput = { id: string } & ( | TZabbixConnectionInput | TRailwayConnectionInput | TChecklyConnectionInput + | TSupabaseConnectionInput ); export type TSqlConnectionInput = @@ -311,7 +319,8 @@ export type TAppConnectionConfig = | TBitbucketConnectionConfig | TZabbixConnectionConfig | TRailwayConnectionConfig - | TChecklyConnectionConfig; + | TChecklyConnectionConfig + | TSupabaseConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -346,7 +355,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateBitbucketConnectionCredentialsSchema | TValidateZabbixConnectionCredentialsSchema | TValidateRailwayConnectionCredentialsSchema - | TValidateChecklyConnectionCredentialsSchema; + | TValidateChecklyConnectionCredentialsSchema + | TValidateSupabaseConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/supabase/index.ts b/backend/src/services/app-connection/supabase/index.ts new file mode 100644 index 000000000..509204769 --- /dev/null +++ b/backend/src/services/app-connection/supabase/index.ts @@ -0,0 +1,4 @@ +export * from "./supabase-connection-constants"; +export * from "./supabase-connection-fns"; +export * from "./supabase-connection-schemas"; +export * from "./supabase-connection-types"; diff --git a/backend/src/services/app-connection/supabase/supabase-connection-constants.ts b/backend/src/services/app-connection/supabase/supabase-connection-constants.ts new file mode 100644 index 000000000..18ca669b1 --- /dev/null +++ b/backend/src/services/app-connection/supabase/supabase-connection-constants.ts @@ -0,0 +1,3 @@ +export enum SupabaseConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/supabase/supabase-connection-fns.ts b/backend/src/services/app-connection/supabase/supabase-connection-fns.ts new file mode 100644 index 000000000..579bb5269 --- /dev/null +++ b/backend/src/services/app-connection/supabase/supabase-connection-fns.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { SupabaseConnectionMethod } from "./supabase-connection-constants"; +import { SupabasePublicAPI } from "./supabase-connection-public-client"; +import { TSupabaseConnection, TSupabaseConnectionConfig } from "./supabase-connection-types"; + +export const getSupabaseConnectionListItem = () => { + return { + name: "Supabase" as const, + app: AppConnection.Supabase as const, + methods: Object.values(SupabaseConnectionMethod) + }; +}; + +export const validateSupabaseConnectionCredentials = async (config: TSupabaseConnectionConfig) => { + const { credentials } = config; + + try { + await SupabasePublicAPI.healthcheck(config); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + return credentials; +}; + +export const listProjects = async (appConnection: TSupabaseConnection) => { + try { + return await SupabasePublicAPI.getProjects(appConnection); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list projects: ${error.message || "Unknown error"}` + }); + } + + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: "Unable to list projects", + error + }); + } +}; diff --git a/backend/src/services/app-connection/supabase/supabase-connection-public-client.ts b/backend/src/services/app-connection/supabase/supabase-connection-public-client.ts new file mode 100644 index 000000000..3aae50b96 --- /dev/null +++ b/backend/src/services/app-connection/supabase/supabase-connection-public-client.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode } from "axios"; + +import { createRequestClient } from "@app/lib/config/request"; +import { delay } from "@app/lib/delay"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { SupabaseConnectionMethod } from "./supabase-connection-constants"; +import { TSupabaseConnectionConfig, TSupabaseProject, TSupabaseSecret } from "./supabase-connection-types"; + +export const getSupabaseInstanceUrl = async (config: TSupabaseConnectionConfig) => { + const instanceUrl = config.credentials.instanceUrl + ? removeTrailingSlash(config.credentials.instanceUrl) + : "https://api.supabase.com"; + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export function getSupabaseAuthHeaders(connection: TSupabaseConnectionConfig): Record { + switch (connection.method) { + case SupabaseConnectionMethod.AccessToken: + return { + Authorization: `Bearer ${connection.credentials.accessKey}` + }; + default: + throw new Error(`Unsupported Supabase connection method`); + } +} + +export function getSupabaseRatelimiter(response: AxiosResponse): { + maxAttempts: number; + isRatelimited: boolean; + wait: () => Promise; +} { + const wait = () => { + return delay(60 * 1000); + }; + + return { + isRatelimited: response.status === HttpStatusCode.TooManyRequests, + wait, + maxAttempts: 3 + }; +} + +class SupabasePublicClient { + private client: AxiosInstance; + + constructor() { + this.client = createRequestClient({ + headers: { + "Content-Type": "application/json" + } + }); + } + + async send( + connection: TSupabaseConnectionConfig, + config: AxiosRequestConfig, + retryAttempt = 0 + ): Promise { + const response = await this.client.request({ + ...config, + baseURL: await getSupabaseInstanceUrl(connection), + validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests, + headers: getSupabaseAuthHeaders(connection) + }); + + const limiter = getSupabaseRatelimiter(response); + + if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) { + await limiter.wait(); + return this.send(connection, config, retryAttempt + 1); + } + + return response.data; + } + + async healthcheck(connection: TSupabaseConnectionConfig) { + switch (connection.method) { + case SupabaseConnectionMethod.AccessToken: + return void (await this.getProjects(connection)); + default: + throw new Error(`Unsupported Supabase connection method`); + } + } + + async getVariables(connection: TSupabaseConnectionConfig, projectRef: string) { + const res = await this.send(connection, { + method: "GET", + url: `/v1/projects/${projectRef}/secrets` + }); + + return res; + } + + // Supabase does not support updating variables directly + // Instead, just call create again with the same key and it will overwrite the existing variable + async createVariables(connection: TSupabaseConnectionConfig, projectRef: string, ...variables: TSupabaseSecret[]) { + const res = await this.send(connection, { + method: "POST", + url: `/v1/projects/${projectRef}/secrets`, + data: variables + }); + + return res; + } + + async deleteVariables(connection: TSupabaseConnectionConfig, projectRef: string, ...variables: string[]) { + const res = await this.send(connection, { + method: "DELETE", + url: `/v1/projects/${projectRef}/secrets`, + data: variables + }); + + return res; + } + + async getProjects(connection: TSupabaseConnectionConfig) { + const res = await this.send(connection, { + method: "GET", + url: `/v1/projects` + }); + + return res; + } +} + +export const SupabasePublicAPI = new SupabasePublicClient(); diff --git a/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts b/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts new file mode 100644 index 000000000..9a06b6554 --- /dev/null +++ b/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts @@ -0,0 +1,70 @@ +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 { SupabaseConnectionMethod } from "./supabase-connection-constants"; + +export const SupabaseConnectionMethodSchema = z + .nativeEnum(SupabaseConnectionMethod) + .describe(AppConnections.CREATE(AppConnection.Supabase).method); + +export const SupabaseConnectionAccessTokenCredentialsSchema = z.object({ + accessKey: z + .string() + .trim() + .min(1, "Access Key required") + .max(255) + .describe(AppConnections.CREDENTIALS.SUPABASE.accessKey), + instanceUrl: z.string().trim().url().max(255).describe(AppConnections.CREDENTIALS.SUPABASE.instanceUrl).optional() +}); + +const BaseSupabaseConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Supabase) +}); + +export const SupabaseConnectionSchema = BaseSupabaseConnectionSchema.extend({ + method: SupabaseConnectionMethodSchema, + credentials: SupabaseConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedSupabaseConnectionSchema = z.discriminatedUnion("method", [ + BaseSupabaseConnectionSchema.extend({ + method: SupabaseConnectionMethodSchema, + credentials: SupabaseConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateSupabaseConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: SupabaseConnectionMethodSchema, + credentials: SupabaseConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Supabase).credentials + ) + }) +]); + +export const CreateSupabaseConnectionSchema = ValidateSupabaseConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Supabase) +); + +export const UpdateSupabaseConnectionSchema = z + .object({ + credentials: SupabaseConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Supabase).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Supabase)); + +export const SupabaseConnectionListItemSchema = z.object({ + name: z.literal("Supabase"), + app: z.literal(AppConnection.Supabase), + methods: z.nativeEnum(SupabaseConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/supabase/supabase-connection-service.ts b/backend/src/services/app-connection/supabase/supabase-connection-service.ts new file mode 100644 index 000000000..11cff2b8a --- /dev/null +++ b/backend/src/services/app-connection/supabase/supabase-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listProjects as getSupabaseProjects } from "./supabase-connection-fns"; +import { TSupabaseConnection } from "./supabase-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const supabaseConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Supabase, connectionId, actor); + try { + const projects = await getSupabaseProjects(appConnection); + + return projects ?? []; + } catch (error) { + logger.error(error, "Failed to establish connection with Supabase"); + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/supabase/supabase-connection-types.ts b/backend/src/services/app-connection/supabase/supabase-connection-types.ts new file mode 100644 index 000000000..8bf810c1d --- /dev/null +++ b/backend/src/services/app-connection/supabase/supabase-connection-types.ts @@ -0,0 +1,44 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateSupabaseConnectionSchema, + SupabaseConnectionSchema, + ValidateSupabaseConnectionCredentialsSchema +} from "./supabase-connection-schemas"; + +export type TSupabaseConnection = z.infer; + +export type TSupabaseConnectionInput = z.infer & { + app: AppConnection.Supabase; +}; + +export type TValidateSupabaseConnectionCredentialsSchema = typeof ValidateSupabaseConnectionCredentialsSchema; + +export type TSupabaseConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TSupabaseProject = { + id: string; + organization_id: string; + name: string; + region: string; + created_at: Date; + status: string; + database: TSupabaseDatabase; +}; + +type TSupabaseDatabase = { + host: string; + version: string; + postgres_engine: string; + release_channel: string; +}; + +export type TSupabaseSecret = { + name: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 7e377ee13..8d08e4d82 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -22,7 +22,7 @@ export enum SecretSync { GitLab = "gitlab", CloudflarePages = "cloudflare-pages", CloudflareWorkers = "cloudflare-workers", - + Supabase = "supabase", Zabbix = "zabbix", Railway = "railway", Checkly = "checkly" diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index a755c97b7..3daa9232f 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -46,6 +46,7 @@ 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"; import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; +import { SUPABASE_SYNC_LIST_OPTION, SupabaseSyncFns } from "./supabase"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; @@ -76,7 +77,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.GitLab]: GITLAB_SYNC_LIST_OPTION, [SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION, [SecretSync.CloudflareWorkers]: CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, - + [SecretSync.Supabase]: SUPABASE_SYNC_LIST_OPTION, [SecretSync.Zabbix]: ZABBIX_SYNC_LIST_OPTION, [SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION, [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION @@ -255,6 +256,8 @@ export const SecretSyncFns = { return RailwaySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Checkly: return ChecklySyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Supabase: + return SupabaseSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -359,6 +362,9 @@ export const SecretSyncFns = { case SecretSync.Checkly: secretMap = await ChecklySyncFns.getSecrets(secretSync); break; + case SecretSync.Supabase: + secretMap = await SupabaseSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -444,6 +450,8 @@ export const SecretSyncFns = { return RailwaySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Checkly: return ChecklySyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Supabase: + return SupabaseSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index a83418d15..a8a017480 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -25,7 +25,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.GitLab]: "GitLab", [SecretSync.CloudflarePages]: "Cloudflare Pages", [SecretSync.CloudflareWorkers]: "Cloudflare Workers", - + [SecretSync.Supabase]: "Supabase", [SecretSync.Zabbix]: "Zabbix", [SecretSync.Railway]: "Railway", [SecretSync.Checkly]: "Checkly" @@ -55,7 +55,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.GitLab]: AppConnection.GitLab, [SecretSync.CloudflarePages]: AppConnection.Cloudflare, [SecretSync.CloudflareWorkers]: AppConnection.Cloudflare, - + [SecretSync.Supabase]: AppConnection.Supabase, [SecretSync.Zabbix]: AppConnection.Zabbix, [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly @@ -85,7 +85,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.GitLab]: SecretSyncPlanType.Regular, [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular, [SecretSync.CloudflareWorkers]: SecretSyncPlanType.Regular, - + [SecretSync.Supabase]: SecretSyncPlanType.Regular, [SecretSync.Zabbix]: SecretSyncPlanType.Regular, [SecretSync.Railway]: SecretSyncPlanType.Regular, [SecretSync.Checkly]: SecretSyncPlanType.Regular diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 3fcef9493..2c8753d66 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -118,6 +118,12 @@ import { TRenderSyncListItem, TRenderSyncWithCredentials } from "./render/render-sync-types"; +import { + TSupabaseSync, + TSupabaseSyncInput, + TSupabaseSyncListItem, + TSupabaseSyncWithCredentials +} from "./supabase/supabase-sync-types"; import { TTeamCitySync, TTeamCitySyncInput, @@ -159,7 +165,8 @@ export type TSecretSync = | TCloudflareWorkersSync | TZabbixSync | TRailwaySync - | TChecklySync; + | TChecklySync + | TSupabaseSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -187,7 +194,8 @@ export type TSecretSyncWithCredentials = | TCloudflareWorkersSyncWithCredentials | TZabbixSyncWithCredentials | TRailwaySyncWithCredentials - | TChecklySyncWithCredentials; + | TChecklySyncWithCredentials + | TSupabaseSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -215,7 +223,8 @@ export type TSecretSyncInput = | TCloudflareWorkersSyncInput | TZabbixSyncInput | TRailwaySyncInput - | TChecklySyncInput; + | TChecklySyncInput + | TSupabaseSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -243,7 +252,8 @@ export type TSecretSyncListItem = | TCloudflareWorkersSyncListItem | TZabbixSyncListItem | TRailwaySyncListItem - | TChecklySyncListItem; + | TChecklySyncListItem + | TSupabaseSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/supabase/index.ts b/backend/src/services/secret-sync/supabase/index.ts new file mode 100644 index 000000000..0e1292f35 --- /dev/null +++ b/backend/src/services/secret-sync/supabase/index.ts @@ -0,0 +1,4 @@ +export * from "./supabase-sync-constants"; +export * from "./supabase-sync-fns"; +export * from "./supabase-sync-schemas"; +export * from "./supabase-sync-types"; diff --git a/backend/src/services/secret-sync/supabase/supabase-sync-constants.ts b/backend/src/services/secret-sync/supabase/supabase-sync-constants.ts new file mode 100644 index 000000000..319fcc82e --- /dev/null +++ b/backend/src/services/secret-sync/supabase/supabase-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 SUPABASE_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Supabase", + destination: SecretSync.Supabase, + connection: AppConnection.Supabase, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/supabase/supabase-sync-fns.ts b/backend/src/services/secret-sync/supabase/supabase-sync-fns.ts new file mode 100644 index 000000000..b8106a0ae --- /dev/null +++ b/backend/src/services/secret-sync/supabase/supabase-sync-fns.ts @@ -0,0 +1,102 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import { chunkArray } from "@app/lib/fn"; +import { TSupabaseSecret } from "@app/services/app-connection/supabase"; +import { SupabasePublicAPI } from "@app/services/app-connection/supabase/supabase-connection-public-client"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; + +import { SecretSyncError } from "../secret-sync-errors"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; +import { TSecretMap } from "../secret-sync-types"; +import { TSupabaseSyncWithCredentials } from "./supabase-sync-types"; + +const SUPABASE_INTERNAL_SECRETS = ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_DB_URL"]; + +export const SupabaseSyncFns = { + async getSecrets(secretSync: TSupabaseSyncWithCredentials) { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + async syncSecrets(secretSync: TSupabaseSyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + const config = secretSync.destinationConfig; + + const variables = await SupabasePublicAPI.getVariables(secretSync.connection, config.projectId); + + const supabaseSecrets = new Map(variables!.map((variable) => [variable.name, variable])); + + const toCreate: TSupabaseSecret[] = []; + + for (const key of Object.keys(secretMap)) { + const variable: TSupabaseSecret = { name: key, value: secretMap[key].value ?? "" }; + toCreate.push(variable); + } + + for await (const batch of chunkArray(toCreate, 100)) { + try { + await SupabasePublicAPI.createVariables(secretSync.connection, config.projectId, ...batch); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: batch[0].name // Use the first key in the batch for error reporting + }); + } + } + + if (disableSecretDeletion) return; + + const toDelete: string[] = []; + + for (const key of supabaseSecrets.keys()) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema) || SUPABASE_INTERNAL_SECRETS.includes(key)) continue; + + if (!secretMap[key]) { + toDelete.push(key); + } + } + + for await (const batch of chunkArray(toDelete, 100)) { + try { + await SupabasePublicAPI.deleteVariables(secretSync.connection, config.projectId, ...batch); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: batch[0] // Use the first key in the batch for error reporting + }); + } + } + }, + + async removeSecrets(secretSync: TSupabaseSyncWithCredentials, secretMap: TSecretMap) { + const config = secretSync.destinationConfig; + + const variables = await SupabasePublicAPI.getVariables(secretSync.connection, config.projectId); + + const supabaseSecrets = new Map(variables!.map((variable) => [variable.name, variable])); + + const toDelete: string[] = []; + + for (const key of supabaseSecrets.keys()) { + if (SUPABASE_INTERNAL_SECRETS.includes(key) || !(key in secretMap)) continue; + + toDelete.push(key); + } + + for await (const batch of chunkArray(toDelete, 100)) { + try { + await SupabasePublicAPI.deleteVariables(secretSync.connection, config.projectId, ...batch); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: batch[0] // Use the first key in the batch for error reporting + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts b/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts new file mode 100644 index 000000000..633b40dab --- /dev/null +++ b/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +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 SupabaseSyncDestinationConfigSchema = z.object({ + projectId: z.string().max(255).min(1, "Project ID is required"), + projectName: z.string().max(255).min(1, "Project Name is required") +}); + +const SupabaseSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const SupabaseSyncSchema = BaseSecretSyncSchema(SecretSync.Supabase, SupabaseSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Supabase), + destinationConfig: SupabaseSyncDestinationConfigSchema +}); + +export const CreateSupabaseSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Supabase, + SupabaseSyncOptionsConfig +).extend({ + destinationConfig: SupabaseSyncDestinationConfigSchema +}); + +export const UpdateSupabaseSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Supabase, + SupabaseSyncOptionsConfig +).extend({ + destinationConfig: SupabaseSyncDestinationConfigSchema.optional() +}); + +export const SupabaseSyncListItemSchema = z.object({ + name: z.literal("Supabase"), + connection: z.literal(AppConnection.Supabase), + destination: z.literal(SecretSync.Supabase), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/supabase/supabase-sync-types.ts b/backend/src/services/secret-sync/supabase/supabase-sync-types.ts new file mode 100644 index 000000000..a222748a8 --- /dev/null +++ b/backend/src/services/secret-sync/supabase/supabase-sync-types.ts @@ -0,0 +1,21 @@ +import z from "zod"; + +import { TSupabaseConnection } from "@app/services/app-connection/supabase"; + +import { CreateSupabaseSyncSchema, SupabaseSyncListItemSchema, SupabaseSyncSchema } from "./supabase-sync-schemas"; + +export type TSupabaseSyncListItem = z.infer; + +export type TSupabaseSync = z.infer; + +export type TSupabaseSyncInput = z.infer; + +export type TSupabaseSyncWithCredentials = TSupabaseSync & { + connection: TSupabaseConnection; +}; + +export type TSupabaseVariablesGraphResponse = { + data: { + variables: Record; + }; +}; diff --git a/docs/api-reference/endpoints/app-connections/supabase/available.mdx b/docs/api-reference/endpoints/app-connections/supabase/available.mdx new file mode 100644 index 000000000..136a56749 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/supabase/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/supabase/create.mdx b/docs/api-reference/endpoints/app-connections/supabase/create.mdx new file mode 100644 index 000000000..4b9717d98 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/supabase" +--- + + + Check out the configuration docs for [Supabase Connections](/integrations/app-connections/supabase) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/supabase/delete.mdx b/docs/api-reference/endpoints/app-connections/supabase/delete.mdx new file mode 100644 index 000000000..f116f5dd7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/supabase/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/supabase/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/supabase/get-by-id.mdx new file mode 100644 index 000000000..007a100fe --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/supabase/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/supabase/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/supabase/get-by-name.mdx new file mode 100644 index 000000000..3c968cc76 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/supabase/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/supabase/list.mdx b/docs/api-reference/endpoints/app-connections/supabase/list.mdx new file mode 100644 index 000000000..ff6155541 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/supabase" +--- diff --git a/docs/api-reference/endpoints/app-connections/supabase/update.mdx b/docs/api-reference/endpoints/app-connections/supabase/update.mdx new file mode 100644 index 000000000..693378fb7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/supabase/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/supabase/{connectionId}" +--- + + + Check out the configuration docs for [Supabase Connections](/integrations/app-connections/supabase) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/create.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/create.mdx new file mode 100644 index 000000000..573b3506e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/supabase" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/delete.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/delete.mdx new file mode 100644 index 000000000..24d05f117 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/supabase/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/get-by-id.mdx new file mode 100644 index 000000000..0dc7f3353 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/supabase/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/get-by-name.mdx new file mode 100644 index 000000000..3f8770130 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/supabase/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/list.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/list.mdx new file mode 100644 index 000000000..2d4749419 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/supabase" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/remove-secrets.mdx new file mode 100644 index 000000000..fdfb3a44e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/supabase/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/sync-secrets.mdx new file mode 100644 index 000000000..5e17b1ca4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/supabase/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/supabase/update.mdx b/docs/api-reference/endpoints/secret-syncs/supabase/update.mdx new file mode 100644 index 000000000..a05d17959 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/supabase/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/supabase/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index fd75a8cde..a32453c89 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -78,10 +78,7 @@ }, { "group": "Infisical SSH", - "pages": [ - "documentation/platform/ssh/overview", - "documentation/platform/ssh/host-groups" - ] + "pages": ["documentation/platform/ssh/overview", "documentation/platform/ssh/host-groups"] }, { "group": "Key Management (KMS)", @@ -378,10 +375,7 @@ }, { "group": "Architecture", - "pages": [ - "internals/architecture/components", - "internals/architecture/cloud" - ] + "pages": ["internals/architecture/components", "internals/architecture/cloud"] }, "internals/security", "internals/service-tokens" @@ -491,6 +485,7 @@ "integrations/app-connections/postgres", "integrations/app-connections/railway", "integrations/app-connections/render", + "integrations/app-connections/supabase", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", "integrations/app-connections/vercel", @@ -528,6 +523,7 @@ "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", "integrations/secret-syncs/render", + "integrations/secret-syncs/supabase", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", @@ -555,10 +551,7 @@ "integrations/cloud/gcp-secret-manager", { "group": "Cloudflare", - "pages": [ - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers" - ] + "pages": ["integrations/cloud/cloudflare-pages", "integrations/cloud/cloudflare-workers"] }, "integrations/cloud/terraform-cloud", "integrations/cloud/databricks", @@ -670,11 +663,7 @@ "cli/commands/reset", { "group": "infisical scan", - "pages": [ - "cli/commands/scan", - "cli/commands/scan-git-changes", - "cli/commands/scan-install" - ] + "pages": ["cli/commands/scan", "cli/commands/scan-git-changes", "cli/commands/scan-install"] } ] }, @@ -998,9 +987,7 @@ "pages": [ { "group": "Kubernetes", - "pages": [ - "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" - ] + "pages": ["api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"] }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", @@ -1557,6 +1544,18 @@ "api-reference/endpoints/app-connections/render/delete" ] }, + { + "group": "Supabase", + "pages": [ + "api-reference/endpoints/app-connections/supabase/list", + "api-reference/endpoints/app-connections/supabase/available", + "api-reference/endpoints/app-connections/supabase/get-by-id", + "api-reference/endpoints/app-connections/supabase/get-by-name", + "api-reference/endpoints/app-connections/supabase/create", + "api-reference/endpoints/app-connections/supabase/update", + "api-reference/endpoints/app-connections/supabase/delete" + ] + }, { "group": "TeamCity", "pages": [ @@ -1908,6 +1907,19 @@ "api-reference/endpoints/secret-syncs/render/remove-secrets" ] }, + { + "group": "Supabase", + "pages": [ + "api-reference/endpoints/secret-syncs/supabase/list", + "api-reference/endpoints/secret-syncs/supabase/get-by-id", + "api-reference/endpoints/secret-syncs/supabase/get-by-name", + "api-reference/endpoints/secret-syncs/supabase/create", + "api-reference/endpoints/secret-syncs/supabase/update", + "api-reference/endpoints/secret-syncs/supabase/delete", + "api-reference/endpoints/secret-syncs/supabase/sync-secrets", + "api-reference/endpoints/secret-syncs/supabase/remove-secrets" + ] + }, { "group": "TeamCity", "pages": [ diff --git a/docs/images/app-connections/supabase/app-connection-api-keys.png b/docs/images/app-connections/supabase/app-connection-api-keys.png new file mode 100644 index 000000000..f37c1f306 Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-api-keys.png differ diff --git a/docs/images/app-connections/supabase/app-connection-create-api-key.png b/docs/images/app-connections/supabase/app-connection-create-api-key.png new file mode 100644 index 000000000..c860ba17f Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-create-api-key.png differ diff --git a/docs/images/app-connections/supabase/app-connection-create-form.png b/docs/images/app-connections/supabase/app-connection-create-form.png new file mode 100644 index 000000000..3633dc21e Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-create-form.png differ diff --git a/docs/images/app-connections/supabase/app-connection-form.png b/docs/images/app-connections/supabase/app-connection-form.png new file mode 100644 index 000000000..c1b550147 Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-form.png differ diff --git a/docs/images/app-connections/supabase/app-connection-generated.png b/docs/images/app-connections/supabase/app-connection-generated.png new file mode 100644 index 000000000..c494abc7a Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-generated.png differ diff --git a/docs/images/app-connections/supabase/app-connection-key-generated.png b/docs/images/app-connections/supabase/app-connection-key-generated.png new file mode 100644 index 000000000..0732cd37b Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-key-generated.png differ diff --git a/docs/images/app-connections/supabase/app-connection-option.png b/docs/images/app-connections/supabase/app-connection-option.png new file mode 100644 index 000000000..68c29876c Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-option.png differ diff --git a/docs/images/app-connections/supabase/app-connection-user-settings.png b/docs/images/app-connections/supabase/app-connection-user-settings.png new file mode 100644 index 000000000..fcd280b91 Binary files /dev/null and b/docs/images/app-connections/supabase/app-connection-user-settings.png differ diff --git a/docs/images/secret-syncs/supabase/select-option.png b/docs/images/secret-syncs/supabase/select-option.png new file mode 100644 index 000000000..863bba0ca Binary files /dev/null and b/docs/images/secret-syncs/supabase/select-option.png differ diff --git a/docs/images/secret-syncs/supabase/sync-created.png b/docs/images/secret-syncs/supabase/sync-created.png new file mode 100644 index 000000000..118c499de Binary files /dev/null and b/docs/images/secret-syncs/supabase/sync-created.png differ diff --git a/docs/images/secret-syncs/supabase/sync-destination.png b/docs/images/secret-syncs/supabase/sync-destination.png new file mode 100644 index 000000000..3f9c6680d Binary files /dev/null and b/docs/images/secret-syncs/supabase/sync-destination.png differ diff --git a/docs/images/secret-syncs/supabase/sync-details.png b/docs/images/secret-syncs/supabase/sync-details.png new file mode 100644 index 000000000..79ef5d610 Binary files /dev/null and b/docs/images/secret-syncs/supabase/sync-details.png differ diff --git a/docs/images/secret-syncs/supabase/sync-options.png b/docs/images/secret-syncs/supabase/sync-options.png new file mode 100644 index 000000000..f6b400138 Binary files /dev/null and b/docs/images/secret-syncs/supabase/sync-options.png differ diff --git a/docs/images/secret-syncs/supabase/sync-review.png b/docs/images/secret-syncs/supabase/sync-review.png new file mode 100644 index 000000000..c3b6adde8 Binary files /dev/null and b/docs/images/secret-syncs/supabase/sync-review.png differ diff --git a/docs/images/secret-syncs/supabase/sync-source.png b/docs/images/secret-syncs/supabase/sync-source.png new file mode 100644 index 000000000..b7bbf5de8 Binary files /dev/null and b/docs/images/secret-syncs/supabase/sync-source.png differ diff --git a/docs/integrations/app-connections/supabase.mdx b/docs/integrations/app-connections/supabase.mdx new file mode 100644 index 000000000..9716b1526 --- /dev/null +++ b/docs/integrations/app-connections/supabase.mdx @@ -0,0 +1,107 @@ +--- +title: "Supabase Connection" +description: "Learn how to configure a Supabase Connection for Infisical." +--- + +Infisical supports the use of [Personal Access Tokens](https://supabase.com/dashboard/account/tokens) to connect with Supabase. + +## Create a Supabase Personal Access Token + + + + ![Account Preferences](/images/app-connections/supabase/app-connection-user-settings.png) + + + ![Settings Page](/images/app-connections/supabase/app-connection-api-keys.png) + + + ![Access Tokens Page](/images/app-connections/supabase/app-connection-create-api-key.png) + + + Provide a descriptive name for the token. + + ![Enter Name](/images/app-connections/supabase/app-connection-create-form.png) + + + + ![Create Token](/images/app-connections/supabase/app-connection-key-generated.png) + + + +## Create a Supabase Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Supabase Connection** from the list of integrations. + + ![Select Supabase Connection](/images/app-connections/supabase/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - Supabase instance URL (e.g., `https://your-domain.com` or `https://api.supabase.com`) + - The Access Token value from the previous step + + ![Supabase Connection Modal](/images/app-connections/supabase/app-connection-form.png) + + + After submitting the form, your **Supabase Connection** will be successfully created and ready to use with your Infisical projects. + + ![Supabase Connection Created](/images/app-connections/supabase/app-connection-generated.png) + + + + + + + To create a Supabase Connection via API, send a request to the [Create Supabase Connection](/api-reference/endpoints/app-connections/supabase/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/supabase \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-supabase-connection", + "method": "access-token", + "credentials": { + "accessToken": "[Access Token]", + "instanceUrl": "https://api.supabase.com" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-supabase-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "supabase", + "method": "access-token", + "credentials": { + "instanceUrl": "https://api.supabase.com" + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/supabase.mdx b/docs/integrations/secret-syncs/supabase.mdx new file mode 100644 index 000000000..f43dcfbbe --- /dev/null +++ b/docs/integrations/secret-syncs/supabase.mdx @@ -0,0 +1,163 @@ +--- +title: "Supabase Sync" +description: "Learn how to configure a Supabase Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Supabase Connection](/integrations/app-connections/supabase) + + + + + + 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 Supabase](/images/secret-syncs/supabase/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/supabase/sync-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/supabase/sync-destination.png) + + - **Supabase Connection**: The Supabase Connection to authenticate with. + - **Project**: The Supabase project to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/supabase/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. + + Supabase does not support importing secrets. + + - **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 Supabase Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/supabase/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Supabase Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/supabase/sync-review.png) + + + If enabled, your Supabase Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/supabase/sync-created.png) + + + + + To create a **Supabase Sync**, make an API request to the [Create Supabase Sync](/api-reference/endpoints/secret-syncs/supabase/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/supabase \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-supabase-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "projectName": "Example Project" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-supabase-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "supabase", + "name": "my-supabase-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": "supabase", + "destinationConfig": { + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "projectName": "Example Project" + } + } + } + ``` + + + diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 7ac57bbd9..09cf2caec 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -24,6 +24,7 @@ import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; +import { SupabaseSyncFields } from "./SupabaseSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; @@ -88,6 +89,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Checkly: return ; + case SecretSync.Supabase: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SupabaseSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SupabaseSyncFields.tsx new file mode 100644 index 000000000..7d698c002 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SupabaseSyncFields.tsx @@ -0,0 +1,65 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TSupabaseProject, + useSupabaseConnectionListProjects +} from "@app/hooks/api/appConnections/supabase"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const SupabaseSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Supabase } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: projects = [], isPending: isProjectsLoading } = useSupabaseConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.projectId", ""); + }} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.projectName", v?.name ?? ""); + }} + options={projects} + placeholder="Select project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 5c1da3b37..eaf66a053 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -62,6 +62,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Zabbix: case SecretSync.Railway: case SecretSync.Checkly: + case SecretSync.Supabase: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index a4e1c5b5d..d09f58a2e 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -34,6 +34,7 @@ import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; import { RenderSyncReviewFields } from "./RenderSyncReviewFields"; +import { SupabaseSyncReviewFields } from "./SupabaseSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; @@ -140,6 +141,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Checkly: DestinationFieldsComponent = ; break; + case SecretSync.Supabase: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SupabaseSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SupabaseSyncReviewFields.tsx new file mode 100644 index 000000000..332359743 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SupabaseSyncReviewFields.tsx @@ -0,0 +1,12 @@ +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 SupabaseSyncReviewFields = () => { + const { watch } = useFormContext(); + const projectName = watch("destinationConfig.projectName"); + + return {projectName}; +}; 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 94a2f41ad..83c334f71 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 @@ -21,6 +21,7 @@ import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-sch import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; +import { SupabaseSyncDestinationSchema } from "./supabase-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; @@ -51,7 +52,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ GitlabSyncDestinationSchema, CloudflarePagesSyncDestinationSchema, CloudflareWorkersSyncDestinationSchema, - + SupabaseSyncDestinationSchema, ZabbixSyncDestinationSchema, RailwaySyncDestinationSchema, ChecklySyncDestinationSchema diff --git a/frontend/src/components/secret-syncs/forms/schemas/supabase-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/supabase-sync-destination-schema.ts new file mode 100644 index 000000000..3cbb49775 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/supabase-sync-destination-schema.ts @@ -0,0 +1,14 @@ +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 SupabaseSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Supabase), + destinationConfig: z.object({ + projectId: z.string().max(255).min(1, "Project ID is required"), + projectName: z.string().max(255).min(1, "Project Name is required") + }) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 229b77bf5..996483904 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -46,6 +46,7 @@ import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/hero 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"; +import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/supabase-connection"; export const APP_CONNECTION_MAP: Record< AppConnection, @@ -96,7 +97,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" }, [AppConnection.Railway]: { name: "Railway", image: "Railway.png" }, [AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" }, - [AppConnection.Checkly]: { name: "Checkly", image: "Checkly.png" } + [AppConnection.Checkly]: { name: "Checkly", image: "Checkly.png" }, + [AppConnection.Supabase]: { name: "Supabase", image: "Supabase.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -151,6 +153,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case HerokuConnectionMethod.AuthToken: return { name: "Auth Token", icon: faKey }; case RailwayConnectionMethod.AccountToken: + case SupabaseConnectionMethod.AccessToken: return { name: "Account Token", icon: faKey }; case RailwayConnectionMethod.TeamToken: return { name: "Team Token", icon: faKey }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index b81e7d85a..29b4f2f73 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -97,6 +97,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.GitLab]: AppConnection.Gitlab, [SecretSync.CloudflarePages]: AppConnection.Cloudflare, [SecretSync.CloudflareWorkers]: AppConnection.Cloudflare, - + [SecretSync.Supabase]: AppConnection.Supabase, [SecretSync.Zabbix]: AppConnection.Zabbix, [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 4364be5e4..965675bc7 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -31,5 +31,6 @@ export enum AppConnection { Bitbucket = "bitbucket", Zabbix = "zabbix", Railway = "railway", - Checkly = "checkly" + Checkly = "checkly", + Supabase = "supabase" } diff --git a/frontend/src/hooks/api/appConnections/supabase/index.ts b/frontend/src/hooks/api/appConnections/supabase/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/supabase/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/supabase/queries.tsx b/frontend/src/hooks/api/appConnections/supabase/queries.tsx new file mode 100644 index 000000000..488e8b01a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/supabase/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TSupabaseProject } from "./types"; + +const supabaseConnectionKeys = { + all: [...appConnectionKeys.all, "supabase"] as const, + listProjects: (connectionId: string) => + [...supabaseConnectionKeys.all, "workspace-scopes", connectionId] as const +}; + +export const useSupabaseConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TSupabaseProject[], + unknown, + TSupabaseProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: supabaseConnectionKeys.listProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: TSupabaseProject[] }>( + `/api/v1/app-connections/supabase/${connectionId}/projects` + ); + + return data.projects; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/supabase/types.ts b/frontend/src/hooks/api/appConnections/supabase/types.ts new file mode 100644 index 000000000..fae012644 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/supabase/types.ts @@ -0,0 +1,4 @@ +export type TSupabaseProject = { + 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 8c2ec2fe1..b6b00a615 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -148,6 +148,10 @@ export type TChecklyConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Checkly; }; +export type TSupabaseConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Supabase; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -215,4 +219,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Zabbix]: TZabbixConnectionOption; [AppConnection.Railway]: TRailwayConnectionOption; [AppConnection.Checkly]: TChecklyConnectionOption; + [AppConnection.Supabase]: TSupabaseConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 31a97d2f3..e177fa2ab 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -28,6 +28,7 @@ import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TRailwayConnection } from "./railway-connection"; import { TRenderConnection } from "./render-connection"; +import { TSupabaseConnection } from "./supabase-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; @@ -99,7 +100,8 @@ export type TAppConnection = | TBitbucketConnection | TZabbixConnection | TRailwayConnection - | TChecklyConnection; + | TChecklyConnection + | TSupabaseConnection; export type TAvailableAppConnection = Pick; @@ -160,4 +162,5 @@ export type TAppConnectionMap = { [AppConnection.Zabbix]: TZabbixConnection; [AppConnection.Railway]: TRailwayConnection; [AppConnection.Checkly]: TChecklyConnection; + [AppConnection.Supabase]: TSupabaseConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/supabase-connection.ts b/frontend/src/hooks/api/appConnections/types/supabase-connection.ts new file mode 100644 index 000000000..014554535 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/supabase-connection.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum SupabaseConnectionMethod { + AccessToken = "access-token" +} + +export type TSupabaseConnection = TRootAppConnection & { + app: AppConnection.Supabase; + method: SupabaseConnectionMethod.AccessToken; + credentials: { + instanceUrl?: string; + accessKey: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 79f174d4c..dfba4bf4b 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -22,7 +22,7 @@ export enum SecretSync { GitLab = "gitlab", CloudflarePages = "cloudflare-pages", CloudflareWorkers = "cloudflare-workers", - + Supabase = "supabase", Zabbix = "zabbix", Railway = "railway", Checkly = "checkly" diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index e0c6de742..df02872ad 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -22,6 +22,7 @@ import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; +import { TSupabaseSync } from "./supabase"; import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; @@ -61,7 +62,8 @@ export type TSecretSync = | TCloudflareWorkersSync | TZabbixSync | TRailwaySync - | TChecklySync; + | TChecklySync + | TSupabaseSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/supabase.ts b/frontend/src/hooks/api/secretSyncs/types/supabase.ts new file mode 100644 index 000000000..e38e264c3 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/supabase.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +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 TSupabaseSync = TRootSecretSync & { + destination: SecretSync.Supabase; + destinationConfig: { + projectId: string; + projectName: string; + }; + connection: { + app: AppConnection.Supabase; + 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 b06d445b4..45a774780 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -37,6 +37,7 @@ import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; import { RailwayConnectionForm } from "./RailwayConnectionForm"; import { RenderConnectionForm } from "./RenderConnectionForm"; +import { SupabaseConnectionForm } from "./SupabaseConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; @@ -146,6 +147,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Checkly: return ; + case AppConnection.Supabase: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -248,6 +251,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Checkly: return ; + case AppConnection.Supabase: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/SupabaseConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/SupabaseConnectionForm.tsx new file mode 100644 index 000000000..af2ddfced --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/SupabaseConnectionForm.tsx @@ -0,0 +1,159 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + 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 { + SupabaseConnectionMethod, + TSupabaseConnection +} from "@app/hooks/api/appConnections/types/supabase-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TSupabaseConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Supabase) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(SupabaseConnectionMethod.AccessToken), + credentials: z.object({ + accessKey: z.string().trim().min(1, "Access Key required"), + instanceUrl: z.string().url().optional() + }) + }) +]); + +type FormData = z.infer; + +export const SupabaseConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Supabase, + method: SupabaseConnectionMethod.AccessToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + onChange(e.target.value)} + placeholder="https://api.supabase.com" + /> + + )} + /> + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 54ee79fe7..0a41b2c4b 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -21,6 +21,7 @@ import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol"; import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; +import { SupabaseSyncDestinationCol } from "./SupabaseSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; @@ -85,6 +86,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Checkly: return ; + case SecretSync.Supabase: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SupabaseSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SupabaseSyncDestinationCol.tsx new file mode 100644 index 000000000..9558c3b39 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SupabaseSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TSupabaseSync } from "@app/hooks/api/secretSyncs/types/supabase"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TSupabaseSync; +}; + +export const SupabaseSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index bc4bd28f5..a4a7e5480 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -170,6 +170,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.accountName; secondaryText = "Checkly Account"; break; + case SecretSync.Supabase: + primaryText = destinationConfig.projectName; + secondaryText = "Supabase Project"; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } 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 68e6f4325..e7230e815 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -32,6 +32,7 @@ import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSecti import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection"; import { RailwaySyncDestinationSection } from "./RailwaySyncDestinationSection"; import { RenderSyncDestinationSection } from "./RenderSyncDestinationSection"; +import { SupabaseSyncDestinationSection } from "./SupabaseSyncDestinationSection"; import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; @@ -130,6 +131,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Checkly: DestinationComponents = ; break; + case SecretSync.Supabase: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SupabaseSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SupabaseSyncDestinationSection.tsx new file mode 100644 index 000000000..baa771a03 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SupabaseSyncDestinationSection.tsx @@ -0,0 +1,12 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSupabaseSync } from "@app/hooks/api/secretSyncs/types/supabase"; + +type Props = { + secretSync: TSupabaseSync; +}; + +export const SupabaseSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return {destinationConfig.projectName}; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 456425f5c..3cc70ebcb 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -63,6 +63,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.CloudflareWorkers: case SecretSync.Zabbix: case SecretSync.Railway: + case SecretSync.Supabase: case SecretSync.Checkly: AdditionalSyncOptionsComponent = null; break;