diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 578ab469c..0a46597b8 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -410,7 +410,7 @@ export const samlConfigServiceFactory = ({ } await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted); + const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); const userEnc = await userDAL.findUserEncKeyByUserId(user.id); const providerAuthToken = crypto.jwt().sign( { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 657729b32..551b04def 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2283,6 +2283,13 @@ export const AppConnections = { }, RAILWAY: { apiToken: "The API token used to authenticate with Railway." + }, + 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." } } }; @@ -2489,6 +2496,13 @@ export const SecretSyncs = { environmentName: "The Railway environment to sync secrets to.", serviceId: "The Railway service that secrets should be synced to.", serviceName: "The Railway service that secrets should be synced to." + }, + 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 f692e700f..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 @@ -39,6 +39,10 @@ import { CamundaConnectionListItemSchema, SanitizedCamundaConnectionSchema } from "@app/services/app-connection/camunda"; +import { + ChecklyConnectionListItemSchema, + SanitizedChecklyConnectionSchema +} from "@app/services/app-connection/checkly"; import { CloudflareConnectionListItemSchema, SanitizedCloudflareConnectionSchema @@ -79,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 @@ -128,7 +136,9 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedCloudflareConnectionSchema.options, ...SanitizedBitbucketConnectionSchema.options, ...SanitizedZabbixConnectionSchema.options, - ...SanitizedRailwayConnectionSchema.options + ...SanitizedRailwayConnectionSchema.options, + ...SanitizedChecklyConnectionSchema.options, + ...SanitizedSupabaseConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -163,7 +173,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ CloudflareConnectionListItemSchema, BitbucketConnectionListItemSchema, ZabbixConnectionListItemSchema, - RailwayConnectionListItemSchema + RailwayConnectionListItemSchema, + ChecklyConnectionListItemSchema, + SupabaseConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts new file mode 100644 index 000000000..bbe3fbbfb --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateChecklyConnectionSchema, + SanitizedChecklyConnectionSchema, + UpdateChecklyConnectionSchema +} from "@app/services/app-connection/checkly"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerChecklyConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Checkly, + server, + sanitizedResponseSchema: SanitizedChecklyConnectionSchema, + createSchema: CreateChecklyConnectionSchema, + updateSchema: UpdateChecklyConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/accounts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + accounts: z + .object({ + name: z.string(), + id: z.string(), + runtimeId: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const accounts = await server.services.appConnection.checkly.listAccounts(connectionId, req.permission); + + return { accounts }; + } + }); +}; 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 524abc18d..287a406f6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -11,6 +11,7 @@ import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-r import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; +import { registerChecklyConnectionRouter } from "./checkly-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerFlyioConnectionRouter } from "./flyio-connection-router"; @@ -27,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"; @@ -68,5 +70,7 @@ 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/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index de9866c85..e529f300b 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -28,7 +28,17 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) .object({ authorization: z.string(), host: z.string(), - "x-date": z.string() + "x-date": z.string().optional(), + date: z.string().optional() + }) + .superRefine((val, ctx) => { + if (!val.date && !val["x-date"]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Either date or x-date must be provided", + path: ["headers", "date"] + }); + } }) .describe(OCI_AUTH.LOGIN.headers) }), diff --git a/backend/src/server/routes/v1/secret-sync-routers/checkly-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/checkly-sync-router.ts new file mode 100644 index 000000000..9e9408820 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/checkly-sync-router.ts @@ -0,0 +1,17 @@ +import { + ChecklySyncSchema, + CreateChecklySyncSchema, + UpdateChecklySyncSchema +} from "@app/services/secret-sync/checkly/checkly-sync-schemas"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerChecklySyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Checkly, + server, + responseSchema: ChecklySyncSchema, + createSchema: CreateChecklySyncSchema, + updateSchema: UpdateChecklySyncSchema + }); 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 4e1d185a8..8e8f696b7 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -8,6 +8,7 @@ import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configurati import { registerAzureDevOpsSyncRouter } from "./azure-devops-sync-router"; import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router"; +import { registerChecklySyncRouter } from "./checkly-sync-router"; import { registerCloudflarePagesSyncRouter } from "./cloudflare-pages-sync-router"; import { registerCloudflareWorkersSyncRouter } from "./cloudflare-workers-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; @@ -20,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"; @@ -52,7 +54,8 @@ 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/1password/1password-connection-fns.ts b/backend/src/services/app-connection/1password/1password-connection-fns.ts index d8a18576f..44f3757b2 100644 --- a/backend/src/services/app-connection/1password/1password-connection-fns.ts +++ b/backend/src/services/app-connection/1password/1password-connection-fns.ts @@ -31,12 +31,16 @@ export const validateOnePassConnectionCredentials = async (config: TOnePassConne const { apiToken } = config.credentials; try { - await request.get(`${instanceUrl}/v1/vaults`, { + const res = await request.get(`${instanceUrl}/v1/vaults`, { headers: { Authorization: `Bearer ${apiToken}`, Accept: "application/json" } }); + + if (!Array.isArray(res.data)) { + throw new AxiosError("Invalid response from 1Password API"); + } } catch (error: unknown) { if (error instanceof AxiosError) { throw new BadRequestError({ diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index b9c405654..233ce0ea8 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -30,7 +30,9 @@ export enum AppConnection { Cloudflare = "cloudflare", Zabbix = "zabbix", Railway = "railway", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + 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 c2290af11..54aac2001 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -57,6 +57,7 @@ import { validateBitbucketConnectionCredentials } from "./bitbucket"; import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda"; +import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly"; import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum"; import { getCloudflareConnectionListItem, @@ -95,6 +96,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, @@ -147,7 +153,9 @@ export const listAppConnectionOptions = () => { getCloudflareConnectionListItem(), getZabbixConnectionListItem(), getRailwayConnectionListItem(), - getBitbucketConnectionListItem() + getBitbucketConnectionListItem(), + getChecklyConnectionListItem(), + getSupabaseConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -231,7 +239,9 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); @@ -289,7 +299,10 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case LdapConnectionMethod.SimpleBind: return "Simple Bind"; 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}`); @@ -352,7 +365,9 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Cloudflare]: platformManagedCredentialsNotSupported, [AppConnection.Zabbix]: platformManagedCredentialsNotSupported, [AppConnection.Railway]: platformManagedCredentialsNotSupported, - [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported + [AppConnection.Bitbucket]: 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 4f274516c..8a85020d8 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -32,7 +32,9 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Cloudflare]: "Cloudflare", [AppConnection.Zabbix]: "Zabbix", [AppConnection.Railway]: "Railway", - [AppConnection.Bitbucket]: "Bitbucket" + [AppConnection.Bitbucket]: "Bitbucket", + [AppConnection.Checkly]: "Checkly", + [AppConnection.Supabase]: "Supabase" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -67,5 +69,7 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -257,6 +271,8 @@ export type TAppConnectionInput = { id: string } & ( | TBitbucketConnectionInput | TZabbixConnectionInput | TRailwayConnectionInput + | TChecklyConnectionInput + | TSupabaseConnectionInput ); export type TSqlConnectionInput = @@ -303,7 +319,9 @@ export type TAppConnectionConfig = | TCloudflareConnectionConfig | TBitbucketConnectionConfig | TZabbixConnectionConfig - | TRailwayConnectionConfig; + | TRailwayConnectionConfig + | TChecklyConnectionConfig + | TSupabaseConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -337,7 +355,9 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateCloudflareConnectionCredentialsSchema | TValidateBitbucketConnectionCredentialsSchema | TValidateZabbixConnectionCredentialsSchema - | TValidateRailwayConnectionCredentialsSchema; + | TValidateRailwayConnectionCredentialsSchema + | TValidateChecklyConnectionCredentialsSchema + | TValidateSupabaseConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-constants.ts b/backend/src/services/app-connection/checkly/checkly-connection-constants.ts new file mode 100644 index 000000000..c62f59c0a --- /dev/null +++ b/backend/src/services/app-connection/checkly/checkly-connection-constants.ts @@ -0,0 +1,3 @@ +export enum ChecklyConnectionMethod { + ApiKey = "api-key" +} diff --git a/backend/src/services/app-connection/checkly/checkly-connection-fns.ts b/backend/src/services/app-connection/checkly/checkly-connection-fns.ts new file mode 100644 index 000000000..96df54e85 --- /dev/null +++ b/backend/src/services/app-connection/checkly/checkly-connection-fns.ts @@ -0,0 +1,35 @@ +/* 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 { ChecklyConnectionMethod } from "./checkly-connection-constants"; +import { ChecklyPublicAPI } from "./checkly-connection-public-client"; +import { TChecklyConnectionConfig } from "./checkly-connection-types"; + +export const getChecklyConnectionListItem = () => { + return { + name: "Checkly" as const, + app: AppConnection.Checkly as const, + methods: Object.values(ChecklyConnectionMethod) + }; +}; + +export const validateChecklyConnectionCredentials = async (config: TChecklyConnectionConfig) => { + try { + await ChecklyPublicAPI.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 config.credentials; +}; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts new file mode 100644 index 000000000..4e5db231f --- /dev/null +++ b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts @@ -0,0 +1,186 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxiosError } from "axios"; + +import { createRequestClient } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { ChecklyConnectionMethod } from "./checkly-connection-constants"; +import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types"; + +export function getChecklyAuthHeaders( + connection: TChecklyConnectionConfig, + accountId?: string +): Record { + switch (connection.method) { + case ChecklyConnectionMethod.ApiKey: + return { + Authorization: `Bearer ${connection.credentials.apiKey}`, + ...(accountId && { "X-Checkly-Account": accountId }) + }; + default: + throw new Error(`Unsupported Checkly connection method`); + } +} + +export function getChecklyRatelimiter(response: AxiosResponse): { + maxAttempts: number; + isRatelimited: boolean; + wait: () => Promise; +} { + const wait = () => { + return new Promise((res) => { + setTimeout(res, 60 * 1000); // Wait for 60 seconds + }); + }; + + return { + isRatelimited: response.status === HttpStatusCode.TooManyRequests, + wait, + maxAttempts: 3 + }; +} + +class ChecklyPublicClient { + private client: AxiosInstance; + + constructor() { + this.client = createRequestClient({ + baseURL: IntegrationUrls.CHECKLY_API_URL, + headers: { + "Content-Type": "application/json" + } + }); + } + + async send( + connection: TChecklyConnectionConfig, + config: AxiosRequestConfig & { accountId?: string }, + retryAttempt = 0 + ): Promise { + const response = await this.client.request({ + ...config, + timeout: 1000 * 60, // 60 seconds timeout + validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests, + headers: getChecklyAuthHeaders(connection, config.accountId) + }); + const limiter = getChecklyRatelimiter(response); + + if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) { + await limiter.wait(); + return this.send(connection, config, retryAttempt + 1); + } + + return response.data; + } + + healthcheck(connection: TChecklyConnectionConfig) { + switch (connection.method) { + case ChecklyConnectionMethod.ApiKey: + return this.getChecklyAccounts(connection); + default: + throw new Error(`Unsupported Checkly connection method`); + } + } + + async getVariables(connection: TChecklyConnectionConfig, accountId: string, limit: number = 50, page: number = 1) { + const res = await this.send(connection, { + accountId, + method: "GET", + url: `/v1/variables`, + params: { + limit, + page + } + }); + + return res; + } + + async createVariable(connection: TChecklyConnectionConfig, accountId: string, variable: TChecklyVariable) { + const res = await this.send(connection, { + accountId, + method: "POST", + url: `/v1/variables`, + data: variable + }); + + return res; + } + + async updateVariable(connection: TChecklyConnectionConfig, accountId: string, variable: TChecklyVariable) { + const res = await this.send(connection, { + accountId, + method: "PUT", + url: `/v1/variables/${variable.key}`, + data: variable + }); + + return res; + } + + async getVariable(connection: TChecklyConnectionConfig, accountId: string, variable: Pick) { + try { + const res = await this.send(connection, { + accountId, + method: "GET", + url: `/v1/variables/${variable.key}` + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async upsertVariable(connection: TChecklyConnectionConfig, accountId: string, variable: TChecklyVariable) { + const res = await this.getVariable(connection, accountId, variable); + + if (!res) { + return this.createVariable(connection, accountId, variable); + } + + await this.updateVariable(connection, accountId, variable); + + return res; + } + + async deleteVariable( + connection: TChecklyConnectionConfig, + accountId: string, + variable: Pick + ) { + try { + const res = await this.send(connection, { + accountId, + method: "DELETE", + url: `/v1/variables/${variable.key}` + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async getChecklyAccounts(connection: TChecklyConnectionConfig) { + // This endpoint is in beta and might be subject to changes + // Refer: https://developers.checklyhq.com/reference/getv1accounts + const res = await this.send(connection, { + method: "GET", + url: `/v1/accounts` + }); + + return res; + } +} + +export const ChecklyPublicAPI = new ChecklyPublicClient(); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts b/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts new file mode 100644 index 000000000..174e5bce0 --- /dev/null +++ b/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts @@ -0,0 +1,62 @@ +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 { ChecklyConnectionMethod } from "./checkly-connection-constants"; + +export const ChecklyConnectionMethodSchema = z + .nativeEnum(ChecklyConnectionMethod) + .describe(AppConnections.CREATE(AppConnection.Checkly).method); + +export const ChecklyConnectionAccessTokenCredentialsSchema = z.object({ + apiKey: z.string().trim().min(1, "API Key required").max(255).describe(AppConnections.CREDENTIALS.CHECKLY.apiKey) +}); + +const BaseChecklyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Checkly) +}); + +export const ChecklyConnectionSchema = BaseChecklyConnectionSchema.extend({ + method: ChecklyConnectionMethodSchema, + credentials: ChecklyConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedChecklyConnectionSchema = z.discriminatedUnion("method", [ + BaseChecklyConnectionSchema.extend({ + method: ChecklyConnectionMethodSchema, + credentials: ChecklyConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateChecklyConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: ChecklyConnectionMethodSchema, + credentials: ChecklyConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Checkly).credentials + ) + }) +]); + +export const CreateChecklyConnectionSchema = ValidateChecklyConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Checkly) +); + +export const UpdateChecklyConnectionSchema = z + .object({ + credentials: ChecklyConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Checkly).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Checkly)); + +export const ChecklyConnectionListItemSchema = z.object({ + name: z.literal("Checkly"), + app: z.literal(AppConnection.Checkly), + methods: z.nativeEnum(ChecklyConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-service.ts b/backend/src/services/app-connection/checkly/checkly-connection-service.ts new file mode 100644 index 000000000..c3598320f --- /dev/null +++ b/backend/src/services/app-connection/checkly/checkly-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 { ChecklyPublicAPI } from "./checkly-connection-public-client"; +import { TChecklyConnection } from "./checkly-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const checklyConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listAccounts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Checkly, connectionId, actor); + try { + const accounts = await ChecklyPublicAPI.getChecklyAccounts(appConnection); + return accounts!; + } catch (error) { + logger.error(error, "Failed to list accounts on Checkly"); + return []; + } + }; + + return { + listAccounts + }; +}; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-types.ts b/backend/src/services/app-connection/checkly/checkly-connection-types.ts new file mode 100644 index 000000000..e8bb242ba --- /dev/null +++ b/backend/src/services/app-connection/checkly/checkly-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + ChecklyConnectionSchema, + CreateChecklyConnectionSchema, + ValidateChecklyConnectionCredentialsSchema +} from "./checkly-connection-schemas"; + +export type TChecklyConnection = z.infer; + +export type TChecklyConnectionInput = z.infer & { + app: AppConnection.Checkly; +}; + +export type TValidateChecklyConnectionCredentialsSchema = typeof ValidateChecklyConnectionCredentialsSchema; + +export type TChecklyConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TChecklyVariable = { + key: string; + value: string; + locked: boolean; + secret: boolean; +}; + +export type TChecklyAccount = { + id: string; + name: string; + runtimeId: string; +}; diff --git a/backend/src/services/app-connection/checkly/index.ts b/backend/src/services/app-connection/checkly/index.ts new file mode 100644 index 000000000..341413feb --- /dev/null +++ b/backend/src/services/app-connection/checkly/index.ts @@ -0,0 +1,4 @@ +export * from "./checkly-connection-constants"; +export * from "./checkly-connection-fns"; +export * from "./checkly-connection-schemas"; +export * from "./checkly-connection-types"; diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index d8c98e832..e4281625b 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -145,12 +145,20 @@ export const getGitHubEnvironments = async (appConnection: TGitHubConnection, ow }; type TokenRespData = { - access_token: string; + access_token?: string; scope: string; token_type: string; error?: string; }; +function isErrorResponse(data: TokenRespData): data is TokenRespData & { + error: string; + error_description: string; + error_uri: string; +} { + return "error" in data; +} + export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { const { credentials, method } = config; @@ -198,7 +206,17 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect "Accept-Encoding": "application/json" } }); + + if (isErrorResponse(tokenResp?.data)) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with an error: ${tokenResp.data.error} - ${tokenResp.data.error_description}` + }); + } } catch (e: unknown) { + if (e instanceof BadRequestError) { + throw e; + } + throw new BadRequestError({ message: `Unable to validate connection: verify credentials` }); @@ -211,6 +229,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect } if (method === GitHubConnectionMethod.App) { + if (!tokenResp.data.access_token) { + throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); + } + const installationsResp = await request.get<{ installations: { id: number; @@ -239,10 +261,6 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect } } - if (!tokenResp.data.access_token) { - throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); - } - switch (method) { case GitHubConnectionMethod.App: return { 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/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts index d5a45ce50..352675441 100644 --- a/backend/src/services/certificate-authority/certificate-authority-dal.ts +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -218,7 +218,7 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => { }; const findWithAssociatedCa = async ( - filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string }, + filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string; serialNumber?: string }, { offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt = {}, tx?: Knex ) => { diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 6668c806c..80201eab6 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1068,11 +1068,11 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "Invalid certificate chain" }); const parentCertObj = chainItems[1]; - const parentCertSubject = parentCertObj.subject; + const parentSerialNumber = parentCertObj.serialNumber; const [parentCa] = await certificateAuthorityDAL.findWithAssociatedCa({ [`${TableName.CertificateAuthority}.projectId` as "projectId"]: ca.projectId, - [`${TableName.InternalCertificateAuthority}.dn` as "dn"]: parentCertSubject + [`${TableName.InternalCertificateAuthority}.serialNumber` as "serialNumber"]: parentSerialNumber }); const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts index c7a131bde..8eb33a866 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts @@ -6,7 +6,8 @@ export type TLoginOciAuthDTO = { headers: { authorization: string; host: string; - "x-date": string; + "x-date"?: string; + date?: string; }; }; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index c68033911..6aa73465d 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -174,6 +174,7 @@ export const fnSecretsV2FromImports = async ({ skipMultilineEncoding?: boolean | null; secretPath: string; environment: string; + secretKey: string; }) => Promise; hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean; }) => { @@ -293,7 +294,8 @@ export const fnSecretsV2FromImports = async ({ value: decryptedSecret.secretValue, secretPath: processedImport.secretPath, environment: processedImport.environment, - skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding, + secretKey: decryptedSecret.secretKey }); // eslint-disable-next-line no-param-reassign processedImport.secrets[index].secretValue = expandedSecretValue || ""; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-constants.ts b/backend/src/services/secret-sync/checkly/checkly-sync-constants.ts new file mode 100644 index 000000000..9c21fef63 --- /dev/null +++ b/backend/src/services/secret-sync/checkly/checkly-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 CHECKLY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Checkly", + destination: SecretSync.Checkly, + connection: AppConnection.Checkly, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts new file mode 100644 index 000000000..eded130bb --- /dev/null +++ b/backend/src/services/secret-sync/checkly/checkly-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 { ChecklyPublicAPI } from "@app/services/app-connection/checkly/checkly-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 { TChecklySyncWithCredentials } from "./checkly-sync-types"; + +export const ChecklySyncFns = { + async getSecrets(secretSync: TChecklySyncWithCredentials) { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + async syncSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const config = secretSync.destinationConfig; + + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; + + // If value is empty, we skip the upsert - checkly does not allow empty values + if (entry.value.trim() === "") { + // Delete the secret from Checkly if its empty + if (!disableSecretDeletion) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + continue; // Skip empty values + } + + await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + key, + value: entry.value, + secret: true, + locked: true + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(checklySecrets)) { + try { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }, + + async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) { + const config = secretSync.destinationConfig; + + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(checklySecrets)) { + try { + if (secret in secretMap) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts new file mode 100644 index 000000000..04f444357 --- /dev/null +++ b/backend/src/services/secret-sync/checkly/checkly-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 ChecklySyncDestinationConfigSchema = z.object({ + accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), + accountName: z.string().min(1, "Account Name is required").max(255, "Account ID must be less than 255 characters") +}); + +const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const ChecklySyncSchema = BaseSecretSyncSchema(SecretSync.Checkly, ChecklySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Checkly), + destinationConfig: ChecklySyncDestinationConfigSchema +}); + +export const CreateChecklySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Checkly, + ChecklySyncOptionsConfig +).extend({ + destinationConfig: ChecklySyncDestinationConfigSchema +}); + +export const UpdateChecklySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Checkly, + ChecklySyncOptionsConfig +).extend({ + destinationConfig: ChecklySyncDestinationConfigSchema.optional() +}); + +export const ChecklySyncListItemSchema = z.object({ + name: z.literal("Checkly"), + connection: z.literal(AppConnection.Checkly), + destination: z.literal(SecretSync.Checkly), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-types.ts b/backend/src/services/secret-sync/checkly/checkly-sync-types.ts new file mode 100644 index 000000000..6434cec39 --- /dev/null +++ b/backend/src/services/secret-sync/checkly/checkly-sync-types.ts @@ -0,0 +1,23 @@ +import z from "zod"; + +import { TChecklyConnection, TChecklyVariable } from "@app/services/app-connection/checkly"; + +import { ChecklySyncListItemSchema, ChecklySyncSchema, CreateChecklySyncSchema } from "./checkly-sync-schemas"; + +export type TChecklySyncListItem = z.infer; + +export type TChecklySync = z.infer; + +export type TChecklySyncInput = z.infer; + +export type TChecklySyncWithCredentials = TChecklySync & { + connection: TChecklyConnection; +}; + +export type TChecklySecret = TChecklyVariable; + +export type TChecklyVariablesGraphResponse = { + data: { + variables: Record; + }; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 64a191a22..8d08e4d82 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -22,9 +22,10 @@ export enum SecretSync { GitLab = "gitlab", CloudflarePages = "cloudflare-pages", CloudflareWorkers = "cloudflare-workers", - + Supabase = "supabase", Zabbix = "zabbix", - Railway = "railway" + Railway = "railway", + Checkly = "checkly" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 7dc19d3e9..3daa9232f 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -29,6 +29,8 @@ import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFact import { AZURE_DEVOPS_SYNC_LIST_OPTION, azureDevOpsSyncFactory } from "./azure-devops"; import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; +import { CHECKLY_SYNC_LIST_OPTION } from "./checkly/checkly-sync-constants"; +import { ChecklySyncFns } from "./checkly/checkly-sync-fns"; import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants"; import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns"; import { CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, CloudflareWorkersSyncFns } from "./cloudflare-workers"; @@ -44,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"; @@ -74,9 +77,10 @@ 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.Railway]: RAILWAY_SYNC_LIST_OPTION, + [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -250,6 +254,10 @@ export const SecretSyncFns = { return ZabbixSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Railway: 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}` @@ -351,6 +359,12 @@ export const SecretSyncFns = { case SecretSync.Railway: secretMap = await RailwaySyncFns.getSecrets(secretSync); break; + 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}` @@ -434,6 +448,10 @@ export const SecretSyncFns = { return ZabbixSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Railway: 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 dd1d5b146..a8a017480 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -25,9 +25,10 @@ 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.Railway]: "Railway", + [SecretSync.Checkly]: "Checkly" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -54,9 +55,10 @@ 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.Railway]: AppConnection.Railway, + [SecretSync.Checkly]: AppConnection.Checkly }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -83,7 +85,8 @@ 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.Railway]: SecretSyncPlanType.Regular, + [SecretSync.Checkly]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 2acba91e5..8f5a2e806 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -231,7 +231,8 @@ export const secretSyncQueueFactory = ({ environment: environment.slug, secretPath: folder.path, skipMultilineEncoding: secret.skipMultilineEncoding, - value: secretValue + value: secretValue, + secretKey }); secretMap[secretKey] = { value: expandedSecretValue || "" }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 998328b85..2c8753d66 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -72,6 +72,12 @@ import { TAzureKeyVaultSyncListItem, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault"; +import { + TChecklySync, + TChecklySyncInput, + TChecklySyncListItem, + TChecklySyncWithCredentials +} from "./checkly/checkly-sync-types"; import { TCloudflarePagesSync, TCloudflarePagesSyncInput, @@ -112,6 +118,12 @@ import { TRenderSyncListItem, TRenderSyncWithCredentials } from "./render/render-sync-types"; +import { + TSupabaseSync, + TSupabaseSyncInput, + TSupabaseSyncListItem, + TSupabaseSyncWithCredentials +} from "./supabase/supabase-sync-types"; import { TTeamCitySync, TTeamCitySyncInput, @@ -152,7 +164,9 @@ export type TSecretSync = | TCloudflarePagesSync | TCloudflareWorkersSync | TZabbixSync - | TRailwaySync; + | TRailwaySync + | TChecklySync + | TSupabaseSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -179,7 +193,9 @@ export type TSecretSyncWithCredentials = | TCloudflarePagesSyncWithCredentials | TCloudflareWorkersSyncWithCredentials | TZabbixSyncWithCredentials - | TRailwaySyncWithCredentials; + | TRailwaySyncWithCredentials + | TChecklySyncWithCredentials + | TSupabaseSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -206,7 +222,9 @@ export type TSecretSyncInput = | TCloudflarePagesSyncInput | TCloudflareWorkersSyncInput | TZabbixSyncInput - | TRailwaySyncInput; + | TRailwaySyncInput + | TChecklySyncInput + | TSupabaseSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -233,7 +251,9 @@ export type TSecretSyncListItem = | TCloudflarePagesSyncListItem | TCloudflareWorkersSyncListItem | TZabbixSyncListItem - | TRailwaySyncListItem; + | TRailwaySyncListItem + | 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/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 01718d570..9f62b6d57 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -614,6 +614,7 @@ export const expandSecretReferencesFactory = ({ secretPath: string; environment: string; shouldStackTrace?: boolean; + secretKey: string; }) => { const stackTrace = { ...dto, key: "root", children: [] } as TSecretReferenceTraceNode; @@ -656,7 +657,7 @@ export const expandSecretReferencesFactory = ({ const referredValue = await fetchSecret(environment, secretPath, secretKey); if (!canExpandValue(environment, secretPath, secretKey, referredValue.tags)) throw new ForbiddenRequestError({ - message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to read value on.` + message: `You do not have permission to read secret '${secretKey}' in environment '${environment}' at path '${secretPath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.` }); const cacheKey = getCacheUniqueKey(environment, secretPath); @@ -675,7 +676,7 @@ export const expandSecretReferencesFactory = ({ const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey); if (!canExpandValue(secretReferenceEnvironment, secretReferencePath, secretReferenceKey, referedValue.tags)) throw new ForbiddenRequestError({ - message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to read value on.` + message: `You do not have permission to read secret '${secretReferenceKey}' in environment '${secretReferenceEnvironment}' at path '${secretReferencePath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.` }); const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath); @@ -692,6 +693,7 @@ export const expandSecretReferencesFactory = ({ secretPath: referencedSecretPath, environment: referencedSecretEnvironmentSlug, depth: depth + 1, + secretKey: referencedSecretKey, trace }; @@ -726,6 +728,7 @@ export const expandSecretReferencesFactory = ({ skipMultilineEncoding?: boolean | null; secretPath: string; environment: string; + secretKey: string; }) => { if (!inputSecret.value) return inputSecret.value; @@ -741,6 +744,7 @@ export const expandSecretReferencesFactory = ({ value?: string; secretPath: string; environment: string; + secretKey: string; }) => { const { stackTrace, expandedValue } = await recursivelyExpandSecret({ ...inputSecret, shouldStackTrace: true }); return { stackTrace, expandedValue }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 71180772f..cab41933f 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -1105,7 +1105,7 @@ export const secretV2BridgeServiceFactory = ({ if (shouldExpandSecretReferences) { const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath); - await Promise.allSettled( + const settledPromises = await Promise.allSettled( Object.keys(secretsGroupByPath).map((groupedPath) => Promise.allSettled( secretsGroupByPath[groupedPath].map(async (decryptedSecret, index) => { @@ -1113,7 +1113,8 @@ export const secretV2BridgeServiceFactory = ({ value: decryptedSecret.secretValue, secretPath: groupedPath, environment, - skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding, + secretKey: decryptedSecret.secretKey }); // eslint-disable-next-line no-param-reassign secretsGroupByPath[groupedPath][index].secretValue = expandedSecretValue || ""; @@ -1121,6 +1122,35 @@ export const secretV2BridgeServiceFactory = ({ ) ) ); + const errors: { path: string; error: string }[] = []; + + settledPromises.forEach((outerResult: PromiseSettledResult[]>, outerIndex) => { + const groupedPath = Object.keys(secretsGroupByPath)[outerIndex]; + + if (outerResult.status === "rejected") { + errors.push({ + path: groupedPath, + error: `Failed to process secret group: ${outerResult.reason}` + }); + } else { + // Check inner promise results + outerResult.value.forEach((innerResult: PromiseSettledResult) => { + if (innerResult.status === "rejected") { + const reason = innerResult.reason as ForbiddenRequestError; + errors.push({ + path: groupedPath, + error: reason.message + }); + } + }); + } + }); + if (errors.length > 0) { + throw new ForbiddenRequestError({ + message: "Failed to expand one or more secret references", + details: errors.map((err) => err.error) + }); + } } if (!includeImports) { @@ -1424,7 +1454,8 @@ export const secretV2BridgeServiceFactory = ({ environment, secretPath: path, value: secretValue, - skipMultilineEncoding: secret.skipMultilineEncoding + skipMultilineEncoding: secret.skipMultilineEncoding, + secretKey: secret.key }); secretValue = expandedSecretValue || ""; @@ -2722,7 +2753,8 @@ export const secretV2BridgeServiceFactory = ({ const { expandedValue, stackTrace } = await getExpandedSecretStackTrace({ environment, secretPath, - value: decryptedSecretValue + value: decryptedSecretValue, + secretKey: secretName }); return { tree: stackTrace, value: expandedValue }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 8fcaa6e34..b178aa8e1 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -426,7 +426,8 @@ export const secretQueueFactory = ({ environment: dto.environment, secretPath: dto.secretPath, skipMultilineEncoding: secret.skipMultilineEncoding, - value: secretValue + value: secretValue, + secretKey }); content[secretKey] = { value: expandedSecretValue || "" }; diff --git a/company/documentation/engineering/how-to-write-design-doc.mdx b/company/documentation/engineering/how-to-write-design-doc.mdx index 753f884b0..0c6128824 100644 --- a/company/documentation/engineering/how-to-write-design-doc.mdx +++ b/company/documentation/engineering/how-to-write-design-doc.mdx @@ -33,6 +33,7 @@ Every feature/problem is unique, but your design docs should generally include t - A high-level summary of the problem and proposed solution. Keep it brief (max 3 paragraphs). 3. **Context** - Explain the problem's background, why it's important to solve now, and any constraints (e.g., technical, sales, or timeline-related). What do we get out of solving this problem? (needed to close a deal, scale, performance, etc.). + - Consider whether this feature has notable sales implications (e.g., affects pricing, customer commitments, go-to-market strategy, or competitive positioning) that would require Sales team input and approval. 4. **Solution** - Provide a big-picture explanation of the solution, followed by detailed technical architecture. @@ -76,3 +77,11 @@ Before sharing your design docs with others, review your design doc as if you we - Ask a relevant engineer(s) to review your document. Their role is to identify blind spots, challenge assumptions, and ensure everything is clear. Once you and the reviewer are on the same page on the approach, update the document with any missing details they brought up. 4. **Team Review and Feedback** - Invite the relevant engineers to a design doc review meeting and give them 10-15 minutes to read through the document. After everyone has had a chance to review it, open the floor up for discussion. Address any feedback or concerns raised during this meeting. If significant points were overlooked during your initial planning, you may need to revisit the drawing board. Your goal is to think about the feature holistically and minimize the need for drastic changes to your design doc later on. +5. **Sales Approval (When Applicable)** + - If your design document has notable sales implications, get explicit approval from the Sales team before proceeding to implementation. This includes features that: + - Affect pricing models or billing structures + - Impact customer commitments or contractual obligations + - Change core product functionality that's actively being sold + - Introduce new capabilities that could affect competitive positioning + - Modify user experience in ways that could impact customer acquisition or retention + - Share the design document with the Sales team to ensure alignment between the proposed technical approach and sales strategy, pricing models, and market positioning. diff --git a/docs/api-reference/endpoints/app-connections/checkly/available.mdx b/docs/api-reference/endpoints/app-connections/checkly/available.mdx new file mode 100644 index 000000000..c07f1e11a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/checkly/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/checkly/create.mdx b/docs/api-reference/endpoints/app-connections/checkly/create.mdx new file mode 100644 index 000000000..33aa7940e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/checkly" +--- + + + Check out the configuration docs for [Checkly Connections](/integrations/app-connections/checkly) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/checkly/delete.mdx b/docs/api-reference/endpoints/app-connections/checkly/delete.mdx new file mode 100644 index 000000000..31812e054 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/checkly/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/checkly/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/checkly/get-by-id.mdx new file mode 100644 index 000000000..f700275b3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/checkly/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/checkly/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/checkly/get-by-name.mdx new file mode 100644 index 000000000..15827c32e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/checkly/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/checkly/list.mdx b/docs/api-reference/endpoints/app-connections/checkly/list.mdx new file mode 100644 index 000000000..a86b6259a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/checkly" +--- diff --git a/docs/api-reference/endpoints/app-connections/checkly/update.mdx b/docs/api-reference/endpoints/app-connections/checkly/update.mdx new file mode 100644 index 000000000..dcf03c8ef --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/checkly/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/checkly/{connectionId}" +--- + + + Check out the configuration docs for [Checkly Connections](/integrations/app-connections/checkly) to learn how to obtain the required credentials. + 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/checkly/create.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/create.mdx new file mode 100644 index 000000000..a0b638568 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/checkly" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/delete.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/delete.mdx new file mode 100644 index 000000000..4c4ee0b00 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/checkly/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/get-by-id.mdx new file mode 100644 index 000000000..e61a942c9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/checkly/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/get-by-name.mdx new file mode 100644 index 000000000..ff41b9629 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/checkly/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/list.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/list.mdx new file mode 100644 index 000000000..cb0a57794 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/checkly" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/remove-secrets.mdx new file mode 100644 index 000000000..666d2657b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/checkly/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/sync-secrets.mdx new file mode 100644 index 000000000..7204f528a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/checkly/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/checkly/update.mdx b/docs/api-reference/endpoints/secret-syncs/checkly/update.mdx new file mode 100644 index 000000000..a5aa9b693 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/checkly/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/checkly/{syncId}" +--- 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/api-reference/endpoints/tls-cert-auth/login.mdx b/docs/api-reference/endpoints/tls-cert-auth/login.mdx index 0069ef1b7..b93f4c40a 100644 --- a/docs/api-reference/endpoints/tls-cert-auth/login.mdx +++ b/docs/api-reference/endpoints/tls-cert-auth/login.mdx @@ -2,3 +2,8 @@ title: "Login" openapi: "POST /api/v1/auth/tls-cert-auth/login" --- + + + Infisical US/EU and dedicated instances are deployed with AWS ALB. TLS Certificate Auth must flow through our ALB mTLS pass-through in order to authenticate. + When you are authenticating with TLS Certificate Auth, you must use the port `8443` instead of the default `443`. Example: `https://app.infisical.com:8443/api/v1/auth/tls-cert-auth/login` + \ No newline at end of file diff --git a/docs/cli/commands/export.mdx b/docs/cli/commands/export.mdx index 6711903ec..b1dcb5d32 100644 --- a/docs/cli/commands/export.mdx +++ b/docs/cli/commands/export.mdx @@ -9,7 +9,7 @@ infisical export [options] ## Description -Export environment variables from the platform into a file format. +Export environment variables from the platform into a file format. By default, output is sent to stdout (standard output), but you can use the `--output-file` flag to save directly to a file. ## Subcommands & flags @@ -21,18 +21,19 @@ $ infisical export # Export variables to a .env file infisical export > .env +infisical export --output-file=./.env # Export variables to a .env file (with export keyword) infisical export --format=dotenv-export > .env - -# Export variables to a CSV file -infisical export --format=csv > secrets.csv +infisical export --format=dotenv-export --output-file=./.env # Export variables to a JSON file infisical export --format=json > secrets.json +infisical export --format=json --output-file=./secrets.json # Export variables to a YAML file infisical export --format=yaml > secrets.yaml +infisical export --format=yaml --output-file=./secrets.yaml # Render secrets using a custom template file infisical export --template= @@ -73,6 +74,34 @@ infisical export --template= ### flags + + The path to write the output file to. Can be a full file path, directory, or filename. + + ```bash + # Export to specific file + infisical export --format=json --output-file=./secrets.json + + # Export to directory (uses default filename based on format) + infisical export --format=yaml --output-file=./ + ``` + + **When `--output-file` is specified:** + - Secrets are saved directly to the specified file + - A success message is displayed showing the file path + - For directories: adds default filename `secrets.{format}` (e.g., `secrets.json`, `secrets.yaml`) + - For dotenv formats in directories: uses `.env` as the filename + + **When `--output-file` is NOT specified (default behavior):** + - Output is sent to stdout (standard output) + - You can use shell redirection like `infisical export > secrets.json` + - Maintains backwards compatibility with existing scripts + + + If you're using shell redirection and your token expires, re-authentication will fail because the prompt can't display properly due to the redirection. + + + + The `--template` flag specifies the path to the template file used for rendering secrets. When using templates, you can omit the other format flags. @@ -94,6 +123,7 @@ infisical export --template= ``` + Used to set the environment that secrets are pulled from. @@ -162,7 +192,7 @@ infisical export --template= ```bash # Example - infisical run --tags=tag1,tag2,tag3 -- npm run dev + infisical export --tags=tag1,tag2,tag3 --env=dev ``` Note: you must reference the tag by its slug name not its fully qualified name. Go to project settings to view all tag slugs. @@ -171,4 +201,4 @@ infisical export --template= - + \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 62149ca99..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" @@ -472,6 +466,7 @@ "integrations/app-connections/azure-key-vault", "integrations/app-connections/bitbucket", "integrations/app-connections/camunda", + "integrations/app-connections/checkly", "integrations/app-connections/cloudflare", "integrations/app-connections/databricks", "integrations/app-connections/flyio", @@ -490,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", @@ -513,6 +509,7 @@ "integrations/secret-syncs/azure-devops", "integrations/secret-syncs/azure-key-vault", "integrations/secret-syncs/camunda", + "integrations/secret-syncs/checkly", "integrations/secret-syncs/cloudflare-pages", "integrations/secret-syncs/cloudflare-workers", "integrations/secret-syncs/databricks", @@ -526,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", @@ -553,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", @@ -668,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"] } ] }, @@ -996,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", @@ -1328,6 +1317,17 @@ "api-reference/endpoints/app-connections/camunda/delete" ] }, + { + "group": "Checkly", + "pages": [ + "api-reference/endpoints/app-connections/checkly/list", + "api-reference/endpoints/app-connections/checkly/get-by-id", + "api-reference/endpoints/app-connections/checkly/get-by-name", + "api-reference/endpoints/app-connections/checkly/create", + "api-reference/endpoints/app-connections/checkly/update", + "api-reference/endpoints/app-connections/checkly/delete" + ] + }, { "group": "Cloudflare", "pages": [ @@ -1544,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": [ @@ -1708,6 +1720,19 @@ "api-reference/endpoints/secret-syncs/camunda/remove-secrets" ] }, + { + "group": "Checkly", + "pages": [ + "api-reference/endpoints/secret-syncs/checkly/list", + "api-reference/endpoints/secret-syncs/checkly/get-by-id", + "api-reference/endpoints/secret-syncs/checkly/get-by-name", + "api-reference/endpoints/secret-syncs/checkly/create", + "api-reference/endpoints/secret-syncs/checkly/update", + "api-reference/endpoints/secret-syncs/checkly/delete", + "api-reference/endpoints/secret-syncs/checkly/sync-secrets", + "api-reference/endpoints/secret-syncs/checkly/remove-secrets" + ] + }, { "group": "Cloudflare Pages", "pages": [ @@ -1882,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/documentation/platform/identities/tls-cert-auth.mdx b/docs/documentation/platform/identities/tls-cert-auth.mdx index e11d0c06e..0ecb60b99 100644 --- a/docs/documentation/platform/identities/tls-cert-auth.mdx +++ b/docs/documentation/platform/identities/tls-cert-auth.mdx @@ -42,10 +42,14 @@ To be more specific: Most of the time, the Infisical server will be behind a load balancer or proxy. To propagate the TLS certificate from the load balancer to the instance, you can configure the TLS to send the client certificate as a header - that is set as an [environment - variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key). + that is set as an [environment variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key). + + Infisical US/EU and dedicated instances are deployed with AWS ALB. TLS Certificate Auth must flow through our ALB mTLS pass-through in order to authenticate. + When you are authenticating with TLS Certificate Auth, you must use the port `8443` instead of the default `443`. Example: `https://app.infisical.com:8443/api/v1/auth/tls-cert-auth/login` + + ## Guide In the following steps, we explore how to create and use identities for your workloads and applications on TLS Certificate to @@ -123,7 +127,7 @@ try { const clientCertificate = fs.readFileSync("client-cert.pem", "utf8"); const clientKeyCertificate = fs.readFileSync("client-key.pem", "utf8"); - const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const infisicalUrl = "https://app.infisical.com:8443"; // or your self-hosted Infisical URL const identityId = ""; // Create HTTPS agent with client certificate and key diff --git a/docs/images/app-connections/checkly/checkly-app-connection-api-keys.png b/docs/images/app-connections/checkly/checkly-app-connection-api-keys.png new file mode 100644 index 000000000..c03dcc806 Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-api-keys.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-create-api-key.png b/docs/images/app-connections/checkly/checkly-app-connection-create-api-key.png new file mode 100644 index 000000000..56fe4ad36 Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-create-api-key.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-create-form.png b/docs/images/app-connections/checkly/checkly-app-connection-create-form.png new file mode 100644 index 000000000..e743b8079 Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-create-form.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-form.png b/docs/images/app-connections/checkly/checkly-app-connection-form.png new file mode 100644 index 000000000..818138c56 Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-form.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-generated.png b/docs/images/app-connections/checkly/checkly-app-connection-generated.png new file mode 100644 index 000000000..cb1ced15f Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-generated.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-key-generated.png b/docs/images/app-connections/checkly/checkly-app-connection-key-generated.png new file mode 100644 index 000000000..7fbb66502 Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-key-generated.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-option.png b/docs/images/app-connections/checkly/checkly-app-connection-option.png new file mode 100644 index 000000000..8189b1826 Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-option.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-profile.png b/docs/images/app-connections/checkly/checkly-app-connection-profile.png new file mode 100644 index 000000000..b9974683f Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-profile.png differ diff --git a/docs/images/app-connections/checkly/checkly-app-connection-user-settings.png b/docs/images/app-connections/checkly/checkly-app-connection-user-settings.png new file mode 100644 index 000000000..a918e62cf Binary files /dev/null and b/docs/images/app-connections/checkly/checkly-app-connection-user-settings.png differ diff --git a/docs/images/app-connections/railway/SCR-20250712-pjrc.png b/docs/images/app-connections/railway/SCR-20250712-pjrc.png new file mode 100644 index 000000000..7c39c0d5b Binary files /dev/null and b/docs/images/app-connections/railway/SCR-20250712-pjrc.png differ 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/platform/pki/est/template-enroll-hover.png b/docs/images/platform/pki/est/template-enroll-hover.png index cc0f6f658..7bec8e3f6 100644 Binary files a/docs/images/platform/pki/est/template-enroll-hover.png and b/docs/images/platform/pki/est/template-enroll-hover.png differ diff --git a/docs/images/platform/pki/est/template-enrollment-est-label.png b/docs/images/platform/pki/est/template-enrollment-est-label.png index 8a13beec9..4ad7bbeb1 100644 Binary files a/docs/images/platform/pki/est/template-enrollment-est-label.png and b/docs/images/platform/pki/est/template-enrollment-est-label.png differ diff --git a/docs/images/platform/pki/est/template-enrollment-modal.png b/docs/images/platform/pki/est/template-enrollment-modal.png index 60ed273d7..4ce08cfe0 100644 Binary files a/docs/images/platform/pki/est/template-enrollment-modal.png and b/docs/images/platform/pki/est/template-enrollment-modal.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-created.png b/docs/images/secret-syncs/checkly/checkly-sync-created.png new file mode 100644 index 000000000..70ff19b20 Binary files /dev/null and b/docs/images/secret-syncs/checkly/checkly-sync-created.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-destination.png b/docs/images/secret-syncs/checkly/checkly-sync-destination.png new file mode 100644 index 000000000..bb3df8d94 Binary files /dev/null and b/docs/images/secret-syncs/checkly/checkly-sync-destination.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-details.png b/docs/images/secret-syncs/checkly/checkly-sync-details.png new file mode 100644 index 000000000..536ca8ff9 Binary files /dev/null and b/docs/images/secret-syncs/checkly/checkly-sync-details.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-options.png b/docs/images/secret-syncs/checkly/checkly-sync-options.png new file mode 100644 index 000000000..24caa2a9b Binary files /dev/null and b/docs/images/secret-syncs/checkly/checkly-sync-options.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-review.png b/docs/images/secret-syncs/checkly/checkly-sync-review.png new file mode 100644 index 000000000..29f50a0a6 Binary files /dev/null and b/docs/images/secret-syncs/checkly/checkly-sync-review.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-source.png b/docs/images/secret-syncs/checkly/checkly-sync-source.png new file mode 100644 index 000000000..0d2a83f19 Binary files /dev/null and b/docs/images/secret-syncs/checkly/checkly-sync-source.png differ diff --git a/docs/images/secret-syncs/checkly/select-option.png b/docs/images/secret-syncs/checkly/select-option.png new file mode 100644 index 000000000..7c39c0d5b Binary files /dev/null and b/docs/images/secret-syncs/checkly/select-option.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/checkly.mdx b/docs/integrations/app-connections/checkly.mdx new file mode 100644 index 000000000..38234744d --- /dev/null +++ b/docs/integrations/app-connections/checkly.mdx @@ -0,0 +1,106 @@ +--- +title: "Checkly Connection" +description: "Learn how to configure a Checkly Connection for Infisical." +--- + +Infisical supports the use of [API Keys](https://app.checklyhq.com/settings/user/api-keys) to connect with Checkly. + + Checkly requires the account user to have Read/Write or Admin permissions + + +## Create a Checkly API Token + + + + ![Dashboard Page](/images/app-connections/checkly/checkly-app-connection-profile.png) + + + ![User Settings Page](/images/app-connections/checkly/checkly-app-connection-api-keys.png) + + + ![Api Keys Page](/images/app-connections/checkly/checkly-app-connection-create-api-key.png) + + + Provide a descriptive name for the token. + + ![Enter Name](/images/app-connections/checkly/checkly-app-connection-create-form.png) + + + + ![Create Token](/images/app-connections/checkly/checkly-app-connection-key-generated.png) + + + +## Create a Checkly 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 **Checkly Connection** from the list of integrations. + + ![Select Checkly Connection](/images/app-connections/checkly/checkly-app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Key value from the previous step + + ![Checkly Connection Modal](/images/app-connections/checkly/checkly-app-connection-form.png) + + + After submitting the form, your **Checkly Connection** will be successfully created and ready to use with your Infisical projects. + + ![Checkly Connection Created](/images/app-connections/checkly/checkly-app-connection-generated.png) + + + + + + + To create a Checkly Connection via API, send a request to the [Create Checkly Connection](/api-reference/endpoints/app-connections/checkly/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/checkly \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-checkly-connection", + "method": "api-key", + "credentials": { + "apiKey": "[API KEY]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-checkly-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": "checkly", + "method": "api-key", + "credentials": {} + } + } + ``` + + + diff --git a/docs/integrations/app-connections/postgres.mdx b/docs/integrations/app-connections/postgres.mdx index 860e9ee3c..239608905 100644 --- a/docs/integrations/app-connections/postgres.mdx +++ b/docs/integrations/app-connections/postgres.mdx @@ -30,6 +30,14 @@ Infisical supports connecting to PostgreSQL using a database role. -- enable permissions to alter login credentials ALTER ROLE infisical_role WITH CREATEROLE; ``` + + In some configurations, the role performing the rotation must be explicitly granted access to manage each user. To do this, grant the user's role to the rotation role with: + ```SQL + -- grant each user role to admin user for password rotation + GRANT TO WITH ADMIN OPTION; + ``` + Replace `` with each specific username whose credentials will be rotated, and `` with the role that will perform the rotation. + 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/checkly.mdx b/docs/integrations/secret-syncs/checkly.mdx new file mode 100644 index 000000000..4599fe634 --- /dev/null +++ b/docs/integrations/secret-syncs/checkly.mdx @@ -0,0 +1,163 @@ +--- +title: "Checkly Sync" +description: "Learn how to configure a Checkly Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Checkly Connection](/integrations/app-connections/checkly) + + + + + + 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 Checkly](/images/secret-syncs/checkly/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/checkly/checkly-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/checkly/checkly-sync-destination.png) + + - **Checkly Connection**: The Checkly Connection to authenticate with. + - **Account**: The Checkly account to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/checkly/checkly-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. + + Checkly 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 Checkly Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/checkly/checkly-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Checkly Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/checkly/checkly-sync-review.png) + + + If enabled, your Checkly Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/checkly/checkly-sync-created.png) + + + + + To create a **Checkly Sync**, make an API request to the [Create Checkly Sync](/api-reference/endpoints/secret-syncs/checkly/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/checkly \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-checkly-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": { + "accountId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "accountName": "Example Company" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-checkly-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": "checkly", + "name": "my-checkly-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": "checkly", + "destinationConfig": { + "accountId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "accountName": "Example Company", + } + } + } + ``` + + + 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/SecretSyncSelect.tsx b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx index 6940f0ab3..fed312ace 100644 --- a/frontend/src/components/secret-syncs/SecretSyncSelect.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx @@ -63,6 +63,7 @@ export const SecretSyncSelect = ({ onSelect }: Props) => { const { image, name } = SECRET_SYNC_MAP[destination]; return ( + + + + + + + ); +}; 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/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx index 86b7d7633..51a83c6e0 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -79,10 +79,14 @@ type TSecretPermissionForm = z.infer; export const SpecificPrivilegeSecretForm = ({ privilege, policies, - onClose + onClose, + selectedActions = [], + secretPath: initialSecretPath }: { privilege?: TProjectUserPrivilege; policies?: TAccessApprovalPolicy[]; + selectedActions?: ProjectPermissionActions[]; + secretPath?: string; onClose?: () => void; }) => { const { currentWorkspace } = useWorkspace(); @@ -126,10 +130,11 @@ export const SpecificPrivilegeSecretForm = ({ } : { environmentSlug: currentWorkspace.environments?.[0]?.slug, - read: false, - edit: false, - create: false, - delete: false, + secretPath: initialSecretPath, + read: selectedActions.includes(ProjectPermissionActions.Read), + edit: selectedActions.includes(ProjectPermissionActions.Edit), + create: selectedActions.includes(ProjectPermissionActions.Create), + delete: selectedActions.includes(ProjectPermissionActions.Delete), temporaryAccess: { isTemporary: false } @@ -281,6 +286,8 @@ export const SpecificPrivilegeSecretForm = ({ isDisabled={isMemberEditDisabled} className="w-full bg-mineshaft-900 hover:bg-mineshaft-800" onValueChange={(e) => onChange(e)} + position="popper" + dropdownContainerClassName="max-w-none" > {currentWorkspace?.environments?.map(({ slug, id, name }) => ( @@ -309,6 +316,8 @@ export const SpecificPrivilegeSecretForm = ({ className="w-full hover:bg-mineshaft-800" placeholder="Select a secret path" onValueChange={(e) => field.onChange(e)} + position="popper" + dropdownContainerClassName="max-w-none" > {selectablePaths.map((path) => ( @@ -636,6 +645,7 @@ export const SpecificPrivilegeSecretForm = ({ {!!policies && ( )} diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ChecklySyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ChecklySyncDestinationCol.tsx new file mode 100644 index 000000000..4a21c4b0b --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ChecklySyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TChecklySync } from "@app/hooks/api/secretSyncs/types/checkly-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TChecklySync; +}; + +export const ChecklySyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; 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 5a4d451fc..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 @@ -7,6 +7,7 @@ import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfiguration import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol"; import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol"; import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol"; +import { ChecklySyncDestinationCol } from "./ChecklySyncDestinationCol"; import { CloudflarePagesSyncDestinationCol } from "./CloudflarePagesSyncDestinationCol"; import { CloudflareWorkersSyncDestinationCol } from "./CloudflareWorkersSyncDestinationCol"; import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; @@ -20,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"; @@ -82,6 +84,10 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Railway: 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 2e892a91c..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 @@ -163,8 +163,16 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { } break; case SecretSync.Railway: - primaryText = "Railway Project"; - secondaryText = destinationConfig.projectName; + primaryText = destinationConfig.projectName; + secondaryText = "Railway Project"; + break; + case SecretSync.Checkly: + 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/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx index b337f4e69..0da633155 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx @@ -1,15 +1,19 @@ import { Modal, ModalContent } from "@app/components/v2"; +import { ProjectPermissionActions } from "@app/context"; import { TAccessApprovalPolicy } from "@app/hooks/api/types"; import { SpecificPrivilegeSecretForm } from "@app/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection"; export const RequestAccessModal = ({ isOpen, onOpenChange, - policies + policies, + ...props }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; policies: TAccessApprovalPolicy[]; + selectedActions?: ProjectPermissionActions[]; + secretPath?: string; }) => { return ( @@ -18,7 +22,11 @@ export const RequestAccessModal = ({ title="Request Access" subTitle="Request access to any secrets and resources based on the predefined policies." > - onOpenChange(false)} policies={policies} /> + onOpenChange(false)} + policies={policies} + {...props} + /> ); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 360ab6e62..46b611328 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -6,16 +6,19 @@ import { faCheckCircle, faChevronDown, faCodeBranch, + faCodeMerge, faMagnifyingGlass, - faSearch + faSearch, + faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useSearch } from "@tanstack/react-router"; -import { formatDistance } from "date-fns"; +import { format, formatDistance } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; import { + Badge, Button, DropdownMenu, DropdownMenuContent, @@ -25,7 +28,8 @@ import { EmptyState, Input, Pagination, - Skeleton + Skeleton, + Tooltip } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { @@ -308,7 +312,9 @@ export const SecretApprovalRequest = () => { createdAt, reviewers, status, - committerUser + committerUser, + hasMerged, + updatedAt } = secretApproval; const isReviewed = reviewers.some( ({ status: reviewStatus, userId }) => @@ -317,7 +323,7 @@ export const SecretApprovalRequest = () => { return (
setSelectedApprovalId(secretApproval.id)} @@ -325,29 +331,46 @@ export const SecretApprovalRequest = () => { if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); }} > -
- - {secretApproval.isReplicated - ? `${commits.length} secret pending import` - : generateCommitText(commits)} - #{secretApproval.slug} +
+
+ + {secretApproval.isReplicated + ? `${commits.length} secret pending import` + : generateCommitText(commits)} + #{secretApproval.slug} +
+ + Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} + {committerUser ? ( + <> + {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( + {committerUser?.email}) + + ) : ( + Deleted User + )} + {!isReviewed && status === "open" && " - Review required"} +
- - Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} - {committerUser ? ( - <> - {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( - {committerUser?.email}) - - ) : ( - Deleted User - )} - {!isReviewed && status === "open" && " - Review required"} - + {status === "close" && ( + +
+ + + {hasMerged ? "Merged" : "Rejected"} + +
+
+ )}
); })} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 0f1c194d2..1c95a202f 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; -import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; +import { faArrowDown, faArrowUp, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; @@ -10,10 +10,12 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { PermissionDeniedBanner } from "@app/components/permissions"; import { + Button, Checkbox, ContentLoader, Modal, ModalContent, + PageHeader, Pagination, Tooltip } from "@app/components/v2"; @@ -46,7 +48,9 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; +import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal"; import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; @@ -114,7 +118,10 @@ const Page = () => { const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); - const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const); + const { popUp, handlePopUpClose, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "snapshots", + "requestAccess" + ] as const); // env slug const workspaceId = currentWorkspace?.id || ""; @@ -132,6 +139,26 @@ const Page = () => { } ); + const canEditSecrets = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ); + + const canDeleteSecrets = permission.can( + ProjectPermissionSecretActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ); + const canReadSecretValue = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.ReadValue, @@ -257,6 +284,8 @@ const Page = () => { permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" ); + const { pathPolicies, hasPathPolicies } = usePathAccessPolicies({ secretPath, environment }); + const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ workspaceId, environment, @@ -476,8 +505,55 @@ const Page = () => { setFilter(defaultFilterState); setDebouncedSearchFilter(""); }; + return (
+ env.slug === environment)?.name ?? environment + } + description={ +

+ Inject your secrets using + + Infisical CLI + + , + + Infisical API + + , + + Infisical SDKs + + , and + + more + + . +

+ } + /> {!isRollbackMode ? ( <> @@ -500,8 +576,15 @@ const Page = () => { importedBy={importedBy} usedBySecretSyncs={usedBySecretSyncs} isPITEnabled={isPITEnabled} + hasPathPolicies={hasPathPolicies} + onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)} /> -
+
{isNotEmpty && (
{
Value
)} + {hasPathPolicies && + // eslint-disable-next-line no-nested-ternary + (!canReadSecret ? ( +
+
+ + You do not have permission to read secrets in this folder +
+ +
+ ) : !canEditSecrets || !canDeleteSecrets ? ( +
+
+ + + You do not have permission to {!canEditSecrets ? "edit" : ""} + {!canEditSecrets && !canDeleteSecrets ? " or " : ""} + {!canDeleteSecrets ? "delete" : ""} secrets in this folder + +
+ +
+ ) : null)} + {canReadSecretImports && Boolean(imports?.length) && ( { /> + {!!pathPolicies && ( + { + handlePopUpClose("requestAccess"); + }} + selectedActions={popUp.requestAccess.data} + secretPath={pathPolicies?.[0]?.secretPath} + /> + )} void; + hasPathPolicies: boolean; }; export const ActionBar = ({ @@ -147,7 +152,9 @@ export const ActionBar = ({ protectedBranchPolicyName, importedBy, isPITEnabled = false, - usedBySecretSyncs + usedBySecretSyncs, + onRequestAccess, + hasPathPolicies }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", @@ -159,7 +166,8 @@ export const ActionBar = ({ "misc", "upgradePlan", "replicateFolder", - "confirmUpload" + "confirmUpload", + "requestAccess" ] as const); const isProtectedBranch = Boolean(protectedBranchPolicyName); const { subscription } = useSubscription(); @@ -180,6 +188,7 @@ export const ActionBar = ({ const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); const handleFolderCreate = async (folderName: string, description: string | null) => { try { @@ -807,27 +816,50 @@ export const ActionBar = ({
- - {(isAllowed) => ( - - )} - + {hasPathPolicies ? ( + + ) : ( + + {(isAllowed) => ( + + )} + + )} handlePopUpToggle("misc", isOpen)} @@ -1166,6 +1198,27 @@ export const ActionBar = ({ )} + handlePopUpToggle("requestAccess", open)} + > + +

You do not have permission to perform this action.

+

Request access to perform this action in this folder.

+
+ + + + + + +
+
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index b0f61dc89..92df89cae 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -262,7 +262,7 @@ export const SecretDropzone = ({ className={twMerge( "relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 px-2 py-4 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100", isDragActive && "opacity-100", - !isSmaller && "mx-auto w-full max-w-3xl flex-col space-y-4 py-20", + !isSmaller && "mx-auto mt-40 w-full max-w-3xl flex-col space-y-4 py-20", isLoading && "bg-bunker-800" )} > diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx new file mode 100644 index 000000000..4920bb16b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/ChecklySyncDestinationSection.tsx @@ -0,0 +1,12 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TChecklySync } from "@app/hooks/api/secretSyncs/types/checkly-sync"; + +type Props = { + secretSync: TChecklySync; +}; + +export const ChecklySyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return {destinationConfig.accountName}; +}; 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 71755c7e9..e7230e815 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -18,6 +18,7 @@ import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigura import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationSection"; import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; +import { ChecklySyncDestinationSection } from "./ChecklySyncDestinationSection"; import { CloudflarePagesSyncDestinationSection } from "./CloudflarePagesSyncDestinationSection"; import { CloudflareWorkersSyncDestinationSection } from "./CloudflareWorkersSyncDestinationSection"; import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; @@ -31,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"; @@ -126,6 +128,12 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Railway: DestinationComponents = ; break; + 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 60710e3ef..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,8 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.CloudflareWorkers: case SecretSync.Zabbix: case SecretSync.Railway: + case SecretSync.Supabase: + case SecretSync.Checkly: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsSection.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsSection.tsx index 9aaf719b9..82ea049f9 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsSection.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsSection.tsx @@ -1,7 +1,7 @@ import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Spinner } from "@app/components/v2"; +import { ContentLoader } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useListSecretScanningFindings } from "@app/hooks/api/secretScanningV2"; @@ -17,12 +17,7 @@ export const SecretScanningFindingsSection = () => { } ); - if (isFindingsPending) - return ( -
- -
- ); + if (isFindingsPending) return ; return (
diff --git a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx index ba061dd15..333479984 100644 --- a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx @@ -1,16 +1,54 @@ +import { useEffect } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSub, useSubscription } from "@app/context"; import { ProjectPermissionSecretScanningConfigActions } from "@app/context/ProjectPermissionContext/types"; +import { usePopUp } from "@app/hooks"; import { ProjectScanningConfigTab } from "./components/ProjectScanningConfigTab"; export const SettingsPage = () => { const { t } = useTranslation(); + const { subscription } = useSubscription(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + useEffect(() => { + if (!subscription.secretScanning) { + handlePopUpOpen("upgradePlan"); + } + }, [subscription.secretScanning]); + + if (!subscription.secretScanning) { + return ( + <> +
+
+
+ +
+
+
Access Restricted
+
Upgrade your plan to access Secret Scanning
+
+
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text="Secret Scanning is not available on your current plan." + /> + + ); + } + return (