diff --git a/backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts b/backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts new file mode 100644 index 000000000..2cf19c1b8 --- /dev/null +++ b/backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Identity, "hasDeleteProtection"); + if (!hasCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.boolean("hasDeleteProtection").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Identity, "hasDeleteProtection"); + if (hasCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("hasDeleteProtection"); + }); + } +} diff --git a/backend/src/db/migrations/20250618172150_increase-aws-arn-field-size.ts b/backend/src/db/migrations/20250618172150_increase-aws-arn-field-size.ts new file mode 100644 index 000000000..a349ec2bc --- /dev/null +++ b/backend/src/db/migrations/20250618172150_increase-aws-arn-field-size.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.IdentityAwsAuth, "allowedPrincipalArns"); + if (hasColumn) { + await knex.schema.alterTable(TableName.IdentityAwsAuth, (t) => { + t.string("allowedPrincipalArns", 2048).notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.IdentityAwsAuth, "allowedPrincipalArns"); + if (hasColumn) { + await knex.schema.alterTable(TableName.IdentityAwsAuth, (t) => { + t.string("allowedPrincipalArns", 255).notNullable().alter(); + }); + } +} diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index adf3a6ef2..a592e2480 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -12,7 +12,8 @@ export const IdentitiesSchema = z.object({ name: z.string(), authMethod: z.string().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + hasDeleteProtection: z.boolean().default(false) }); export type TIdentities = z.infer; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index d10e1800b..ec235d34e 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -48,7 +48,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(GROUPS.GET_BY_ID.id) }), response: { - 200: GroupsSchema + 200: GroupsSchema.extend({ + customRoleSlug: z.string().nullable() + }) } }, handler: async (req) => { diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 2acf8022c..e72b9fa46 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -780,6 +780,7 @@ interface CreateIdentityEvent { metadata: { identityId: string; name: string; + hasDeleteProtection: boolean; }; } @@ -788,6 +789,7 @@ interface UpdateIdentityEvent { metadata: { identityId: string; name?: string; + hasDeleteProtection?: boolean; }; } diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 801f52fc0..708fbdbd3 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -169,11 +169,29 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.Groups) + .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) + .where(`${TableName.Groups}.id`, id) + .select( + selectAllTableCols(TableName.Groups), + db.ref("slug").as("customRoleSlug").withSchema(TableName.OrgRoles) + ) + .first(); + + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "Find by id" }); + } + }; + return { + ...groupOrm, findGroups, findByOrgId, findAllGroupPossibleMembers, findGroupsByProjectId, - ...groupOrm + findById }; }; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 5cbfbbc97..a5088bde5 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -698,9 +698,9 @@ export const oidcConfigServiceFactory = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (_req: any, tokenSet: TokenSet, cb: any) => { const claims = tokenSet.claims(); - if (!claims.email || !claims.given_name) { + if (!claims.email) { throw new BadRequestError({ - message: "Invalid request. Missing email or first name" + message: "Invalid request. Missing email claim." }); } @@ -713,12 +713,19 @@ export const oidcConfigServiceFactory = ({ } } + const name = claims?.given_name || claims?.name; + if (!name) { + throw new BadRequestError({ + message: "Invalid request. Missing name claim." + }); + } + const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined); oidcLogin({ email: claims.email.toLowerCase(), externalId: claims.sub, - firstName: claims.given_name ?? "", + firstName: name, lastName: claims.family_name ?? "", orgId: org.id, groups, diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index f61c4b1a4..966146c27 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -211,6 +211,11 @@ export type SecretFolderSubjectFields = { secretPath: string; }; +export type SecretSyncSubjectFields = { + environment: string; + secretPath: string; +}; + export type DynamicSecretSubjectFields = { environment: string; secretPath: string; @@ -267,6 +272,10 @@ export type ProjectPermissionSet = | (ForcedSubject & DynamicSecretSubjectFields) ) ] + | [ + ProjectPermissionSecretSyncActions, + ProjectPermissionSub.SecretSyncs | (ForcedSubject & SecretSyncSubjectFields) + ] | [ ProjectPermissionActions, ( @@ -323,7 +332,6 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] - | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionKmipActions, ProjectPermissionSub.Kmip] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] @@ -412,6 +420,23 @@ const DynamicSecretConditionV2Schema = z }) .partial(); +const SecretSyncConditionV2Schema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const SecretImportConditionSchema = z .object({ environment: z.union([ @@ -671,12 +696,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z.literal(ProjectPermissionSub.Kmip).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionKmipActions).describe( @@ -836,6 +855,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretSyncs).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretSyncActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretSyncConditionV2Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 6c3421222..67b70079b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -111,12 +111,14 @@ export const IDENTITIES = { CREATE: { name: "The name of the identity to create.", organizationId: "The organization ID to which the identity belongs.", - role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'." + role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'.", + hasDeleteProtection: "Prevents deletion of the identity when enabled." }, UPDATE: { identityId: "The ID of the identity to update.", name: "The new name of the identity.", - role: "The new role of the identity." + role: "The new role of the identity.", + hasDeleteProtection: "Prevents deletion of the identity when enabled." }, DELETE: { identityId: "The ID of the identity to delete." @@ -2223,6 +2225,9 @@ export const AppConnections = { ONEPASS: { instanceUrl: "The URL of the 1Password Connect Server instance to authenticate with.", apiToken: "The API token used to access the 1Password Connect Server." + }, + FLYIO: { + accessToken: "The Access Token used to access fly.io." } } }; @@ -2388,6 +2393,14 @@ export const SecretSyncs = { HEROKU: { app: "The ID of the Heroku app to sync secrets to.", appName: "The name of the Heroku app to sync secrets to." + }, + RENDER: { + serviceId: "The ID of the Render service to sync secrets to.", + scope: "The Render scope that secrets should be synced to.", + type: "The Render resource type to sync secrets to." + }, + FLYIO: { + appId: "The ID of the Fly.io app to sync secrets to." } } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 6b9d33c2a..b0b35cc2f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -262,8 +262,8 @@ const envSchema = z DATADOG_HOSTNAME: zpStr(z.string().optional()), // PIT - PIT_CHECKPOINT_WINDOW: zpStr(z.string().optional().default("2")), - PIT_TREE_CHECKPOINT_WINDOW: zpStr(z.string().optional().default("30")), + PIT_CHECKPOINT_WINDOW: zpStr(z.string().optional().default("100")), + PIT_TREE_CHECKPOINT_WINDOW: zpStr(z.string().optional().default("200")), /* CORS ----------------------------------------------------------------------------- */ CORS_ALLOWED_ORIGINS: zpStr( 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 5122c4159..e474859c3 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,7 @@ import { DatabricksConnectionListItemSchema, SanitizedDatabricksConnectionSchema } from "@app/services/app-connection/databricks"; +import { FlyioConnectionListItemSchema, SanitizedFlyioConnectionSchema } from "@app/services/app-connection/flyio"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; import { @@ -61,6 +62,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + RenderConnectionListItemSchema, + SanitizedRenderConnectionSchema +} from "@app/services/app-connection/render/render-connection-schema"; import { SanitizedTeamCityConnectionSchema, TeamCityConnectionListItemSchema @@ -102,7 +107,9 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOCIConnectionSchema.options, ...SanitizedOracleDBConnectionSchema.options, ...SanitizedOnePassConnectionSchema.options, - ...SanitizedHerokuConnectionSchema.options + ...SanitizedHerokuConnectionSchema.options, + ...SanitizedRenderConnectionSchema.options, + ...SanitizedFlyioConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -130,7 +137,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OCIConnectionListItemSchema, OracleDBConnectionListItemSchema, OnePassConnectionListItemSchema, - HerokuConnectionListItemSchema + HerokuConnectionListItemSchema, + RenderConnectionListItemSchema, + FlyioConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/flyio-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/flyio-connection-router.ts new file mode 100644 index 000000000..c2a1199aa --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/flyio-connection-router.ts @@ -0,0 +1,51 @@ +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 { + CreateFlyioConnectionSchema, + SanitizedFlyioConnectionSchema, + UpdateFlyioConnectionSchema +} from "@app/services/app-connection/flyio"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerFlyioConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Flyio, + server, + sanitizedResponseSchema: SanitizedFlyioConnectionSchema, + createSchema: CreateFlyioConnectionSchema, + updateSchema: UpdateFlyioConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/apps`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const apps = await server.services.appConnection.flyio.listApps(connectionId, req.permission); + return apps; + } + }); +}; 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 beabaf1f2..da1857ac5 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 { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; +import { registerFlyioConnectionRouter } from "./flyio-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router"; @@ -21,6 +22,7 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerRenderConnectionRouter } from "./render-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; @@ -54,5 +56,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Render, + server, + sanitizedResponseSchema: SanitizedRenderConnectionSchema, + createSchema: CreateRenderConnectionSchema, + updateSchema: UpdateRenderConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/services`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const services = await server.services.appConnection.render.listServices(connectionId, req.permission); + + return services; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 0e127796a..2ea70af7a 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -44,6 +44,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { name: z.string().trim().describe(IDENTITIES.CREATE.name), organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId), role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role), + hasDeleteProtection: z.boolean().default(false).describe(IDENTITIES.CREATE.hasDeleteProtection), metadata: z .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) .array() @@ -75,6 +76,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { type: EventType.CREATE_IDENTITY, metadata: { name: identity.name, + hasDeleteProtection: identity.hasDeleteProtection, identityId: identity.id } } @@ -86,6 +88,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { properties: { orgId: req.body.organizationId, name: identity.name, + hasDeleteProtection: identity.hasDeleteProtection, identityId: identity.id, ...req.auditLogInfo } @@ -117,6 +120,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name), role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role), + hasDeleteProtection: z.boolean().optional().describe(IDENTITIES.UPDATE.hasDeleteProtection), metadata: z .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) .array() @@ -148,6 +152,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { type: EventType.UPDATE_IDENTITY, metadata: { name: identity.name, + hasDeleteProtection: identity.hasDeleteProtection, identityId: identity.id } } @@ -243,7 +248,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }) }) @@ -292,7 +297,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }) }).array(), @@ -386,7 +391,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }) }).array(), @@ -451,7 +456,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { temporaryAccessEndTime: z.date().nullable().optional() }) ), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }), project: SanitizedProjectSchema.pick({ name: true, id: true, type: true }) diff --git a/backend/src/server/routes/v1/secret-sync-routers/flyio-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/flyio-sync-router.ts new file mode 100644 index 000000000..30501078e --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/flyio-sync-router.ts @@ -0,0 +1,13 @@ +import { CreateFlyioSyncSchema, FlyioSyncSchema, UpdateFlyioSyncSchema } from "@app/services/secret-sync/flyio"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerFlyioSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Flyio, + server, + responseSchema: FlyioSyncSchema, + createSchema: CreateFlyioSyncSchema, + updateSchema: UpdateFlyioSyncSchema + }); 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 81fa9f703..989d289ec 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -9,11 +9,13 @@ import { registerAzureDevOpsSyncRouter } from "./azure-devops-sync-router"; import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; +import { registerFlyioSyncRouter } from "./flyio-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerRenderSyncRouter } from "./render-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; @@ -39,5 +41,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Render, + server, + responseSchema: RenderSyncSchema, + createSchema: CreateRenderSyncSchema, + updateSchema: UpdateRenderSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index b3d4276c9..47158a28c 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -23,11 +23,13 @@ import { AzureDevOpsSyncListItemSchema, AzureDevOpsSyncSchema } from "@app/servi import { AzureKeyVaultSyncListItemSchema, AzureKeyVaultSyncSchema } from "@app/services/secret-sync/azure-key-vault"; import { CamundaSyncListItemSchema, CamundaSyncSchema } from "@app/services/secret-sync/camunda"; import { DatabricksSyncListItemSchema, DatabricksSyncSchema } from "@app/services/secret-sync/databricks"; +import { FlyioSyncListItemSchema, FlyioSyncSchema } from "@app/services/secret-sync/flyio"; import { GcpSyncListItemSchema, GcpSyncSchema } from "@app/services/secret-sync/gcp"; import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github"; import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { TeamCitySyncListItemSchema, TeamCitySyncSchema } from "@app/services/secret-sync/teamcity"; import { TerraformCloudSyncListItemSchema, TerraformCloudSyncSchema } from "@app/services/secret-sync/terraform-cloud"; import { VercelSyncListItemSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel"; @@ -51,7 +53,9 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ TeamCitySyncSchema, OCIVaultSyncSchema, OnePassSyncSchema, - HerokuSyncSchema + HerokuSyncSchema, + RenderSyncSchema, + FlyioSyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -72,7 +76,9 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ TeamCitySyncListItemSchema, OCIVaultSyncListItemSchema, OnePassSyncListItemSchema, - HerokuSyncListItemSchema + HerokuSyncListItemSchema, + RenderSyncListItemSchema, + FlyioSyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index fdb8888d9..e0b7a61f1 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -23,7 +23,9 @@ export enum AppConnection { OCI = "oci", OracleDB = "oracledb", OnePass = "1password", - Heroku = "heroku" + Heroku = "heroku", + Render = "render", + Flyio = "flyio" } 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 f20674b07..66027e24b 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -56,6 +56,7 @@ import { getDatabricksConnectionListItem, validateDatabricksConnectionCredentials } from "./databricks"; +import { FlyioConnectionMethod, getFlyioConnectionListItem, validateFlyioConnectionCredentials } from "./flyio"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; import { @@ -79,6 +80,8 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { RenderConnectionMethod } from "./render/render-connection-enums"; +import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns"; import { getTeamCityConnectionListItem, TeamCityConnectionMethod, @@ -123,7 +126,9 @@ export const listAppConnectionOptions = () => { getOCIConnectionListItem(), getOracleDBConnectionListItem(), getOnePassConnectionListItem(), - getHerokuConnectionListItem() + getHerokuConnectionListItem(), + getRenderConnectionListItem(), + getFlyioConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -199,7 +204,9 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -244,6 +251,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: case AzureDevOpsConnectionMethod.AccessToken: + case FlyioConnectionMethod.AccessToken: return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; @@ -251,6 +259,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "App Role"; case LdapConnectionMethod.SimpleBind: return "Simple Bind"; + case RenderConnectionMethod.ApiKey: + return "API Key"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); @@ -306,7 +316,9 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.OCI]: platformManagedCredentialsNotSupported, [AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.OnePass]: platformManagedCredentialsNotSupported, - [AppConnection.Heroku]: platformManagedCredentialsNotSupported + [AppConnection.Heroku]: platformManagedCredentialsNotSupported, + [AppConnection.Render]: platformManagedCredentialsNotSupported, + [AppConnection.Flyio]: 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 4edaf9edb..57725d1e1 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -25,7 +25,9 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OCI]: "OCI", [AppConnection.OracleDB]: "OracleDB", [AppConnection.OnePass]: "1Password", - [AppConnection.Heroku]: "Heroku" + [AppConnection.Heroku]: "Heroku", + [AppConnection.Render]: "Render", + [AppConnection.Flyio]: "Fly.io" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -53,5 +55,7 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -200,6 +214,8 @@ export type TAppConnectionInput = { id: string } & ( | TOracleDBConnectionInput | TOnePassConnectionInput | THerokuConnectionInput + | TRenderConnectionInput + | TFlyioConnectionInput ); export type TSqlConnectionInput = @@ -239,7 +255,9 @@ export type TAppConnectionConfig = | TTeamCityConnectionConfig | TOCIConnectionConfig | TOnePassConnectionConfig - | THerokuConnectionConfig; + | THerokuConnectionConfig + | TRenderConnectionConfig + | TFlyioConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -266,7 +284,9 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateOCIConnectionCredentialsSchema | TValidateOracleDBConnectionCredentialsSchema | TValidateOnePassConnectionCredentialsSchema - | TValidateHerokuConnectionCredentialsSchema; + | TValidateHerokuConnectionCredentialsSchema + | TValidateRenderConnectionCredentialsSchema + | TValidateFlyioConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/flyio/flyio-connection-enums.ts b/backend/src/services/app-connection/flyio/flyio-connection-enums.ts new file mode 100644 index 000000000..651e46658 --- /dev/null +++ b/backend/src/services/app-connection/flyio/flyio-connection-enums.ts @@ -0,0 +1,3 @@ +export enum FlyioConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/flyio/flyio-connection-fns.ts b/backend/src/services/app-connection/flyio/flyio-connection-fns.ts new file mode 100644 index 000000000..56d4c0b31 --- /dev/null +++ b/backend/src/services/app-connection/flyio/flyio-connection-fns.ts @@ -0,0 +1,72 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { FlyioConnectionMethod } from "./flyio-connection-enums"; +import { TFlyioApp, TFlyioConnection, TFlyioConnectionConfig } from "./flyio-connection-types"; + +export const getFlyioConnectionListItem = () => { + return { + name: "Fly.io" as const, + app: AppConnection.Flyio as const, + methods: Object.values(FlyioConnectionMethod) as [FlyioConnectionMethod.AccessToken] + }; +}; + +export const validateFlyioConnectionCredentials = async (config: TFlyioConnectionConfig) => { + const { accessToken } = config.credentials; + + try { + const resp = await request.post<{ data: { viewer: { id: string | null; email: string } } | null }>( + IntegrationUrls.FLYIO_API_URL, + { query: "query { viewer { id email } }" }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + if (resp.data.data === null) { + throw new BadRequestError({ + message: "Unable to validate connection: Invalid access token provided." + }); + } + } 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; +}; + +export const listFlyioApps = async (appConnection: TFlyioConnection) => { + const { accessToken } = appConnection.credentials; + + const resp = await request.post<{ data: { apps: { nodes: TFlyioApp[] } } }>( + IntegrationUrls.FLYIO_API_URL, + { + query: + "query GetApps { apps { nodes { id name hostname status organization { id slug } currentRelease { version status createdAt } } } }" + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + + return resp.data.data.apps.nodes; +}; diff --git a/backend/src/services/app-connection/flyio/flyio-connection-schemas.ts b/backend/src/services/app-connection/flyio/flyio-connection-schemas.ts new file mode 100644 index 000000000..4466d24df --- /dev/null +++ b/backend/src/services/app-connection/flyio/flyio-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 { FlyioConnectionMethod } from "./flyio-connection-enums"; + +export const FlyioConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .max(1000) + .startsWith("FlyV1", "Token must start with 'FlyV1'") + .describe(AppConnections.CREDENTIALS.FLYIO.accessToken) +}); + +const BaseFlyioConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Flyio) }); + +export const FlyioConnectionSchema = BaseFlyioConnectionSchema.extend({ + method: z.literal(FlyioConnectionMethod.AccessToken), + credentials: FlyioConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedFlyioConnectionSchema = z.discriminatedUnion("method", [ + BaseFlyioConnectionSchema.extend({ + method: z.literal(FlyioConnectionMethod.AccessToken), + credentials: FlyioConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateFlyioConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(FlyioConnectionMethod.AccessToken).describe(AppConnections.CREATE(AppConnection.Flyio).method), + credentials: FlyioConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Flyio).credentials + ) + }) +]); + +export const CreateFlyioConnectionSchema = ValidateFlyioConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Flyio) +); + +export const UpdateFlyioConnectionSchema = z + .object({ + credentials: FlyioConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Flyio).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Flyio)); + +export const FlyioConnectionListItemSchema = z.object({ + name: z.literal("Fly.io"), + app: z.literal(AppConnection.Flyio), + methods: z.nativeEnum(FlyioConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/flyio/flyio-connection-service.ts b/backend/src/services/app-connection/flyio/flyio-connection-service.ts new file mode 100644 index 000000000..dd88633a0 --- /dev/null +++ b/backend/src/services/app-connection/flyio/flyio-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 { listFlyioApps } from "./flyio-connection-fns"; +import { TFlyioConnection } from "./flyio-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const flyioConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listApps = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Flyio, connectionId, actor); + + try { + const apps = await listFlyioApps(appConnection); + return apps; + } catch (error) { + logger.error(error, "Failed to establish connection with fly.io"); + return []; + } + }; + + return { + listApps + }; +}; diff --git a/backend/src/services/app-connection/flyio/flyio-connection-types.ts b/backend/src/services/app-connection/flyio/flyio-connection-types.ts new file mode 100644 index 000000000..f643caffb --- /dev/null +++ b/backend/src/services/app-connection/flyio/flyio-connection-types.ts @@ -0,0 +1,27 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateFlyioConnectionSchema, + FlyioConnectionSchema, + ValidateFlyioConnectionCredentialsSchema +} from "./flyio-connection-schemas"; + +export type TFlyioConnection = z.infer; + +export type TFlyioConnectionInput = z.infer & { + app: AppConnection.Flyio; +}; + +export type TValidateFlyioConnectionCredentialsSchema = typeof ValidateFlyioConnectionCredentialsSchema; + +export type TFlyioConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TFlyioApp = { + id: string; + name: string; +}; diff --git a/backend/src/services/app-connection/flyio/index.ts b/backend/src/services/app-connection/flyio/index.ts new file mode 100644 index 000000000..f6aefd6b1 --- /dev/null +++ b/backend/src/services/app-connection/flyio/index.ts @@ -0,0 +1,4 @@ +export * from "./flyio-connection-enums"; +export * from "./flyio-connection-fns"; +export * from "./flyio-connection-schemas"; +export * from "./flyio-connection-types"; diff --git a/backend/src/services/app-connection/render/render-connection-enums.ts b/backend/src/services/app-connection/render/render-connection-enums.ts new file mode 100644 index 000000000..36ca11384 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-enums.ts @@ -0,0 +1,3 @@ +export enum RenderConnectionMethod { + ApiKey = "api-key" +} diff --git a/backend/src/services/app-connection/render/render-connection-fns.ts b/backend/src/services/app-connection/render/render-connection-fns.ts new file mode 100644 index 000000000..bf85b9b71 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-fns.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { RenderConnectionMethod } from "./render-connection-enums"; +import { + TRawRenderService, + TRenderConnection, + TRenderConnectionConfig, + TRenderService +} from "./render-connection-types"; + +export const getRenderConnectionListItem = () => { + return { + name: "Render" as const, + app: AppConnection.Render as const, + methods: Object.values(RenderConnectionMethod) as [RenderConnectionMethod.ApiKey] + }; +}; + +export const listRenderServices = async (appConnection: TRenderConnection): Promise => { + const { + credentials: { apiKey } + } = appConnection; + + const services: TRenderService[] = []; + let hasMorePages = true; + const perPage = 100; + let cursor; + + while (hasMorePages) { + const res: TRawRenderService[] = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { + params: new URLSearchParams({ + ...(cursor ? { cursor: String(cursor) } : {}), + limit: String(perPage) + }), + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + res.forEach((item) => { + services.push({ + name: item.service.name, + id: item.service.id + }); + }); + + if (res.length < perPage) { + hasMorePages = false; + } else { + cursor = res[res.length - 1].cursor; + } + } + + return services; +}; + +export const validateRenderConnectionCredentials = async (config: TRenderConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/users`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiKey}` + } + }); + } 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 inputCredentials; +}; diff --git a/backend/src/services/app-connection/render/render-connection-schema.ts b/backend/src/services/app-connection/render/render-connection-schema.ts new file mode 100644 index 000000000..77cc46714 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-schema.ts @@ -0,0 +1,56 @@ +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 { RenderConnectionMethod } from "./render-connection-enums"; + +export const RenderConnectionApiKeyCredentialsSchema = z.object({ + apiKey: z.string().trim().min(1, "API key required").max(256, "API key cannot exceed 256 characters") +}); + +const BaseRenderConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Render) }); + +export const RenderConnectionSchema = BaseRenderConnectionSchema.extend({ + method: z.literal(RenderConnectionMethod.ApiKey), + credentials: RenderConnectionApiKeyCredentialsSchema +}); + +export const SanitizedRenderConnectionSchema = z.discriminatedUnion("method", [ + BaseRenderConnectionSchema.extend({ + method: z.literal(RenderConnectionMethod.ApiKey), + credentials: RenderConnectionApiKeyCredentialsSchema.pick({}) + }) +]); + +export const ValidateRenderConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(RenderConnectionMethod.ApiKey).describe(AppConnections.CREATE(AppConnection.Render).method), + credentials: RenderConnectionApiKeyCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Render).credentials + ) + }) +]); + +export const CreateRenderConnectionSchema = ValidateRenderConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Render) +); + +export const UpdateRenderConnectionSchema = z + .object({ + credentials: RenderConnectionApiKeyCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Render).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Render)); + +export const RenderConnectionListItemSchema = z.object({ + name: z.literal("Render"), + app: z.literal(AppConnection.Render), + methods: z.nativeEnum(RenderConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/render/render-connection-service.ts b/backend/src/services/app-connection/render/render-connection-service.ts new file mode 100644 index 000000000..371790bcb --- /dev/null +++ b/backend/src/services/app-connection/render/render-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 { listRenderServices } from "./render-connection-fns"; +import { TRenderConnection } from "./render-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listServices = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor); + try { + const services = await listRenderServices(appConnection); + + return services; + } catch (error) { + logger.error(error, "Failed to list services for Render connection"); + return []; + } + }; + + return { + listServices + }; +}; diff --git a/backend/src/services/app-connection/render/render-connection-types.ts b/backend/src/services/app-connection/render/render-connection-types.ts new file mode 100644 index 000000000..0902472e5 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateRenderConnectionSchema, + RenderConnectionSchema, + ValidateRenderConnectionCredentialsSchema +} from "./render-connection-schema"; + +export type TRenderConnection = z.infer; + +export type TRenderConnectionInput = z.infer & { + app: AppConnection.Render; +}; + +export type TValidateRenderConnectionCredentialsSchema = typeof ValidateRenderConnectionCredentialsSchema; + +export type TRenderConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TRenderService = { + name: string; + id: string; +}; + +export type TRawRenderService = { + cursor: string; + service: { + id: string; + name: string; + }; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts index 1a6e5cbd6..d0ef6fd88 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -9,6 +9,7 @@ const arnRegex = new RE2(/^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/ export const validateAccountIds = z .string() .trim() + .max(2048) .default("") // Custom validation to ensure each part is a 12-digit number .refine( @@ -36,6 +37,7 @@ export const validateAccountIds = z export const validatePrincipalArns = z .string() .trim() + .max(2048) .default("") // Custom validation for ARN format .refine( diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index 433f5ebd9..e5e59607d 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -101,6 +101,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("id").as("identityId").withSchema(TableName.Identity), db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity), db.ref("id").withSchema(TableName.IdentityProjectMembership), db.ref("role").withSchema(TableName.IdentityProjectMembershipRole), db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"), @@ -130,6 +131,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { data: docs, parentMapper: ({ identityName, + hasDeleteProtection, uaId, awsId, gcpId, @@ -151,6 +153,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + hasDeleteProtection, authMethods: buildAuthMethods({ uaId, awsId, diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index b54a12679..28064c9bb 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -114,16 +114,18 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), - db.ref("name").withSchema(TableName.Identity) + db.ref("name").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) ); if (data) { - const { name } = data; + const { name, hasDeleteProtection } = data; return { ...data, identity: { id: data.identityId, name, + hasDeleteProtection, authMethods: buildAuthMethods(data) } }; @@ -155,7 +157,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) .select( selectAllTableCols(TableName.IdentityOrgMembership), - db.ref("name").withSchema(TableName.Identity).as("identityName") + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) ) .where(filter) .as("paginatedIdentity"); @@ -245,6 +248,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema("paginatedIdentity"), db.ref("identityId").withSchema("paginatedIdentity").as("identityId"), db.ref("identityName").withSchema("paginatedIdentity"), + db.ref("hasDeleteProtection").withSchema("paginatedIdentity"), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -286,6 +290,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { crName, identityId, identityName, + hasDeleteProtection, role, roleId, id, @@ -324,6 +329,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + hasDeleteProtection, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -476,6 +482,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -518,6 +525,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { crName, identityId, identityName, + hasDeleteProtection, role, roleId, total_count, @@ -556,6 +564,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + hasDeleteProtection, authMethods: buildAuthMethods({ uaId, alicloudId, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 3ffee698a..4ea382f9e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -47,6 +47,7 @@ export const identityServiceFactory = ({ const createIdentity = async ({ name, role, + hasDeleteProtection, actor, orgId, actorId, @@ -96,7 +97,7 @@ export const identityServiceFactory = ({ } const identity = await identityDAL.transaction(async (tx) => { - const newIdentity = await identityDAL.create({ name }, tx); + const newIdentity = await identityDAL.create({ name, hasDeleteProtection }, tx); await identityOrgMembershipDAL.create( { identityId: newIdentity.id, @@ -138,6 +139,7 @@ export const identityServiceFactory = ({ const updateIdentity = async ({ id, role, + hasDeleteProtection, name, actor, actorId, @@ -189,7 +191,11 @@ export const identityServiceFactory = ({ } const identity = await identityDAL.transaction(async (tx) => { - const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx); + const newIdentity = + name || hasDeleteProtection + ? await identityDAL.updateById(id, { name, hasDeleteProtection }, tx) + : await identityDAL.findById(id, tx); + if (role) { await identityOrgMembershipDAL.updateById( identityOrgMembership.id, @@ -272,6 +278,9 @@ export const identityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + if (identityOrgMembership.identity.hasDeleteProtection) + throw new BadRequestError({ message: "Identity has delete protection" }); + const deletedIdentity = await identityDAL.deleteById(id); await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.orgId); diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 363d42a88..8d23f34fe 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -5,12 +5,14 @@ import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { role: string; name: string; + hasDeleteProtection: boolean; metadata?: { key: string; value: string }[]; } & TOrgPermission; export type TUpdateIdentityDTO = { id: string; role?: string; + hasDeleteProtection?: boolean; name?: string; metadata?: { key: string; value: string }[]; isActorSuperAdmin?: boolean; diff --git a/backend/src/services/secret-sync/flyio/flyio-sync-constants.ts b/backend/src/services/secret-sync/flyio/flyio-sync-constants.ts new file mode 100644 index 000000000..7e9e6b287 --- /dev/null +++ b/backend/src/services/secret-sync/flyio/flyio-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 FLYIO_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Fly.io", + destination: SecretSync.Flyio, + connection: AppConnection.Flyio, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/flyio/flyio-sync-fns.ts b/backend/src/services/secret-sync/flyio/flyio-sync-fns.ts new file mode 100644 index 000000000..7c006fb64 --- /dev/null +++ b/backend/src/services/secret-sync/flyio/flyio-sync-fns.ts @@ -0,0 +1,133 @@ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { + TDeleteFlyioVariable, + TFlyioListVariables, + TFlyioSecret, + TFlyioSyncWithCredentials, + TPutFlyioVariable +} from "@app/services/secret-sync/flyio/flyio-sync-types"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + +const listFlyioSecrets = async ({ accessToken, appId }: TFlyioListVariables) => { + const { data } = await request.post<{ data: { app: { secrets: TFlyioSecret[] } } }>( + IntegrationUrls.FLYIO_API_URL, + { + query: "query GetAppSecrets($appId: String!) { app(id: $appId) { id name secrets { name createdAt } } }", + variables: { appId } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + + return data.data.app.secrets.map((s) => s.name); +}; + +const putFlyioSecrets = async ({ accessToken, appId, secretMap }: TPutFlyioVariable) => { + return request.post( + IntegrationUrls.FLYIO_API_URL, + { + query: + "mutation SetAppSecrets($appId: ID!, $secrets: [SecretInput!]!) { setSecrets(input: { appId: $appId, secrets: $secrets }) { app { name } release { version } } }", + variables: { + appId, + secrets: Object.entries(secretMap).map(([key, { value }]) => ({ key, value })) + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const deleteFlyioSecrets = async ({ accessToken, appId, keys }: TDeleteFlyioVariable) => { + return request.post( + IntegrationUrls.FLYIO_API_URL, + { + query: + "mutation UnsetAppSecrets($appId: ID!, $keys: [String!]!) { unsetSecrets(input: { appId: $appId, keys: $keys }) { app { name } release { version } } }", + variables: { + appId, + keys + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +export const FlyioSyncFns = { + syncSecrets: async (secretSync: TFlyioSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + environment, + destinationConfig: { appId } + } = secretSync; + + const { accessToken } = connection.credentials; + + try { + await putFlyioSecrets({ accessToken, appId, secretMap }); + } catch (error) { + throw new SecretSyncError({ + error + }); + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + const secrets = await listFlyioSecrets({ accessToken, appId }); + + const keys = secrets.filter( + (secret) => + matchesSchema(secret, environment?.slug || "", secretSync.syncOptions.keySchema) && !(secret in secretMap) + ); + + try { + await deleteFlyioSecrets({ accessToken, appId, keys }); + } catch (error) { + throw new SecretSyncError({ + error + }); + } + }, + removeSecrets: async (secretSync: TFlyioSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { appId } + } = secretSync; + + const { accessToken } = connection.credentials; + + const secrets = await listFlyioSecrets({ accessToken, appId }); + + const keys = secrets.filter((secret) => secret in secretMap); + + try { + await deleteFlyioSecrets({ accessToken, appId, keys }); + } catch (error) { + throw new SecretSyncError({ + error + }); + } + }, + getSecrets: async (secretSync: TFlyioSyncWithCredentials) => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + } +}; diff --git a/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts b/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts new file mode 100644 index 000000000..b353f94b4 --- /dev/null +++ b/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const FlyioSyncDestinationConfigSchema = z.object({ + appId: z.string().trim().min(1, "App required").max(255).describe(SecretSyncs.DESTINATION_CONFIG.FLYIO.appId) +}); + +const FlyioSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const FlyioSyncSchema = BaseSecretSyncSchema(SecretSync.Flyio, FlyioSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Flyio), + destinationConfig: FlyioSyncDestinationConfigSchema +}); + +export const CreateFlyioSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Flyio, + FlyioSyncOptionsConfig +).extend({ + destinationConfig: FlyioSyncDestinationConfigSchema +}); + +export const UpdateFlyioSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Flyio, + FlyioSyncOptionsConfig +).extend({ + destinationConfig: FlyioSyncDestinationConfigSchema.optional() +}); + +export const FlyioSyncListItemSchema = z.object({ + name: z.literal("Fly.io"), + connection: z.literal(AppConnection.Flyio), + destination: z.literal(SecretSync.Flyio), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/flyio/flyio-sync-types.ts b/backend/src/services/secret-sync/flyio/flyio-sync-types.ts new file mode 100644 index 000000000..336639091 --- /dev/null +++ b/backend/src/services/secret-sync/flyio/flyio-sync-types.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { TFlyioConnection } from "@app/services/app-connection/flyio"; + +import { CreateFlyioSyncSchema, FlyioSyncListItemSchema, FlyioSyncSchema } from "./flyio-sync-schemas"; + +export type TFlyioSync = z.infer; + +export type TFlyioSyncInput = z.infer; + +export type TFlyioSyncListItem = z.infer; + +export type TFlyioSyncWithCredentials = TFlyioSync & { + connection: TFlyioConnection; +}; + +export type TFlyioSecret = { + name: string; +}; + +export type TFlyioListVariables = { + accessToken: string; + appId: string; +}; + +export type TPutFlyioVariable = TFlyioListVariables & { + secretMap: { [key: string]: { value: string } }; +}; + +export type TDeleteFlyioVariable = TFlyioListVariables & { + keys: string[]; +}; diff --git a/backend/src/services/secret-sync/flyio/index.ts b/backend/src/services/secret-sync/flyio/index.ts new file mode 100644 index 000000000..b2fe2b0d5 --- /dev/null +++ b/backend/src/services/secret-sync/flyio/index.ts @@ -0,0 +1,4 @@ +export * from "./flyio-sync-constants"; +export * from "./flyio-sync-fns"; +export * from "./flyio-sync-schemas"; +export * from "./flyio-sync-types"; diff --git a/backend/src/services/secret-sync/render/index.ts b/backend/src/services/secret-sync/render/index.ts new file mode 100644 index 000000000..7e8ddd412 --- /dev/null +++ b/backend/src/services/secret-sync/render/index.ts @@ -0,0 +1,4 @@ +export * from "./render-sync-constants"; +export * from "./render-sync-fns"; +export * from "./render-sync-schemas"; +export * from "./render-sync-types"; diff --git a/backend/src/services/secret-sync/render/render-sync-constants.ts b/backend/src/services/secret-sync/render/render-sync-constants.ts new file mode 100644 index 000000000..0246a95d9 --- /dev/null +++ b/backend/src/services/secret-sync/render/render-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 RENDER_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Render", + destination: SecretSync.Render, + connection: AppConnection.Render, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/render/render-sync-enums.ts b/backend/src/services/secret-sync/render/render-sync-enums.ts new file mode 100644 index 000000000..dc0af3b91 --- /dev/null +++ b/backend/src/services/secret-sync/render/render-sync-enums.ts @@ -0,0 +1,8 @@ +export enum RenderSyncScope { + Service = "service" +} + +export enum RenderSyncType { + Env = "env", + File = "file" +} diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts new file mode 100644 index 000000000..8a9039e2e --- /dev/null +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -0,0 +1,134 @@ +/* eslint-disable no-await-in-loop */ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; + +const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials) => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; + const allSecrets: TRenderSecret[] = []; + let cursor: string | undefined; + + do { + const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; + const { data } = await request.get< + { + envVar: { + key: string; + value: string; + }; + cursor: string; + }[] + >(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }); + + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); + + allSecrets.push(...secrets); + + cursor = data[data.length - 1]?.cursor; + } while (cursor); + + return allSecrets; +}; + +const putEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap, key: string) => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + await request.put( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${key}`, + { + key, + value: secretMap[key].value + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + } + ); +}; + +const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: TRenderSecret) => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + await request.delete( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + } + ); +}; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 500); + }); + +export const RenderSyncFns = { + syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { + const renderSecrets = await getRenderEnvironmentSecrets(secretSync); + for await (const key of Object.keys(secretMap)) { + await putEnvironmentSecret(secretSync, secretMap, key); + await sleep(); + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const renderSecret of renderSecrets) { + if (!matchesSchema(renderSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; + + if (!secretMap[renderSecret.key]) { + await deleteEnvironmentSecret(secretSync, renderSecret); + await sleep(); + } + } + }, + getSecrets: async (secretSync: TRenderSyncWithCredentials): Promise => { + const renderSecrets = await getRenderEnvironmentSecrets(secretSync); + return Object.fromEntries(renderSecrets.map((secret) => [secret.key, { value: secret.value ?? "" }])); + }, + + removeSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { + const encryptedSecrets = await getRenderEnvironmentSecrets(secretSync); + + for await (const encryptedSecret of encryptedSecrets) { + if (encryptedSecret.key in secretMap) { + await deleteEnvironmentSecret(secretSync, encryptedSecret); + await sleep(); + } + } + } +}; diff --git a/backend/src/services/secret-sync/render/render-sync-schemas.ts b/backend/src/services/secret-sync/render/render-sync-schemas.ts new file mode 100644 index 000000000..77414c17c --- /dev/null +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { RenderSyncScope, RenderSyncType } from "./render-sync-enums"; + +const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), + serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId), + type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) + }) +]); + +const RenderSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const RenderSyncSchema = BaseSecretSyncSchema(SecretSync.Render, RenderSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Render), + destinationConfig: RenderSyncDestinationConfigSchema +}); + +export const CreateRenderSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Render, + RenderSyncOptionsConfig +).extend({ + destinationConfig: RenderSyncDestinationConfigSchema +}); + +export const UpdateRenderSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Render, + RenderSyncOptionsConfig +).extend({ + destinationConfig: RenderSyncDestinationConfigSchema.optional() +}); + +export const RenderSyncListItemSchema = z.object({ + name: z.literal("Render"), + connection: z.literal(AppConnection.Render), + destination: z.literal(SecretSync.Render), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/render/render-sync-types.ts b/backend/src/services/secret-sync/render/render-sync-types.ts new file mode 100644 index 000000000..22d479384 --- /dev/null +++ b/backend/src/services/secret-sync/render/render-sync-types.ts @@ -0,0 +1,20 @@ +import z from "zod"; + +import { TRenderConnection } from "@app/services/app-connection/render/render-connection-types"; + +import { CreateRenderSyncSchema, RenderSyncListItemSchema, RenderSyncSchema } from "./render-sync-schemas"; + +export type TRenderSyncListItem = z.infer; + +export type TRenderSync = z.infer; + +export type TRenderSyncInput = z.infer; + +export type TRenderSyncWithCredentials = TRenderSync & { + connection: TRenderConnection; +}; + +export type TRenderSecret = { + key: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 8cc8a07a4..04aaed0ce 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -16,7 +16,9 @@ export enum SecretSync { TeamCity = "teamcity", OCIVault = "oci-vault", OnePass = "1password", - Heroku = "heroku" + Heroku = "heroku", + Render = "render", + Flyio = "flyio" } 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 1e573a5df..63822a59e 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -29,12 +29,14 @@ 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 { FLYIO_SYNC_LIST_OPTION, FlyioSyncFns } from "./flyio"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; @@ -59,7 +61,9 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION, [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION, [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION, - [SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION + [SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION, + [SecretSync.Render]: RENDER_SYNC_LIST_OPTION, + [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -219,6 +223,10 @@ export const SecretSyncFns = { return OCIVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.OnePass: return OnePassSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Render: + return RenderSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Flyio: + return FlyioSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -299,6 +307,12 @@ export const SecretSyncFns = { case SecretSync.Heroku: secretMap = await HerokuSyncFns.getSecrets(secretSync, { appConnectionDAL, kmsService }); break; + case SecretSync.Render: + secretMap = await RenderSyncFns.getSecrets(secretSync); + break; + case SecretSync.Flyio: + secretMap = await FlyioSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -368,6 +382,10 @@ export const SecretSyncFns = { return OnePassSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Heroku: return HerokuSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); + case SecretSync.Render: + return RenderSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Flyio: + return FlyioSyncFns.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 d0fbd5f6b..f8f5d813c 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -19,7 +19,9 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.TeamCity]: "TeamCity", [SecretSync.OCIVault]: "OCI Vault", [SecretSync.OnePass]: "1Password", - [SecretSync.Heroku]: "Heroku" + [SecretSync.Heroku]: "Heroku", + [SecretSync.Render]: "Render", + [SecretSync.Flyio]: "Fly.io" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -40,7 +42,9 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.TeamCity]: AppConnection.TeamCity, [SecretSync.OCIVault]: AppConnection.OCI, [SecretSync.OnePass]: AppConnection.OnePass, - [SecretSync.Heroku]: AppConnection.Heroku + [SecretSync.Heroku]: AppConnection.Heroku, + [SecretSync.Render]: AppConnection.Render, + [SecretSync.Flyio]: AppConnection.Flyio }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -61,5 +65,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.TeamCity]: SecretSyncPlanType.Regular, [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, [SecretSync.OnePass]: SecretSyncPlanType.Regular, - [SecretSync.Heroku]: SecretSyncPlanType.Regular + [SecretSync.Heroku]: SecretSyncPlanType.Regular, + [SecretSync.Render]: SecretSyncPlanType.Regular, + [SecretSync.Flyio]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index b64620827..3fdb7fea6 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -89,7 +89,17 @@ export const secretSyncServiceFactory = ({ projectId }); - return secretSyncs as TSecretSync[]; + return secretSyncs.filter((secretSync) => + permission.can( + ProjectPermissionSecretSyncActions.Read, + secretSync.environment && secretSync.folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + : ProjectPermissionSub.SecretSyncs + ) + ) as TSecretSync[]; }; const listSecretSyncsBySecretPath = async ( @@ -105,7 +115,15 @@ export const secretSyncServiceFactory = ({ projectId }); - if (permission.cannot(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs)) { + if ( + permission.cannot( + ProjectPermissionSecretSyncActions.Read, + subject(ProjectPermissionSub.SecretSyncs, { + environment, + secretPath + }) + ) + ) { return []; } @@ -142,7 +160,12 @@ export const secretSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSub.SecretSyncs + secretSync.environment && secretSync.folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + : ProjectPermissionSub.SecretSyncs ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) @@ -179,7 +202,12 @@ export const secretSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSub.SecretSyncs + secretSync.environment && secretSync.folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + : ProjectPermissionSub.SecretSyncs ); if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) @@ -217,13 +245,17 @@ export const secretSyncServiceFactory = ({ ForbiddenError.from(projectPermission).throwUnlessCan( ProjectPermissionSecretSyncActions.Create, - ProjectPermissionSub.SecretSyncs + subject(ProjectPermissionSub.SecretSyncs, { environment, secretPath }) ); - throwIfMissingSecretReadValueOrDescribePermission(projectPermission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath - }); + throwIfMissingSecretReadValueOrDescribePermission( + projectPermission, + ProjectPermissionSecretActions.DescribeSecret, + { + environment, + secretPath + } + ); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); @@ -286,10 +318,38 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.Edit, - ProjectPermissionSub.SecretSyncs - ); + // we always check the permission against the existing environment / secret path + // if no secret path / environment is present on the secret sync, we need to check without conditions + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + ProjectPermissionSub.SecretSyncs + ); + } + + // if the user is updating the secret path or environment, we need to check the permission against the new values + if (secretPath || environment) { + const environmentToCheck = environment || secretSync.environment?.slug || ""; + const secretPathToCheck = secretPath || secretSync.folder?.path || ""; + + if (environmentToCheck && secretPathToCheck) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Edit, + subject(ProjectPermissionSub.SecretSyncs, { + environment: environmentToCheck, + secretPath: secretPathToCheck + }) + ); + } + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -315,7 +375,7 @@ export const secretSyncServiceFactory = ({ if (!updatedEnvironment || !updatedSecretPath) throw new BadRequestError({ message: "Must specify both source environment and secret path" }); - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { environment: updatedEnvironment, secretPath: updatedSecretPath }); @@ -374,10 +434,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.Delete, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Delete, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Delete, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -441,10 +511,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.SyncSecrets, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.SyncSecrets, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.SyncSecrets, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -503,10 +583,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.ImportSecrets, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.ImportSecrets, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.ImportSecrets, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ @@ -559,10 +649,20 @@ export const secretSyncServiceFactory = ({ projectId: secretSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.RemoveSecrets, - ProjectPermissionSub.SecretSyncs - ); + if (secretSync.environment?.slug && secretSync.folder?.path) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.RemoveSecrets, + subject(ProjectPermissionSub.SecretSyncs, { + environment: secretSync.environment.slug, + secretPath: secretSync.folder.path + }) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.RemoveSecrets, + ProjectPermissionSub.SecretSyncs + ); + } if (secretSync.connection.app !== SECRET_SYNC_CONNECTION_MAP[destination]) throw new BadRequestError({ diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 59c95f964..d3dddea82 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -72,6 +72,7 @@ import { TAzureKeyVaultSyncListItem, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault"; +import { TFlyioSync, TFlyioSyncInput, TFlyioSyncListItem, TFlyioSyncWithCredentials } from "./flyio/flyio-sync-types"; import { TGcpSync, TGcpSyncInput, TGcpSyncListItem, TGcpSyncWithCredentials } from "./gcp"; import { THCVaultSync, @@ -86,6 +87,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TRenderSync, + TRenderSyncInput, + TRenderSyncListItem, + TRenderSyncWithCredentials +} from "./render/render-sync-types"; import { TTeamCitySync, TTeamCitySyncInput, @@ -118,7 +125,9 @@ export type TSecretSync = | TTeamCitySync | TOCIVaultSync | TOnePassSync - | THerokuSync; + | THerokuSync + | TRenderSync + | TFlyioSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -138,7 +147,9 @@ export type TSecretSyncWithCredentials = | TTeamCitySyncWithCredentials | TOCIVaultSyncWithCredentials | TOnePassSyncWithCredentials - | THerokuSyncWithCredentials; + | THerokuSyncWithCredentials + | TRenderSyncWithCredentials + | TFlyioSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -158,7 +169,9 @@ export type TSecretSyncInput = | TTeamCitySyncInput | TOCIVaultSyncInput | TOnePassSyncInput - | THerokuSyncInput; + | THerokuSyncInput + | TRenderSyncInput + | TFlyioSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -178,7 +191,9 @@ export type TSecretSyncListItem = | TTeamCitySyncListItem | TOCIVaultSyncListItem | TOnePassSyncListItem - | THerokuSyncListItem; + | THerokuSyncListItem + | TRenderSyncListItem + | TFlyioSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index a370d0332..ab8fc51c5 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -81,6 +81,7 @@ export type TMachineIdentityCreatedEvent = { event: PostHogEventTypes.MachineIdentityCreated; properties: { name: string; + hasDeleteProtection: boolean; orgId: string; identityId: string; }; diff --git a/company/documentation/engineering/oncall-summery-template.mdx b/company/documentation/engineering/oncall-summery-template.mdx index 523f71e00..5c658959c 100644 --- a/company/documentation/engineering/oncall-summery-template.mdx +++ b/company/documentation/engineering/oncall-summery-template.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Summary template" --- ```plain -Date: MM/DD/YY-MM/DD/YY +Date: MM/DD/YY-MM/DD/YY (day) Notable incidents: - []
diff --git a/docs/api-reference/endpoints/app-connections/flyio/available.mdx b/docs/api-reference/endpoints/app-connections/flyio/available.mdx new file mode 100644 index 000000000..20a643942 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/flyio/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/flyio/create.mdx b/docs/api-reference/endpoints/app-connections/flyio/create.mdx new file mode 100644 index 000000000..afe00ba5e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/flyio" +--- + + + Check out the configuration docs for [Fly.io Connections](/integrations/app-connections/flyio) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/flyio/delete.mdx b/docs/api-reference/endpoints/app-connections/flyio/delete.mdx new file mode 100644 index 000000000..ec5907840 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/flyio/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/flyio/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/flyio/get-by-id.mdx new file mode 100644 index 000000000..ad09af5c6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/flyio/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/flyio/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/flyio/get-by-name.mdx new file mode 100644 index 000000000..f6053d02a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/flyio/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/flyio/list.mdx b/docs/api-reference/endpoints/app-connections/flyio/list.mdx new file mode 100644 index 000000000..73271db2e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/flyio" +--- diff --git a/docs/api-reference/endpoints/app-connections/flyio/update.mdx b/docs/api-reference/endpoints/app-connections/flyio/update.mdx new file mode 100644 index 000000000..0cc6e2496 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/flyio/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/flyio/{connectionId}" +--- + + + Check out the configuration docs for [Fly.io Connections](/integrations/app-connections/flyio) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/render/available.mdx b/docs/api-reference/endpoints/app-connections/render/available.mdx new file mode 100644 index 000000000..99691fe59 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/render/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/render/create.mdx b/docs/api-reference/endpoints/app-connections/render/create.mdx new file mode 100644 index 000000000..2078e0418 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/render" +--- + + + Check out the configuration docs for [Render + Connections](/integrations/app-connections/render) to learn how to obtain the + required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/render/delete.mdx b/docs/api-reference/endpoints/app-connections/render/delete.mdx new file mode 100644 index 000000000..40826c700 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/render/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/render/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/render/get-by-id.mdx new file mode 100644 index 000000000..7c4f7ce05 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/render/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/render/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/render/get-by-name.mdx new file mode 100644 index 000000000..464a36558 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/render/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/render/list.mdx b/docs/api-reference/endpoints/app-connections/render/list.mdx new file mode 100644 index 000000000..f473057f1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/render" +--- diff --git a/docs/api-reference/endpoints/app-connections/render/update.mdx b/docs/api-reference/endpoints/app-connections/render/update.mdx new file mode 100644 index 000000000..9c8e33484 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/render/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/render/{connectionId}" +--- + + + Check out the configuration docs for [Render + Connections](/integrations/app-connections/render) to learn how to obtain the + required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/create.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/create.mdx new file mode 100644 index 000000000..a080e09c4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/flyio" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/delete.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/delete.mdx new file mode 100644 index 000000000..5dbd5ff1b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/flyio/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/get-by-id.mdx new file mode 100644 index 000000000..8dfff7915 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/flyio/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/get-by-name.mdx new file mode 100644 index 000000000..0121546b0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/flyio/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/import-secrets.mdx new file mode 100644 index 000000000..39f3475fb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/flyio/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/list.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/list.mdx new file mode 100644 index 000000000..784c78cac --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/flyio" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/remove-secrets.mdx new file mode 100644 index 000000000..3159dd51a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/flyio/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/sync-secrets.mdx new file mode 100644 index 000000000..3495b9ae4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/flyio/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/flyio/update.mdx b/docs/api-reference/endpoints/secret-syncs/flyio/update.mdx new file mode 100644 index 000000000..cf448327f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/flyio/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/flyio/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/create.mdx b/docs/api-reference/endpoints/secret-syncs/render/create.mdx new file mode 100644 index 000000000..2afff8511 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/render" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/delete.mdx b/docs/api-reference/endpoints/secret-syncs/render/delete.mdx new file mode 100644 index 000000000..eef2dbe61 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/render/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/render/get-by-id.mdx new file mode 100644 index 000000000..6918c0645 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/render/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/render/get-by-name.mdx new file mode 100644 index 000000000..c9ff4f0ff --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/render/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/render/import-secrets.mdx new file mode 100644 index 000000000..1ef9069e8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/render/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/list.mdx b/docs/api-reference/endpoints/secret-syncs/render/list.mdx new file mode 100644 index 000000000..82aa6eb88 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/render" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/render/remove-secrets.mdx new file mode 100644 index 000000000..9130316a7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/render/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/render/sync-secrets.mdx new file mode 100644 index 000000000..a08b99d42 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/render/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/render/update.mdx b/docs/api-reference/endpoints/secret-syncs/render/update.mdx new file mode 100644 index 000000000..1041ccc18 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/render/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/render/{syncId}" +--- diff --git a/docs/images/app-connections/flyio/app-connection-created.png b/docs/images/app-connections/flyio/app-connection-created.png new file mode 100644 index 000000000..befffbd02 Binary files /dev/null and b/docs/images/app-connections/flyio/app-connection-created.png differ diff --git a/docs/images/app-connections/flyio/app-connection-modal.png b/docs/images/app-connections/flyio/app-connection-modal.png new file mode 100644 index 000000000..3bf372241 Binary files /dev/null and b/docs/images/app-connections/flyio/app-connection-modal.png differ diff --git a/docs/images/app-connections/flyio/app-connection-option.png b/docs/images/app-connections/flyio/app-connection-option.png new file mode 100644 index 000000000..056ca6b76 Binary files /dev/null and b/docs/images/app-connections/flyio/app-connection-option.png differ diff --git a/docs/images/app-connections/flyio/create-token-page.png b/docs/images/app-connections/flyio/create-token-page.png new file mode 100644 index 000000000..910a6af58 Binary files /dev/null and b/docs/images/app-connections/flyio/create-token-page.png differ diff --git a/docs/images/app-connections/flyio/create-token.png b/docs/images/app-connections/flyio/create-token.png new file mode 100644 index 000000000..3dab26411 Binary files /dev/null and b/docs/images/app-connections/flyio/create-token.png differ diff --git a/docs/images/app-connections/flyio/dashboard-page.png b/docs/images/app-connections/flyio/dashboard-page.png new file mode 100644 index 000000000..ff567f1e2 Binary files /dev/null and b/docs/images/app-connections/flyio/dashboard-page.png differ diff --git a/docs/images/app-connections/render/render-account-settings.png b/docs/images/app-connections/render/render-account-settings.png new file mode 100644 index 000000000..616fc38a4 Binary files /dev/null and b/docs/images/app-connections/render/render-account-settings.png differ diff --git a/docs/images/app-connections/render/render-app-connection-created.png b/docs/images/app-connections/render/render-app-connection-created.png new file mode 100644 index 000000000..359c19104 Binary files /dev/null and b/docs/images/app-connections/render/render-app-connection-created.png differ diff --git a/docs/images/app-connections/render/render-app-connection-form.png b/docs/images/app-connections/render/render-app-connection-form.png new file mode 100644 index 000000000..90fb296e7 Binary files /dev/null and b/docs/images/app-connections/render/render-app-connection-form.png differ diff --git a/docs/images/app-connections/render/render-app-connection-select.png b/docs/images/app-connections/render/render-app-connection-select.png new file mode 100644 index 000000000..4349c7401 Binary files /dev/null and b/docs/images/app-connections/render/render-app-connection-select.png differ diff --git a/docs/images/app-connections/render/render-create-api-key.png b/docs/images/app-connections/render/render-create-api-key.png new file mode 100644 index 000000000..e6fc7aae9 Binary files /dev/null and b/docs/images/app-connections/render/render-create-api-key.png differ diff --git a/docs/images/app-connections/render/render-name-api-key.png b/docs/images/app-connections/render/render-name-api-key.png new file mode 100644 index 000000000..85c90e721 Binary files /dev/null and b/docs/images/app-connections/render/render-name-api-key.png differ diff --git a/docs/images/secret-syncs/flyio/configure-destination.png b/docs/images/secret-syncs/flyio/configure-destination.png new file mode 100644 index 000000000..d11a194ad Binary files /dev/null and b/docs/images/secret-syncs/flyio/configure-destination.png differ diff --git a/docs/images/secret-syncs/flyio/configure-details.png b/docs/images/secret-syncs/flyio/configure-details.png new file mode 100644 index 000000000..d0f70a3f3 Binary files /dev/null and b/docs/images/secret-syncs/flyio/configure-details.png differ diff --git a/docs/images/secret-syncs/flyio/configure-source.png b/docs/images/secret-syncs/flyio/configure-source.png new file mode 100644 index 000000000..75507f6fe Binary files /dev/null and b/docs/images/secret-syncs/flyio/configure-source.png differ diff --git a/docs/images/secret-syncs/flyio/configure-sync-options.png b/docs/images/secret-syncs/flyio/configure-sync-options.png new file mode 100644 index 000000000..f62e433e4 Binary files /dev/null and b/docs/images/secret-syncs/flyio/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/flyio/review-configuration.png b/docs/images/secret-syncs/flyio/review-configuration.png new file mode 100644 index 000000000..a76ead15b Binary files /dev/null and b/docs/images/secret-syncs/flyio/review-configuration.png differ diff --git a/docs/images/secret-syncs/flyio/select-option.png b/docs/images/secret-syncs/flyio/select-option.png new file mode 100644 index 000000000..ab5be088a Binary files /dev/null and b/docs/images/secret-syncs/flyio/select-option.png differ diff --git a/docs/images/secret-syncs/flyio/sync-created.png b/docs/images/secret-syncs/flyio/sync-created.png new file mode 100644 index 000000000..30a23f0d9 Binary files /dev/null and b/docs/images/secret-syncs/flyio/sync-created.png differ diff --git a/docs/images/secret-syncs/render/render-sync-created.png b/docs/images/secret-syncs/render/render-sync-created.png new file mode 100644 index 000000000..726cd14f6 Binary files /dev/null and b/docs/images/secret-syncs/render/render-sync-created.png differ diff --git a/docs/images/secret-syncs/render/render-sync-destination.png b/docs/images/secret-syncs/render/render-sync-destination.png new file mode 100644 index 000000000..67fd1af85 Binary files /dev/null and b/docs/images/secret-syncs/render/render-sync-destination.png differ diff --git a/docs/images/secret-syncs/render/render-sync-details.png b/docs/images/secret-syncs/render/render-sync-details.png new file mode 100644 index 000000000..ff50b26cd Binary files /dev/null and b/docs/images/secret-syncs/render/render-sync-details.png differ diff --git a/docs/images/secret-syncs/render/render-sync-options.png b/docs/images/secret-syncs/render/render-sync-options.png new file mode 100644 index 000000000..25cc9dad5 Binary files /dev/null and b/docs/images/secret-syncs/render/render-sync-options.png differ diff --git a/docs/images/secret-syncs/render/render-sync-review.png b/docs/images/secret-syncs/render/render-sync-review.png new file mode 100644 index 000000000..ab39faf20 Binary files /dev/null and b/docs/images/secret-syncs/render/render-sync-review.png differ diff --git a/docs/images/secret-syncs/render/render-sync-source.png b/docs/images/secret-syncs/render/render-sync-source.png new file mode 100644 index 000000000..436e1d621 Binary files /dev/null and b/docs/images/secret-syncs/render/render-sync-source.png differ diff --git a/docs/images/secret-syncs/render/select-render-option.png b/docs/images/secret-syncs/render/select-render-option.png new file mode 100644 index 000000000..6e47be640 Binary files /dev/null and b/docs/images/secret-syncs/render/select-render-option.png differ diff --git a/docs/integrations/app-connections/flyio.mdx b/docs/integrations/app-connections/flyio.mdx new file mode 100644 index 000000000..e42756254 --- /dev/null +++ b/docs/integrations/app-connections/flyio.mdx @@ -0,0 +1,96 @@ +--- +title: "Fly.io Connection" +description: "Learn how to configure a Fly.io Connection for Infisical." +--- + +Infisical supports the use of [Access Tokens](https://fly.io/docs/security/tokens/) to connect with Fly.io. + +## Create Fly.io Access Token + + + + ![Dashboard Page](/images/app-connections/flyio/dashboard-page.png) + + + ![Click Create Token](/images/app-connections/flyio/create-token.png) + + + Ensure that you give this token access to the correct app, then click 'Create Token'. + + ![Create Token Page](/images/app-connections/flyio/create-token-page.png) + + + After clicking 'Create Token', a modal containing your access token will appear. Save this token for later steps. + + + +## Create Fly.io Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Fly.io Connection** option from the available integrations. + + ![Select Fly.io Connection](/images/app-connections/flyio/app-connection-option.png) + + + Complete the Fly.io Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The Access Token from earlier steps + + ![Fly.io Connection Modal](/images/app-connections/flyio/app-connection-modal.png) + + + After clicking Create, your **Fly.io Connection** is established and ready to use with your Infisical projects. + + ![Fly.io Connection Created](/images/app-connections/flyio/app-connection-created.png) + + + + + To create a Fly.io Connection, make an API request to the [Create Fly.io Connection](/api-reference/endpoints/app-connections/flyio/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/flyio \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-flyio-connection", + "method": "access-token", + "credentials": { + "accessToken": "[PRIVATE TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-flyio-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": "flyio", + "method": "access-token", + "credentials": {} + } + } + ``` + + diff --git a/docs/integrations/app-connections/render.mdx b/docs/integrations/app-connections/render.mdx new file mode 100644 index 000000000..580ec953e --- /dev/null +++ b/docs/integrations/app-connections/render.mdx @@ -0,0 +1,55 @@ +--- +title: "Render Connection" +description: "Learn how to configure a Render Connection for Infisical." +--- + +Infisical supports connecting to Render using API keys for secure access to your Render services. + +## Configure API Key for Infisical + + + + Navigate to your Render dashboard and click on **Account Settings** in the + top right corner. ![Account + Settings](/images/app-connections/render/render-account-settings.png) + + + In the Account Settings page, scroll down to the **API Keys** section and + click **Create API Key**. ![Create API + Key](/images/app-connections/render/render-create-api-key.png) + + + Enter a descriptive name for your API key (e.g., "production") + and click **Create API Key**. ![Name API + Key](/images/app-connections/render/render-name-api-key.png) + + + After creation, you'll be shown your API key. Make sure to copy and securely + store this key as it will not be shown again. + + + +## Setup Render Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** + page. ![App Connections + Tab](/images/app-connections/general/add-connection.png) + + + Select the **Render Connection** option from the connection options modal. + ![Select Render + Connection](/images/app-connections/render/render-app-connection-select.png) + + + Enter your Render API key in the provided field and click **Connect to + Render** to establish the connection. ![Connect to + Render](/images/app-connections/render/render-app-connection-form.png) + + + Your **Render Connection** is now available for use in your Infisical + projects. ![Render Connection + Created](/images/app-connections/render/render-app-connection-created.png) + + diff --git a/docs/integrations/cloud/render.mdx b/docs/integrations/cloud/render.mdx index 366316171..1d4860ebd 100644 --- a/docs/integrations/cloud/render.mdx +++ b/docs/integrations/cloud/render.mdx @@ -3,30 +3,7 @@ title: "Render" description: "How to sync secrets from Infisical to Render" --- -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Render API Key in your Render Account Settings > API Keys. - - ![integrations render dashboard](../../images/integrations/render/integrations-render-dashboard.png) - ![integrations render token](../../images/integrations/render/integrations-render-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Render tile and input your Render API Key to grant Infisical access to your Render account. - - ![integrations render authorization](../../images/integrations/render/integrations-render-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Render service and press create integration to start syncing secrets to Render. - - ![integrations render](../../images/integrations/render/integrations-render-create.png) - ![integrations render](../../images/integrations/render/integrations-render.png) - - \ No newline at end of file + + The Render Native Integration will be deprecated in 2026. Please migrate to + our new [Render Sync](../secret-syncs/render). + diff --git a/docs/integrations/secret-syncs/flyio.mdx b/docs/integrations/secret-syncs/flyio.mdx new file mode 100644 index 000000000..adc8a32d0 --- /dev/null +++ b/docs/integrations/secret-syncs/flyio.mdx @@ -0,0 +1,155 @@ +--- +title: "Fly.io Sync" +description: "Learn how to configure a Fly.io Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Fly.io Connection](/integrations/app-connections/flyio) + + + + + + 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 Fly.io](/images/secret-syncs/flyio/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/flyio/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/flyio/configure-destination.png) + + - **Fly.io Connection**: The Fly.io Connection to authenticate with. + - **App**: The Fly.io app to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/flyio/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + + Fly.io 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 Fly.io Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/flyio/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Fly.io Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/flyio/review-configuration.png) + + + If enabled, your Fly.io Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/flyio/sync-created.png) + + + + + To create a **Fly.io Sync**, make an API request to the [Create Fly.io Sync](/api-reference/endpoints/secret-syncs/flyio/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/flyio \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-flyio-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "appId": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-flyio-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "flyio", + "name": "my-flyio-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": "flyio", + "destinationConfig": { + "appId": "..." + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/render.mdx b/docs/integrations/secret-syncs/render.mdx new file mode 100644 index 000000000..341d84ad5 --- /dev/null +++ b/docs/integrations/secret-syncs/render.mdx @@ -0,0 +1,135 @@ +--- +title: "Render Sync" +description: "Learn how to configure a Render Sync for Infisical." +--- + +**Prerequisites:** + +- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) +- Create a [Render Connection](/integrations/app-connections/render) + + + + 1. 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) + + 2. Select the **Render** option. + ![Select Render](/images/secret-syncs/render/select-render-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/render/render-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). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) + + - **Render Connection**: The Render Connection to authenticate with. + - **Scope**: Select **Service**. + - **Service**: Choose the Render service you want to sync secrets to. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/render/render-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the Render service before syncing, prioritizing values from Infisical over Render when keys conflict. + - **Import Secrets (Prioritize Render)**: Imports secrets from the Render service before syncing, prioritizing values from Render over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + - **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. + + 6. Configure the **Details** of your Render Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/render/render-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Render Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/render/render-sync-review.png) + + 8. If enabled, your Render Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/render/render-sync-created.png) + + + + To create a **Render Sync**, make an API request to the [Create Render Sync](/api-reference/endpoints/secret-syncs/render/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/render \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-render-sync", + "projectId": "your-project-id", + "description": "an example sync", + "connectionId": "your-render-connection-id", + "environment": "production", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "scope": "service", + "serviceId": "your-render-service-id", + "type": "env" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "your-sync-id", + "name": "my-render-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "your-folder-id", + "connectionId": "your-render-connection-id", + "createdAt": "2024-05-01T12:00:00Z", + "updatedAt": "2024-05-01T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2024-05-01T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "your-project-id", + "connection": { + "app": "render", + "name": "my-render-connection", + "id": "your-render-connection-id" + }, + "environment": { + "slug": "production", + "name": "Production", + "id": "your-env-id" + }, + "folder": { + "id": "your-folder-id", + "path": "/my-secrets" + }, + "destination": "render", + "destinationConfig": { + "scope": "service", + "serviceId": "your-render-service-id", + "type": "env" + } + } + } + ``` + + + diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index da9351188..c60db5afc 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -12,7 +12,7 @@ Each permission consists of: - **Subject**: The resource the permission applies to (e.g., secrets, members, settings) - **Action**: The operation that can be performed (e.g., read, create, edit, delete) -Some project-level resources—specifically `secrets`, `secret-folders`, `secret-imports`, and `dynamic-secrets`—support conditional permissions and permission inversion for more granular access control. Conditions allow you to specify criteria (like environment, secret path, or tags) that must be met for the permission to apply. +Some project-level resources—specifically `secrets`, `secret-folders`, `secret-imports`, `dynamic-secrets`, and `secret-syncs`, support conditional permissions and permission inversion for more granular access control. Conditions allow you to specify criteria (like environment, secret path, or tags) that must be met for the permission to apply. ## Available Project Permissions @@ -208,6 +208,8 @@ Supports conditions and permission inversion #### Subject: `secret-syncs` +Supports conditions and permission inversion. + | Action | Description | | ---------------- | -------------------------------------------------- | | `read` | View secret synchronization configurations | diff --git a/docs/mint.json b/docs/mint.json index 3f87206e7..f90554102 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -396,7 +396,8 @@ "pages": [ "self-hosting/guides/mongo-to-postgres", "self-hosting/guides/custom-certificates", - "self-hosting/guides/automated-bootstrapping" + "self-hosting/guides/automated-bootstrapping", + "self-hosting/guides/production-hardening" ] }, { @@ -503,6 +504,7 @@ "integrations/app-connections/azure-key-vault", "integrations/app-connections/camunda", "integrations/app-connections/databricks", + "integrations/app-connections/flyio", "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/github-radar", @@ -515,6 +517,7 @@ "integrations/app-connections/oci", "integrations/app-connections/oracledb", "integrations/app-connections/postgres", + "integrations/app-connections/render", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", "integrations/app-connections/vercel", @@ -538,12 +541,14 @@ "integrations/secret-syncs/azure-key-vault", "integrations/secret-syncs/camunda", "integrations/secret-syncs/databricks", + "integrations/secret-syncs/flyio", "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/oci-vault", + "integrations/secret-syncs/render", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", @@ -666,9 +671,9 @@ "sdks/languages/node", "sdks/languages/python", "sdks/languages/java", + "sdks/languages/csharp", "sdks/languages/go", - "sdks/languages/ruby", - "sdks/languages/csharp" + "sdks/languages/ruby" ] }, { @@ -1264,6 +1269,18 @@ "api-reference/endpoints/app-connections/databricks/delete" ] }, + { + "group": "Fly.io", + "pages": [ + "api-reference/endpoints/app-connections/flyio/list", + "api-reference/endpoints/app-connections/flyio/available", + "api-reference/endpoints/app-connections/flyio/get-by-id", + "api-reference/endpoints/app-connections/flyio/get-by-name", + "api-reference/endpoints/app-connections/flyio/create", + "api-reference/endpoints/app-connections/flyio/update", + "api-reference/endpoints/app-connections/flyio/delete" + ] + }, { "group": "GCP", "pages": [ @@ -1408,6 +1425,18 @@ "api-reference/endpoints/app-connections/postgres/delete" ] }, + { + "group": "Render", + "pages": [ + "api-reference/endpoints/app-connections/render/list", + "api-reference/endpoints/app-connections/render/available", + "api-reference/endpoints/app-connections/render/get-by-id", + "api-reference/endpoints/app-connections/render/get-by-name", + "api-reference/endpoints/app-connections/render/create", + "api-reference/endpoints/app-connections/render/update", + "api-reference/endpoints/app-connections/render/delete" + ] + }, { "group": "TeamCity", "pages": [ @@ -1573,6 +1602,19 @@ "api-reference/endpoints/secret-syncs/databricks/remove-secrets" ] }, + { + "group": "Fly.io", + "pages": [ + "api-reference/endpoints/secret-syncs/flyio/list", + "api-reference/endpoints/secret-syncs/flyio/get-by-id", + "api-reference/endpoints/secret-syncs/flyio/get-by-name", + "api-reference/endpoints/secret-syncs/flyio/create", + "api-reference/endpoints/secret-syncs/flyio/update", + "api-reference/endpoints/secret-syncs/flyio/delete", + "api-reference/endpoints/secret-syncs/flyio/sync-secrets", + "api-reference/endpoints/secret-syncs/flyio/remove-secrets" + ] + }, { "group": "GCP Secret Manager", "pages": [ @@ -1654,6 +1696,20 @@ "api-reference/endpoints/secret-syncs/oci-vault/remove-secrets" ] }, + { + "group": "Render", + "pages": [ + "api-reference/endpoints/secret-syncs/render/list", + "api-reference/endpoints/secret-syncs/render/get-by-id", + "api-reference/endpoints/secret-syncs/render/get-by-name", + "api-reference/endpoints/secret-syncs/render/create", + "api-reference/endpoints/secret-syncs/render/update", + "api-reference/endpoints/secret-syncs/render/delete", + "api-reference/endpoints/secret-syncs/render/sync-secrets", + "api-reference/endpoints/secret-syncs/render/import-secrets", + "api-reference/endpoints/secret-syncs/render/remove-secrets" + ] + }, { "group": "TeamCity", "pages": [ diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx index 4cf75a5c6..71ac7337e 100644 --- a/docs/sdks/languages/csharp.mdx +++ b/docs/sdks/languages/csharp.mdx @@ -1,9 +1,10 @@ --- title: "Infisical .NET SDK" sidebarTitle: ".NET" +url: "https://github.com/Infisical/infisical-dotnet-sdk?tab=readme-ov-file#infisical-net-sdk" icon: "bars" --- - +{/* If you're working with C#, the official [Infisical C# SDK](https://github.com/Infisical/sdk/tree/main/languages/csharp) package is the easiest way to fetch and work with secrets for your application. - [Nuget Package](https://www.nuget.org/packages/Infisical.Sdk) @@ -590,4 +591,4 @@ var decryptedPlaintext = infisical.DecryptSymmetric(decryptOptions); #### Returns (string) `Plaintext` (string): The decrypted plaintext. - + */} diff --git a/docs/self-hosting/guides/production-hardening.mdx b/docs/self-hosting/guides/production-hardening.mdx new file mode 100644 index 000000000..dfd7b575f --- /dev/null +++ b/docs/self-hosting/guides/production-hardening.mdx @@ -0,0 +1,697 @@ +--- +title: "Production Hardening" +description: "Security hardening recommendations for production Infisical deployments" +--- + +This document provides specific security hardening recommendations for production Infisical deployments. These recommendations follow Infisical's security model and focus on defense in depth. + +Choose your deployment method below and follow the recommendations for your specific setup. Start with **Universal Security Fundamentals** that apply to all deployments, then follow your deployment-specific section. + +## Universal Security Fundamentals + +These security configurations apply to **all** Infisical deployments regardless of how you deploy. + +### Cryptographic Security + +#### Generate Secure Keys + +Generate strong cryptographic keys for your deployment: + +```bash +# Required - Generate secure encryption key +ENCRYPTION_KEY=$(openssl rand -hex 16) + +# Required - Generate secure auth secret +AUTH_SECRET=$(openssl rand -base64 32) +``` + +#### Configure Token Lifetimes + +Minimize exposure window for compromised tokens: + +```bash +# JWT token configuration (adjust based on security requirements) +JWT_AUTH_LIFETIME=15m # Authentication tokens +JWT_REFRESH_LIFETIME=24h # Refresh tokens +JWT_SERVICE_LIFETIME=1h # Service tokens +``` + +### Network Security + +#### TLS Configuration + +Configure HTTPS and secure database connections: + +```bash +# Enable HTTPS (recommended for production) +HTTPS_ENABLED=true + +# Secure PostgreSQL connection with SSL +DB_CONNECTION_URI="postgresql://user:pass@host:5432/db?sslmode=require" + +# For base64-encoded SSL certificate +DB_ROOT_CERT="" +``` + +#### Redis Security + +Use authentication and TLS for Redis: + +```bash +# Redis with TLS (if supported by your Redis deployment) +REDIS_URL="rediss://user:password@redis:6380" + +# Redis Sentinel configuration for high availability +REDIS_SENTINEL_HOSTS="192.168.65.254:26379,192.168.65.254:26380" +REDIS_SENTINEL_MASTER_NAME="mymaster" +REDIS_SENTINEL_ENABLE_TLS=true +REDIS_SENTINEL_USERNAME="sentinel_user" +REDIS_SENTINEL_PASSWORD="sentinel_password" +``` + +#### Network Access Controls + +Configure network restrictions and firewall rules: + +```bash +# Limit CORS to specific domains +CORS_ALLOWED_ORIGINS=["https://your-app.example.com"] + +# Prevent connections to internal/private IP addresses +# This blocks access to internal services like metadata endpoints, +# internal APIs, databases, and other sensitive infrastructure +ALLOW_INTERNAL_IP_CONNECTIONS=false +``` + +**Implement network firewalls**. Restrict network access to only necessary services: + +- **Required ports**: Infisical API (8080) and HTTPS (if applicable) +- **Database access**: Restrict PostgreSQL and Redis to authorized sources only +- **Principle**: Default deny incoming, allow only required traffic +- **Implementation**: See your deployment-specific section below for exact configuration + +### Application Security + +#### Site Configuration + +Set proper site URL for your Infisical instance: + +```bash +# Required - Must be absolute URL with protocol +SITE_URL="https://app.infisical.com" +``` + +#### SMTP Security + +Use TLS for email communications: + +```bash +# SMTP with TLS +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_USERNAME="your-smtp-user" +SMTP_PASSWORD="your-smtp-password" +SMTP_REQUIRE_TLS=true +SMTP_IGNORE_TLS=false +SMTP_FROM_ADDRESS="noreply@example.com" +SMTP_FROM_NAME="Infisical" +``` + +#### Privacy Configuration + +Control telemetry and data collection: + +```bash +# Optional - Disable telemetry (enabled by default) +TELEMETRY_ENABLED=false +``` + +### Database Security + +#### High Availability Configuration + +Configure database read replicas for high availability PostgreSQL setups: + +```bash +# Read replica configuration (JSON format) +DB_READ_REPLICAS='[{"DB_CONNECTION_URI":"postgresql://user:pass@replica:5432/db?sslmode=require"}]' +``` + +### Operational Security + +#### User Access Management + +**Establish user off-boarding procedures**. Remove access promptly when users leave: + +1. Remove user from organization +2. Revoke active service tokens +3. Remove from external identity providers +4. Audit access logs for the user's activity +5. Rotate any shared secrets the user had access to + +#### Maintenance and Updates + +**Keep frequent upgrade cadence**. Regularly update to the latest Infisical version for your deployment method. + +## Deployment-Specific Hardening + +### Docker Deployment + +These recommendations are specific to Docker deployments of Infisical. + +#### Container Security + +**Use read-only root filesystems**. Prevent runtime modifications while allowing necessary temporary access: + +```bash +# Run with read-only filesystem but allow /tmp access +docker run --read-only \ + --tmpfs /tmp:rw,exec,size=1G \ + infisical/infisical:latest +``` + +**Note**: Infisical requires temporary directory access for: + +- Secret scanning operations +- SSH certificate generation and validation + +The `--tmpfs` mounts provide secure, isolated temporary storage that is: + +- Automatically cleaned up on container restart +- Limited in size to prevent disk exhaustion +- Isolated from the host system +- Wiped on container removal + +**Drop unnecessary capabilities**. Remove all Linux capabilities: + +```bash +# Drop all capabilities +docker run --cap-drop=ALL infisical/infisical:latest +``` + +**Use specific image tags**. Never use `latest` tags in production: + +```bash +# Use specific version tags +docker run infisical/infisical:v0.93.1-postgres +``` + +#### Resource Management + +**Set resource limits**. Prevent resource exhaustion attacks: + +```bash +# Set memory and CPU limits +docker run --memory=1g --cpus=0.5 infisical/infisical:latest +``` + +#### Health Monitoring + +**Configure health checks**. Set up Docker health checks: + +```dockerfile +# In Dockerfile or docker-compose.yml +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/api/status || exit 1 +``` + +#### Network Security + +**Host firewall configuration**. Configure host-level firewall for Docker deployments: + +```bash +# Docker manages its own iptables rules, but configure host firewall +sudo ufw default deny incoming +sudo ufw default allow outgoing + +# Allow Docker-mapped ports (adjust based on your port mapping) +sudo ufw allow 8080/tcp # If mapping container 8080 to host 8080 +sudo ufw allow 443/tcp # If terminating HTTPS at host level + +# Enable firewall +sudo ufw --force enable + +# Verify Docker iptables integration +sudo iptables -L DOCKER +``` + +#### Maintenance + +**Regular updates**. Monitor [Docker Hub](https://hub.docker.com/r/infisical/infisical/tags) for new releases and update your image tags regularly. + +### Kubernetes Deployment + +These recommendations are specific to Kubernetes deployments of Infisical. + +#### Pod Security + +**Use Pod Security Standards**. Apply restricted security profile: + +```yaml +# Namespace-level Pod Security Standards +apiVersion: v1 +kind: Namespace +metadata: + name: infisical + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted +``` + +**Configure security context**. Set comprehensive security context: + +```yaml +# Deployment security context +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infisical +spec: + template: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + fsGroup: 1001 + containers: + - name: infisical + image: infisical/infisical:v0.93.1-postgres + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + capabilities: + drop: + - ALL + resources: + limits: + memory: 1000Mi + cpu: 500m + requests: + cpu: 350m + memory: 512Mi +``` + +#### Network Security + +**Configure network policies**. Restrict pod-to-pod communication: + +```yaml +# Example Kubernetes NetworkPolicy +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: infisical-netpol + namespace: infisical +spec: + podSelector: + matchLabels: + app: infisical + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + name: ingress-system + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - podSelector: + matchLabels: + app: postgres + ports: + - protocol: TCP + port: 5432 + - to: + - podSelector: + matchLabels: + app: redis + ports: + - protocol: TCP + port: 6379 +``` + +**Infrastructure firewall considerations**. In addition to the universal host firewalls, implement infrastructure-level security: + +For cloud deployments (AWS Security Groups, Azure NSGs, or GCP Firewall Rules): + +- Allow ingress from load balancer to NodePort/ClusterIP service +- Allow egress to managed databases +- Block all other traffic + +For on-premises deployments, ensure node-level firewalls allow: + +- Ingress traffic from ingress controllers +- Egress traffic to external services (databases, SMTP) + +#### Access Control + +**Use dedicated service accounts**. Create service accounts with minimal permissions: + +```yaml +# Service account configuration +apiVersion: v1 +kind: ServiceAccount +metadata: + name: infisical + namespace: infisical +automountServiceAccountToken: false +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infisical +spec: + template: + spec: + serviceAccountName: infisical +``` + +#### Ingress Security + +**Configure ingress with TLS**. Set up secure ingress: + +```yaml +# Secure ingress configuration +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: infisical-ingress + namespace: infisical + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" +spec: + ingressClassName: nginx + tls: + - secretName: infisical-tls + hosts: + - app.example.com + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: infisical + port: + number: 8080 +``` + +#### Secret Management + +**Use Kubernetes secrets**. Store sensitive configuration securely: + +```yaml +# Kubernetes secret for environment variables +apiVersion: v1 +kind: Secret +metadata: + name: infisical-secrets + namespace: infisical +type: Opaque +stringData: + AUTH_SECRET: "" + ENCRYPTION_KEY: "" + DB_CONNECTION_URI: "" + REDIS_URL: "" + SITE_URL: "" +``` + +**Note:** Kubernetes secrets are only base64-encoded by default and are not encrypted at rest unless you explicitly enable etcd encryption. For production environments, you should: + +- Enable [etcd encryption at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) to protect secrets stored in the cluster +- Limit access to etcd and Kubernetes API to only trusted administrators + +#### Health Monitoring + +**Set up health checks**. Configure readiness and liveness probes: + +```yaml +# Health check configuration +containers: + - name: infisical + readinessProbe: + httpGet: + path: /api/status + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /api/status + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 +``` + +#### Infrastructure Considerations + +**Use managed databases (if possible)**. For production deployments, consider using managed PostgreSQL and Redis services instead of in-cluster instances when feasible, as they typically provide better security, backup, and maintenance capabilities. + +#### Maintenance + +**Regular updates**. Monitor [Docker Hub](https://hub.docker.com/r/infisical/infisical/tags) for new releases and update your deployment manifests with new image tags regularly. + +### Linux Binary Deployment + +These recommendations are specific to Linux binary deployments of Infisical. + +#### System User Management + +**Create dedicated user account**. Run Infisical under a dedicated service account: + +```bash +# Create dedicated user +sudo useradd --system --shell /bin/false --home-dir /opt/infisical infisical + +# Create application directory +sudo mkdir -p /opt/infisical +sudo chown infisical:infisical /opt/infisical +``` + +#### Service Configuration + +**Configure systemd service**. Create a secure systemd service: + +```ini +# /etc/systemd/system/infisical.service +[Unit] +Description=Infisical Secret Management +After=network.target + +[Service] +Type=simple +# IMPORTANT: Change from default 'root' user to dedicated service account +User=infisical +Group=infisical +WorkingDirectory=/opt/infisical +ExecStart=/opt/infisical/infisical-linux-amd64 +Restart=always +RestartSec=10 + +# Security settings +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/infisical +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LimitCORE=0 +MemorySwapMax=0 + +# Environment file +EnvironmentFile=/etc/infisical/environment + +[Install] +WantedBy=multi-user.target +``` + +#### Configuration Security + +**Secure environment configuration**. Store environment variables securely: + +```bash +# Create secure config directory +sudo mkdir -p /etc/infisical +sudo chmod 750 /etc/infisical +sudo chown root:infisical /etc/infisical + +# Create environment file +sudo touch /etc/infisical/environment +sudo chmod 640 /etc/infisical/environment +sudo chown root:infisical /etc/infisical/environment +``` + +#### System Security + +**Disable memory swapping**. Prevent sensitive data from being written to disk: + +```bash +# Disable swap immediately +sudo swapoff -a + +# Disable swap permanently (comment out swap entries) +sudo sed -i '/swap/d' /etc/fstab +``` + +**Disable core dumps**. Prevent potential exposure of encryption keys: + +```bash +# Set system-wide core dump limits +echo "* hard core 0" | sudo tee -a /etc/security/limits.conf + +# Disable core dumps for current session +ulimit -c 0 +``` + +#### File Permissions + +**Secure file permissions**. Set proper permissions on application files: + +```bash +# Set binary permissions +sudo chmod 755 /opt/infisical/infisical-linux-amd64 +sudo chown infisical:infisical /opt/infisical/infisical-linux-amd64 + +# Set config file permissions +sudo chmod 640 /etc/infisical/environment +sudo chown root:infisical /etc/infisical/environment +``` + +#### Network Security + +**Host firewall configuration**. Configure comprehensive firewall for Linux binary deployments: + +```bash +# Configure UFW firewall +sudo ufw default deny incoming +sudo ufw default allow outgoing + +# Allow Infisical API access +sudo ufw allow 8080/tcp + +# Allow HTTPS (if terminating TLS at Infisical) +sudo ufw allow 443/tcp + +# If running PostgreSQL locally, restrict to localhost +sudo ufw allow from 127.0.0.1 to any port 5432 + +# If running Redis locally, restrict to localhost +sudo ufw allow from 127.0.0.1 to any port 6379 + +# Enable firewall +sudo ufw --force enable +``` + +#### System Maintenance + +**Synchronize system clocks**. Ensure accurate time for JWT tokens and audit logs: + +```bash +# Install and configure NTP +sudo apt-get update +sudo apt-get install -y ntp +sudo systemctl enable ntp +sudo systemctl start ntp + +# Verify time synchronization +timedatectl status +``` + +**Regular updates**. Monitor [Cloudsmith releases](https://cloudsmith.io/~infisical/repos/infisical-core/packages) for new binary versions and update your installation regularly. + +## Enterprise Security Features + +### Hardware Security Module (HSM) Integration + +For the highest level of encryption security, integrate with Hardware Security Modules: + +HSM integration provides hardware-protected encryption keys stored on tamper-proof devices, offering superior security for encryption operations: + +- **Supported HSM Providers**: Thales Luna Cloud HSM, AWS CloudHSM, Fortanix HSM +- **Root Key Protection**: HSM encrypts Infisical's root encryption keys using hardware-protected keys +- **Enterprise Requirements**: Ideal for government, financial, and healthcare organizations + +```bash +# HSM Environment Variables (example for production) +HSM_LIB_PATH="/path/to/hsm/library.so" +HSM_PIN="your-hsm-pin" +HSM_SLOT="0" +HSM_KEY_LABEL="infisical-root-key" +``` + +For complete HSM setup instructions, see the [HSM Integration Guide](/documentation/platform/kms/hsm-integration). + +### External Key Management Service (KMS) Integration + +Leverage cloud-native KMS providers for enhanced security and compliance: + +Infisical can integrate with external KMS providers to encrypt project secrets, providing enterprise-grade key management: + +- **Supported Providers**: AWS KMS, Google Cloud KMS, Azure Key Vault (coming soon) +- **Workspace Key Protection**: Each project's encryption key is protected by your external KMS +- **Envelope Encryption**: Infisical uses your cloud KMS to encrypt/decrypt project workspace keys, which in turn encrypt the actual secret data +- **Compliance**: Leverage your cloud provider's compliance certifications (FedRAMP, SOC2, ISO 27001) + +#### Benefits for Production Deployments + +- **Separation of Concerns**: Keys managed in your cloud infrastructure, separate from Infisical +- **Regulatory Compliance**: Use your existing compliance-certified KMS infrastructure +- **Audit Integration**: KMS operations logged in your cloud provider's audit trails +- **Disaster Recovery**: Keys backed by your cloud provider's HA and backup systems +- **Access Controls**: Leverage your cloud IAM for KMS access management + +#### Configuration Resources + +For external KMS configuration, see: + +- [AWS KMS Integration](/documentation/platform/kms-configuration/aws-kms) +- [GCP KMS Integration](/documentation/platform/kms-configuration/gcp-kms) +- [External KMS Overview](/documentation/platform/kms-configuration/overview) + +## Advanced Security Configurations + +### Backup Security + +**Configure backup encryption**. Encrypt PostgreSQL backups: + +```bash +# PostgreSQL backup with encryption +pg_dump $DB_CONNECTION_URI | gpg --cipher-algo AES256 --compress-algo 1 --symmetric --output backup.sql.gpg +``` + +### Monitoring and Logging + +**Implement log monitoring**. Set up centralized logging for security analysis and audit trails. Configure your SIEM or logging platform to monitor Infisical operations. + +### Security Updates + +**Regular security updates**. Monitor the [Infisical repository](https://github.com/Infisical/infisical) for security updates and apply them promptly. + +## Compliance and Monitoring + +### Enterprise Compliance Requirements + +For enterprise deployments requiring compliance certifications: + +- Implement audit log retention policies +- Set up security event monitoring and alerting +- Configure automated vulnerability scanning +- Establish incident response procedures +- Document security controls for compliance audits + +### Standards Compliance + +**FIPS 140-3 Compliance**. Infisical is actively working on FIPS 140-3 compliance to meet U.S. and Canadian government cryptographic standards. This will provide validated cryptographic modules for organizations requiring certified encryption implementations. diff --git a/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx index 2a6767396..69c469540 100644 --- a/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx +++ b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; import { ViewMode } from "../types"; @@ -23,8 +24,11 @@ interface AccessTreeProviderProps { children: ReactNode; } +export type AccessTreeForm = { metadata: { key: string; value: string }[] }; + export const AccessTreeProvider: React.FC = ({ children }) => { const [secretName, setSecretName] = useState(""); + const formMethods = useForm({ defaultValues: { metadata: [] } }); const [viewMode, setViewMode] = useState(ViewMode.Docked); const value = useMemo( @@ -37,7 +41,11 @@ export const AccessTreeProvider: React.FC = ({ children [secretName, setSecretName, viewMode, setViewMode] ); - return {children}; + return ( + + {children} + + ); }; export const useAccessTreeContext = (): AccessTreeContextProps => { diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx index 6e3790b3c..ee3ff1ff3 100644 --- a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -1,10 +1,12 @@ import { Dispatch, SetStateAction, useState } from "react"; +import { useFormContext } from "react-hook-form"; import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Panel } from "@xyflow/react"; import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; +import { MetadataForm } from "@app/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm"; import { ViewMode } from "../types"; @@ -32,6 +34,7 @@ export const PermissionSimulation = ({ setSecretName }: TProps) => { const [expand, setExpand] = useState(false); + const { control } = useFormContext(); const handlePermissionSimulation = () => { setExpand(true); @@ -139,6 +142,11 @@ export const PermissionSimulation = ({ /> )} + {subject === ProjectPermissionSub.DynamicSecrets && ( +
+ +
+ )} )} diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index a03f8fa0f..64e9e04a3 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; @@ -7,7 +8,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; -import { useAccessTreeContext } from "../components"; +import { AccessTreeForm, useAccessTreeContext } from "../components"; import { PermissionAccess } from "../types"; import { createBaseEdge, @@ -36,6 +37,8 @@ export const useAccessTree = ( ) => { const { currentWorkspace } = useWorkspace(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); + const { control } = useFormContext(); + const metadata = useWatch({ control, name: "metadata" }); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges] = useEdgesState([]); const [subject, setSubject] = useState(ProjectPermissionSub.Secrets); @@ -168,7 +171,8 @@ export const useAccessTree = ( environment, subject, secretName, - actionRuleMap + actionRuleMap, + metadata }) ); @@ -266,7 +270,8 @@ export const useAccessTree = ( subject, secretName, setNodes, - setEdges + setEdges, + metadata ]); return { diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx index f2ca6e878..f0baedc35 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx @@ -17,6 +17,27 @@ type Props = { access: PermissionAccess; } & Pick["data"], "actionRuleMap" | "subject">; +type ConditionDisplayProps = { + _key: string; + operator: string; + value: string | string[]; +}; + +const ConditionDisplay = ({ _key: key, value, operator }: ConditionDisplayProps) => { + return ( +
  • + {camelCaseToSpaces(key)}{" "} + + {formatedConditionsOperatorNames[operator as PermissionConditionOperators]} + {" "} + + {typeof value === "string" ? value : value.join(", ")} + + . +
  • + ); +}; + export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => { let component: ReactElement; @@ -56,43 +77,58 @@ export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subjec {actionRuleMap.map((ruleMap, index) => { const rule = ruleMap[action]; - if ( - !rule || - !rule.conditions || - (!rule.conditions.secretName && !rule.conditions.secretTags) - ) - return null; + if (!rule || !rule.conditions) return null; - return ( -
  • - - {rule.inverted ? "Forbids" : "Allows"} - - when: - {Object.entries(rule.conditions).map(([key, condition]) => ( -
      - {Object.entries(condition as object).map(([operator, value]) => ( -
    • - - {camelCaseToSpaces(key)} - {" "} - - { - formatedConditionsOperatorNames[ - operator as PermissionConditionOperators - ] + if ( + rule.conditions.secretName || + rule.conditions.secretTags || + rule.conditions.metadata + ) { + return ( +
    • + {rule.inverted ? "Forbids" : "Allows"} + when: + {Object.entries(rule.conditions).map(([key, condition]) => { + if (key.match(/secretPath|environment/)) { + return null; + } + + return ( +
        + {Object.entries(condition as object).map(([operator, value]) => { + if (operator === "$elemMatch") { + return Object.entries(value as object).map( + ([nestedKey, nestedCondition]) => + Object.entries(nestedCondition as object).map( + ([nestedOperator, nestedValue]) => ( + + ) + ) + ); } - {" "} - - {typeof value === "string" ? value : value.join(", ")} - - . - - ))} -
      - ))} -
    • - ); + + return ( + + ); + })} +
    + ); + })} +
  • + ); + } + + return null; })} diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx index f6ffe212e..c3e9f89e0 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -1,5 +1,5 @@ import { Dispatch, SetStateAction } from "react"; -import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons"; +import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; @@ -12,15 +12,15 @@ import { createRoleNode } from "../utils"; const getSubjectIcon = (subject: ProjectPermissionSub) => { switch (subject) { case ProjectPermissionSub.Secrets: - return ; + return ; case ProjectPermissionSub.SecretFolders: return ; case ProjectPermissionSub.DynamicSecrets: - return ; + return ; case ProjectPermissionSub.SecretImports: - return ; + return ; default: - return ; + return ; } }; diff --git a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts index 15c64ce8a..a40398ba7 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts @@ -33,6 +33,12 @@ const ACTION_MAP: Record = { ] }; +const SUBJECT_HEIGHT_MAP: Record = { + [ProjectPermissionSub.DynamicSecrets]: 130, + [ProjectPermissionSub.Secrets]: 85, + default: 64 +}; + const evaluateCondition = ( value: string, operator: PermissionConditionOperators, @@ -52,13 +58,113 @@ const evaluateCondition = ( } }; +const doesConditionMatch = ( + conditions: Record | undefined, + value: string +): boolean => { + if (!conditions) return true; + + return Object.entries(conditions).every(([operator, comparisonValue]) => + evaluateCondition(value, operator as PermissionConditionOperators, comparisonValue) + ); +}; + +const doBaseConditionsApply = ( + ruleConditions: any, + environment: string, + folderPath: string +): boolean => { + return ( + doesConditionMatch(ruleConditions?.environment, environment) && + doesConditionMatch(ruleConditions?.secretPath, folderPath) + ); +}; + +const shouldShowConditionalAccess = ( + actionRuleMap: TActionRuleMap, + action: string, + environment: string, + folderPath: string, + conditionalFields: string[] +): boolean => { + return actionRuleMap.some((rule) => { + const ruleConditions = rule[action]?.conditions; + if (!ruleConditions) return false; + + // Check if any of the conditional fields are present + const hasConditionalField = conditionalFields.some((field) => ruleConditions[field]); + if (!hasConditionalField) return false; + + // Check if base conditions (environment and secretPath) apply + return doBaseConditionsApply(ruleConditions, environment, folderPath); + }); +}; + +const determineAccessLevel = ( + hasPermission: boolean, + subject: ProjectPermissionSub, + action: string, + actionRuleMap: TActionRuleMap, + environment: string, + folderPath: string, + secretName: string, + metadata: Array<{ key: string; value: string }> +): PermissionAccess => { + if (!hasPermission) { + return PermissionAccess.None; + } + + if (subject === ProjectPermissionSub.Secrets) { + if ( + !secretName && + shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, [ + "secretName", + "secretTags" + ]) + ) { + return PermissionAccess.Partial; + } + } else if (subject === ProjectPermissionSub.DynamicSecrets) { + if ( + !metadata.length && + shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, ["metadata"]) + ) { + return PermissionAccess.Partial; + } + } + + return PermissionAccess.Full; +}; + +const checkPermission = ( + permissions: MongoAbility, + subject: ProjectPermissionSub, + action: string, + subjectFields: any +): boolean => { + if ( + subject === ProjectPermissionSub.Secrets && + (action === ProjectPermissionSecretActions.ReadValue || + action === ProjectPermissionSecretActions.DescribeSecret) + ) { + return hasSecretReadValueOrDescribePermission(permissions, action, subjectFields); + } + + return permissions.can( + // @ts-expect-error we are not specifying which so can't resolve if valid + action, + abilitySubject(subject, subjectFields) + ); +}; + export const createFolderNode = ({ folder, permissions, environment, subject, secretName, - actionRuleMap + actionRuleMap, + metadata }: { folder: TSecretFolderWithPath; permissions: MongoAbility; @@ -66,6 +172,7 @@ export const createFolderNode = ({ subject: ProjectPermissionSub; secretName: string; actionRuleMap: TActionRuleMap; + metadata: Array<{ key: string; value: string }>; }) => { const actions = Object.fromEntries( Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => { @@ -73,74 +180,26 @@ export const createFolderNode = ({ // wrapped in try because while editing certain conditions, if their values are empty it throws an error try { - let hasPermission: boolean; - const subjectFields = { secretPath: folder.path, environment, secretName: secretName || "*", - secretTags: ["*"] + secretTags: ["*"], + metadata: metadata.length ? metadata : ["*"] }; - if ( - subject === ProjectPermissionSub.Secrets && - (action === ProjectPermissionSecretActions.ReadValue || - action === ProjectPermissionSecretActions.DescribeSecret) - ) { - hasPermission = hasSecretReadValueOrDescribePermission( - permissions, - action, - subjectFields - ); - } else { - hasPermission = permissions.can( - // @ts-expect-error we are not specifying which so can't resolve if valid - action, - abilitySubject(subject, subjectFields) - ); - } + const hasPermission = checkPermission(permissions, subject, action, subjectFields); - if (hasPermission) { - // we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access - if ( - !secretName && - actionRuleMap.some((el) => { - // we only show conditional if secretName/secretTags are present - environment and path can be directly determined - if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags) - return false; - - // make sure condition applies to env - if (el[action]?.conditions?.environment) { - if ( - !Object.entries(el[action]?.conditions?.environment).every(([operator, value]) => - evaluateCondition(environment, operator as PermissionConditionOperators, value) - ) - ) { - return false; - } - } - - // and applies to path - if (el[action]?.conditions?.secretPath) { - if ( - !Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) => - evaluateCondition(folder.path, operator as PermissionConditionOperators, value) - ) - ) { - return false; - } - } - - return true; - }) - ) { - access = PermissionAccess.Partial; - } else { - access = PermissionAccess.Full; - } - } else { - access = PermissionAccess.None; - } + access = determineAccessLevel( + hasPermission, + subject, + action, + actionRuleMap, + environment, + folder.path, + secretName, + metadata + ); } catch (e) { console.error(e); access = PermissionAccess.None; @@ -150,18 +209,7 @@ export const createFolderNode = ({ }) ); - let height: number; - - switch (subject) { - case ProjectPermissionSub.DynamicSecrets: - height = 130; - break; - case ProjectPermissionSub.Secrets: - height = 85; - break; - default: - height = 64; - } + const height = SUBJECT_HEIGHT_MAP[subject] ?? SUBJECT_HEIGHT_MAP.default; return { type: PermissionNode.Folder, diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/FlyioSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/FlyioSyncFields.tsx new file mode 100644 index 000000000..a2a5bf844 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/FlyioSyncFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { TFlyioApp, useFlyioConnectionListApps } from "@app/hooks/api/appConnections/flyio"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const FlyioSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Flyio } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: apps, isLoading: isAppsLoading } = useFlyioConnectionListApps(connectionId, { + enabled: Boolean(connectionId) + }); + + return ( + <> + { + setValue("destinationConfig.appId", ""); + }} + /> + + ( + + v.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue)?.id ?? null)} + options={apps} + placeholder="Select an app..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx new file mode 100644 index 000000000..b5cb407cb --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx @@ -0,0 +1,112 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs"; +import { + TRenderService, + useRenderConnectionListServices +} from "@app/hooks/api/appConnections/render"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/render-sync"; + +import { TSecretSyncForm } from "../schemas"; + +export const RenderSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Render } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.type", RenderSyncType.Env); + setValue("destinationConfig.scope", RenderSyncScope.Service); + }} + /> + ( + +

    + Specify how Infisical should manage secrets from Render. The following options are + available: +

    +
      + {Object.values(RENDER_SYNC_SCOPES).map(({ name, description }) => { + return ( +
    • +

      + {name}: {description} +

      +
    • + ); + })} +
    + + } + > + +
    + )} + /> + ( + + service.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.serviceName", + (option as SingleValue)?.name ?? "" + ); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 976c33efa..64345aae4 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -11,12 +11,14 @@ import { AzureDevOpsSyncFields } from "./AzureDevOpsSyncFields"; import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { CamundaSyncFields } from "./CamundaSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; +import { FlyioSyncFields } from "./FlyioSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; +import { RenderSyncFields } from "./RenderSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; @@ -64,6 +66,10 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Heroku: return ; + case SecretSync.Render: + return ; + case SecretSync.Flyio: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index d1cc0d74f..55aeca3c6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -53,6 +53,8 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.OnePass: case SecretSync.OCIVault: case SecretSync.Heroku: + case SecretSync.Render: + case SecretSync.Flyio: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/FlyioSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/FlyioSyncReviewFields.tsx new file mode 100644 index 000000000..9a1a4bf69 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/FlyioSyncReviewFields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const FlyioSyncReviewFields = () => { + const { watch } = useFormContext(); + const appId = watch("destinationConfig.appId"); + + return {appId}; +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx new file mode 100644 index 000000000..becc46c1d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const RenderSyncReviewFields = () => { + const { watch } = useFormContext(); + const serviceName = watch("destinationConfig.serviceName"); + const scope = watch("destinationConfig.scope"); + + return ( + <> + {scope} + {serviceName} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 437d07d36..2131518a0 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -20,6 +20,7 @@ import { AzureDevOpsSyncReviewFields } from "./AzureDevOpsSyncReviewFields"; import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; +import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; @@ -27,6 +28,7 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; +import { RenderSyncReviewFields } from "./RenderSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; @@ -108,6 +110,12 @@ export const SecretSyncReviewFields = () => { case SecretSync.Heroku: DestinationFieldsComponent = ; break; + case SecretSync.Render: + DestinationFieldsComponent = ; + break; + case SecretSync.Flyio: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx index 7cc19ae97..850ebbc80 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncSourceFields.tsx @@ -1,17 +1,45 @@ +import { useEffect } from "react"; import { Controller, useFormContext } from "react-hook-form"; +import { subject } from "@casl/ability"; import { FilterableSelect, FormControl } from "@app/components/v2"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; -import { useWorkspace } from "@app/context"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { + ProjectPermissionSecretSyncActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { TSecretSyncForm } from "./schemas"; export const SecretSyncSourceFields = () => { - const { control, watch } = useFormContext(); + const { control, watch, setError, clearErrors } = useFormContext(); + const { permission } = useProjectPermission(); const { currentWorkspace } = useWorkspace(); const selectedEnvironment = watch("environment"); + const selectedSecretPath = watch("secretPath"); + + useEffect(() => { + const hasAccessToSource = + selectedEnvironment && + permission.can( + ProjectPermissionSecretSyncActions.Create, + subject(ProjectPermissionSub.SecretSyncs, { + environment: selectedEnvironment.slug, + secretPath: selectedSecretPath + }) + ); + + if (!hasAccessToSource) { + setError("secretPath", { + message: "You do not have permission to create secret syncs in this environment or path." + }); + } else { + clearErrors("secretPath"); + } + }, [selectedEnvironment, selectedSecretPath]); return ( <> diff --git a/frontend/src/components/secret-syncs/forms/schemas/flyio-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/flyio-sync-destination-schema.ts new file mode 100644 index 000000000..b18081ccc --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/flyio-sync-destination-schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const FlyioSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Flyio), + destinationConfig: z.object({ + appId: z.string().trim().min(1, "App ID required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts new file mode 100644 index 000000000..16b213421 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/render-sync"; + +export const RenderSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Render), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(RenderSyncScope.Service), + serviceId: z.string().trim().min(1, "Service is required"), + serviceName: z.string().trim().optional(), + type: z.nativeEnum(RenderSyncType) + }) + ]) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 381ddac8e..b296cb6d6 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -8,12 +8,14 @@ import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destinatio import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; +import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; +import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; @@ -37,7 +39,9 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ TeamCitySyncDestinationSchema, OCIVaultSyncDestinationSchema, OnePassSyncDestinationSchema, - HerokuSyncDestinationSchema + HerokuSyncDestinationSchema, + RenderSyncDestinationSchema, + FlyioSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 417e450bd..c1f9306fa 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -70,7 +70,15 @@ export const Pagination = ({ key={`pagination-per-page-options-${perPageOption}`} icon={perPage === perPageOption && } iconPos="right" - onClick={() => onChangePerPage(perPageOption)} + onClick={() => { + const totalPages = Math.ceil(count / perPageOption); + + if (page > totalPages) { + onChangePage(totalPages); + } + + onChangePerPage(perPageOption); + }} > {perPageOption} rows per page diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index c82e492e8..a1d4fb2a2 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -137,7 +137,7 @@ export const SecretPathInput = ({ maxHeight: "var(--radix-select-content-available-height)" }} > -
    +
    {suggestions.map((suggestion, i) => (
    & DynamicSecretSubjectFields) ) ] + | [ + ProjectPermissionSecretSyncActions, + ( + | ProjectPermissionSub.SecretSyncs + | (ForcedSubject & SecretSyncSubjectFields) + ) + ] | [ ProjectPermissionActions, ( @@ -365,7 +377,6 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] - | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 4c16c429d..d0e5be036 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -19,6 +19,7 @@ import { AzureKeyVaultConnectionMethod, CamundaConnectionMethod, DatabricksConnectionMethod, + FlyioConnectionMethod, GcpConnectionMethod, GitHubConnectionMethod, GitHubRadarConnectionMethod, @@ -38,6 +39,7 @@ import { } from "@app/hooks/api/appConnections/types"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; +import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; export const APP_CONNECTION_MAP: Record< AppConnection, @@ -80,7 +82,9 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }, [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }, - [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" } + [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, + [AppConnection.Render]: { name: "Render", image: "Render.png" }, + [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -120,6 +124,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case TeamCityConnectionMethod.AccessToken: case AzureDevOpsConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: + case FlyioConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; @@ -129,6 +134,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Simple Bind", icon: faLink }; case HerokuConnectionMethod.AuthToken: return { name: "Auth Token", icon: faKey }; + case RenderConnectionMethod.ApiKey: + return { name: "API Key", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 477f92425..afcd32958 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -4,6 +4,7 @@ import { SecretSyncImportBehavior, SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/render-sync"; import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; @@ -64,6 +65,14 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.TeamCity]: AppConnection.TeamCity, [SecretSync.OCIVault]: AppConnection.OCI, [SecretSync.OnePass]: AppConnection.OnePass, - [SecretSync.Heroku]: AppConnection.Heroku + [SecretSync.Heroku]: AppConnection.Heroku, + [SecretSync.Render]: AppConnection.Render, + [SecretSync.Flyio]: AppConnection.Flyio }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< @@ -146,3 +157,10 @@ export const GCP_SYNC_SCOPES: Record = { + [RenderSyncScope.Service]: { + name: "Service", + description: "Infisical will sync secrets to the specified Render service." + } +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 144cef776..d4ffdaba3 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -23,5 +23,7 @@ export enum AppConnection { TeamCity = "teamcity", OCI = "oci", OnePass = "1password", - Heroku = "heroku" + Heroku = "heroku", + Render = "render", + Flyio = "flyio" } diff --git a/frontend/src/hooks/api/appConnections/flyio/index.ts b/frontend/src/hooks/api/appConnections/flyio/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/flyio/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/flyio/queries.tsx b/frontend/src/hooks/api/appConnections/flyio/queries.tsx new file mode 100644 index 000000000..32c1ffbc2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/flyio/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TFlyioApp } from "./types"; + +const flyioConnectionKeys = { + all: [...appConnectionKeys.all, "flyio"] as const, + listApps: (connectionId: string) => [...flyioConnectionKeys.all, "apps", connectionId] as const +}; + +export const useFlyioConnectionListApps = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TFlyioApp[], + unknown, + TFlyioApp[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: flyioConnectionKeys.listApps(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/flyio/${connectionId}/apps` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/flyio/types.ts b/frontend/src/hooks/api/appConnections/flyio/types.ts new file mode 100644 index 000000000..73345e628 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/flyio/types.ts @@ -0,0 +1,4 @@ +export type TFlyioApp = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/render/index.ts b/frontend/src/hooks/api/appConnections/render/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/render/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/render/queries.tsx b/frontend/src/hooks/api/appConnections/render/queries.tsx new file mode 100644 index 000000000..728c46bd9 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/render/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TRenderService } from "./types"; + +const renderConnectionKeys = { + all: [...appConnectionKeys.all, "render"] as const, + listServices: (connectionId: string) => + [...renderConnectionKeys.all, "services", connectionId] as const +}; + +export const useRenderConnectionListServices = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRenderService[], + unknown, + TRenderService[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: renderConnectionKeys.listServices(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/render/${connectionId}/services` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/render/types.ts b/frontend/src/hooks/api/appConnections/render/types.ts new file mode 100644 index 000000000..ec51adb78 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/render/types.ts @@ -0,0 +1,4 @@ +export type TRenderService = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index cd5323c3c..1332354ec 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -115,6 +115,14 @@ export type TOnePassConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OnePass; }; +export type TRenderConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Render; +}; + +export type TFlyioConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Flyio; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -138,7 +146,9 @@ export type TAppConnectionOption = | TTeamCityConnectionOption | TOCIConnectionOption | TOnePassConnectionOption - | THerokuConnectionOption; + | THerokuConnectionOption + | TRenderConnectionOption + | TFlyioConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -166,4 +176,6 @@ export type TAppConnectionOptionMap = { [AppConnection.OCI]: TOCIConnectionOption; [AppConnection.OnePass]: TOnePassConnectionOption; [AppConnection.Heroku]: THerokuConnectionOption; + [AppConnection.Render]: TRenderConnectionOption; + [AppConnection.Flyio]: TFlyioConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/flyio-connection.ts b/frontend/src/hooks/api/appConnections/types/flyio-connection.ts new file mode 100644 index 000000000..b1c9123b6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/flyio-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum FlyioConnectionMethod { + AccessToken = "access-token" +} + +export type TFlyioConnection = TRootAppConnection & { app: AppConnection.Flyio } & { + method: FlyioConnectionMethod.AccessToken; + credentials: { + accessToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index e5b5569c9..413ad0782 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -9,6 +9,7 @@ import { TAzureDevOpsConnection } from "./azure-devops-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TDatabricksConnection } from "./databricks-connection"; +import { TFlyioConnection } from "./flyio-connection"; import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; import { TGitHubRadarConnection } from "./github-radar-connection"; @@ -21,6 +22,7 @@ import { TMySqlConnection } from "./mysql-connection"; import { TOCIConnection } from "./oci-connection"; import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TRenderConnection } from "./render-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; @@ -35,6 +37,7 @@ export * from "./azure-devops-connection"; export * from "./azure-key-vault-connection"; export * from "./camunda-connection"; export * from "./databricks-connection"; +export * from "./flyio-connection"; export * from "./gcp-connection"; export * from "./github-connection"; export * from "./github-radar-connection"; @@ -47,6 +50,7 @@ export * from "./mysql-connection"; export * from "./oci-connection"; export * from "./oracledb-connection"; export * from "./postgres-connection"; +export * from "./render-connection"; export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; @@ -77,7 +81,9 @@ export type TAppConnection = | TTeamCityConnection | TOCIConnection | TOnePassConnection - | THerokuConnection; + | THerokuConnection + | TRenderConnection + | TFlyioConnection; export type TAvailableAppConnection = Pick; @@ -130,4 +136,6 @@ export type TAppConnectionMap = { [AppConnection.OCI]: TOCIConnection; [AppConnection.OnePass]: TOnePassConnection; [AppConnection.Heroku]: THerokuConnection; + [AppConnection.Render]: TRenderConnection; + [AppConnection.Flyio]: TFlyioConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/render-connection.ts b/frontend/src/hooks/api/appConnections/types/render-connection.ts new file mode 100644 index 000000000..bc59c4c39 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/render-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum RenderConnectionMethod { + ApiKey = "api-key" +} + +export type TRenderConnection = TRootAppConnection & { app: AppConnection.Render } & { + method: RenderConnectionMethod.ApiKey; + credentials: { + apiKey: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 006a3d7ca..56dc11aeb 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -242,6 +242,7 @@ interface CreateIdentityEvent { metadata: { identityId: string; name: string; + hasDeleteProtection: boolean; }; } @@ -250,6 +251,7 @@ interface UpdateIdentityEvent { metadata: { identityId: string; name?: string; + hasDeleteProtection?: boolean; }; } diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 52fe52dc2..7abf1a015 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -85,12 +85,13 @@ export const useCreateIdentity = () => { export const useUpdateIdentity = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ identityId, name, role, metadata }) => { + mutationFn: async ({ identityId, name, role, hasDeleteProtection, metadata }) => { const { data: { identity } } = await apiRequest.patch(`/api/v1/identities/${identityId}`, { name, role, + hasDeleteProtection, metadata }); diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 116030131..873338f09 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -14,6 +14,7 @@ export type IdentityTrustedIp = { export type Identity = { id: string; name: string; + hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; createdAt: string; updatedAt: string; @@ -83,6 +84,7 @@ export type CreateIdentityDTO = { name: string; organizationId: string; role?: string; + hasDeleteProtection: boolean; metadata?: { key: string; value: string }[]; }; @@ -90,6 +92,7 @@ export type UpdateIdentityDTO = { identityId: string; name?: string; role?: string; + hasDeleteProtection?: boolean; organizationId: string; metadata?: { key: string; value: string }[]; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index a0f2b6135..a59ba20c9 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -16,7 +16,9 @@ export enum SecretSync { TeamCity = "teamcity", OCIVault = "oci-vault", OnePass = "1password", - Heroku = "heroku" + Heroku = "heroku", + Render = "render", + Flyio = "flyio" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/render-sync.ts b/frontend/src/hooks/api/secretSyncs/render-sync.ts new file mode 100644 index 000000000..ecac7d077 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/render-sync.ts @@ -0,0 +1,28 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TRenderSync = TRootSecretSync & { + destination: SecretSync.Render; + destinationConfig: { + scope: RenderSyncScope.Service; + type: RenderSyncType; + serviceId: string; + serviceName?: string; + }; + + connection: { + app: AppConnection.Render; + name: string; + id: string; + }; +}; + +export enum RenderSyncScope { + Service = "service" +} + +export enum RenderSyncType { + Env = "env", + File = "file" +} diff --git a/frontend/src/hooks/api/secretSyncs/types/flyio-sync.ts b/frontend/src/hooks/api/secretSyncs/types/flyio-sync.ts new file mode 100644 index 000000000..0717de2c4 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/flyio-sync.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TFlyioSync = TRootSecretSync & { + destination: SecretSync.Flyio; + destinationConfig: { + appId: string; + }; + connection: { + app: AppConnection.Flyio; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index bbc054ffd..aa46b5988 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -1,6 +1,7 @@ import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; import { DiscriminativePick } from "@app/types"; +import { TRenderSync } from "../render-sync"; import { TOnePassSync } from "./1password-sync"; import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; @@ -9,6 +10,7 @@ import { TAzureDevOpsSync } from "./azure-devops-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; import { TDatabricksSync } from "./databricks-sync"; +import { TFlyioSync } from "./flyio-sync"; import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; import { THCVaultSync } from "./hc-vault-sync"; @@ -45,7 +47,9 @@ export type TSecretSync = | TTeamCitySync | TOCIVaultSync | TOnePassSync - | THerokuSync; + | THerokuSync + | TRenderSync + | TFlyioSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 2b09143dc..829b55674 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -15,7 +15,8 @@ import { IconButton, Input, Modal, - ModalContent + ModalContent, + Switch } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { findOrgMembershipRole } from "@app/helpers/roles"; @@ -27,6 +28,7 @@ const schema = z .object({ name: z.string().min(1, "Required"), role: z.object({ slug: z.string(), name: z.string() }), + hasDeleteProtection: z.boolean(), metadata: z .object({ key: z.string().trim().min(1), @@ -64,7 +66,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { } = useForm({ resolver: zodResolver(schema), defaultValues: { - name: "" + name: "", + hasDeleteProtection: false } }); @@ -78,6 +81,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { identityId: string; name: string; role: string; + hasDeleteProtection: boolean; metadata?: { key: string; value: string }[]; customRole: { name: string; @@ -91,22 +95,25 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset({ name: identity.name, role: identity.customRole ?? findOrgMembershipRole(roles, identity.role), + hasDeleteProtection: identity.hasDeleteProtection, metadata: identity.metadata }); } else { reset({ name: "", - role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole), + hasDeleteProtection: false }); } }, [popUp?.identity?.data, roles]); - const onFormSubmit = async ({ name, role, metadata }: FormData) => { + const onFormSubmit = async ({ name, role, metadata, hasDeleteProtection }: FormData) => { try { const identity = popUp?.identity?.data as { identityId: string; name: string; role: string; + hasDeleteProtection: boolean; }; if (identity) { @@ -116,6 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { identityId: identity.identityId, name, role: role.slug || undefined, + hasDeleteProtection, organizationId: orgId, metadata }); @@ -127,6 +135,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: createdId } = await createMutateAsync({ name, role: role.slug || undefined, + hasDeleteProtection, organizationId: orgId, metadata }); @@ -215,6 +224,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> + ( + + +

    Delete Protection {value ? "Enabled" : "Disabled"}

    +
    +
    + )} + />
    diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index d499a7b36..f445505e0 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -329,7 +329,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
    - + { return ; case AppConnection.Heroku: return ; + case AppConnection.Render: + return ; + case AppConnection.Flyio: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -208,6 +214,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Heroku: return ; + case AppConnection.Render: + return ; + case AppConnection.Flyio: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/FlyioConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/FlyioConnectionForm.tsx new file mode 100644 index 000000000..4b6c5bbc9 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/FlyioConnectionForm.tsx @@ -0,0 +1,136 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { FlyioConnectionMethod, TFlyioConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TFlyioConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Flyio) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(FlyioConnectionMethod.AccessToken), + credentials: z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .startsWith("FlyV1", "Token must start with 'FlyV1'") + }) + }) +]); + +type FormData = z.infer; + +export const FlyioConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Flyio, + method: FlyioConnectionMethod.AccessToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
    + {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
    + + + + +
    + +
    + ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RenderConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RenderConnectionForm.tsx new file mode 100644 index 000000000..adf401afe --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RenderConnectionForm.tsx @@ -0,0 +1,132 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { RenderConnectionMethod, TRenderConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TRenderConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Render) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(RenderConnectionMethod.ApiKey), + credentials: z.object({ + apiKey: z.string().trim().min(1, "API Key required") + }) + }) +]); + +type FormData = z.infer; + +export const RenderConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Render, + method: RenderConnectionMethod.ApiKey + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
    + {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
    + + + + +
    + +
    + ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/EditAppConnectionDetailsModal.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/EditAppConnectionDetailsModal.tsx index c3e4f13dc..f46a3b3cf 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/EditAppConnectionDetailsModal.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/EditAppConnectionDetailsModal.tsx @@ -7,7 +7,6 @@ import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { TAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; -import { DiscriminativePick } from "@app/types"; import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields } from "./AppConnectionForm"; @@ -43,7 +42,7 @@ const Content = ({ appConnection, onComplete }: ContentProps) => { formState: { isSubmitting, isDirty } } = form; - const onSubmit = async (formData: DiscriminativePick) => { + const onSubmit = async (formData: FormData) => { try { await updateAppConnection.mutateAsync({ connectionId: appConnection.id, diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx index 020a1f1c8..328c5505d 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx @@ -47,13 +47,19 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) = - + Name

    {data.identity.name}

    +
    +

    Delete Protection

    +

    + {data.identity.hasDeleteProtection ? "On" : "Off"} +

    +

    Organization Role

    {data.role}

    diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx index 05106c658..7ea2c8ba3 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx @@ -35,7 +35,7 @@ export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit }: Props Options - + { {(isAllowed) => (
    {format(new Date(createdAt), "yyyy-MM-dd")} - - - {(isAllowed) => ( - { - evt.stopPropagation(); - evt.preventDefault(); - handlePopUpOpen("deleteIdentity", { - identityId: id, - name - }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} - > - - - )} - - - - + + + + + + + + + + + {(isAllowed) => ( + } + isDisabled={!isAllowed} + onClick={(evt) => { + evt.stopPropagation(); + evt.preventDefault(); + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + > + Remove Identity From Project + + )} + + + + ); diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx index ee1f791d3..a8ecde73a 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx @@ -56,12 +56,12 @@ export const MembersSection = () => { return (
    -
    +

    Users

    {(isAllowed) => (
    + setSearch(e.target.value)} + leftIcon={} + placeholder="Search project roles..." + className="flex-1" + containerClassName="mb-4" + /> - - + + - {isRolesLoading && } - {roles?.map((role) => { + {isRolesLoading && } + {filteredRoles?.slice(offset, perPage * page).map((role) => { const { id, name, slug } = role; const isNonMutatable = Object.values(ProjectMembershipRole).includes( slug as ProjectMembershipRole @@ -109,88 +238,118 @@ export const ProjectRoleList = () => { ); })}
    NameSlug +
    + Name + handleSort(RolesOrderBy.Name)} + > + + +
    +
    +
    + Slug + handleSort(RolesOrderBy.Slug)} + > + + +
    +
    {name} {slug} - - -
    - -
    -
    - - - {(isAllowed) => ( - { - e.stopPropagation(); - navigate({ - to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, - params: { - projectId: currentWorkspace.id, - roleSlug: slug - } - }); - }} - disabled={!isAllowed} - > - {`${isNonMutatable ? "View" : "Edit"} Role`} - - )} - - - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("duplicateRole", role); - }} - disabled={!isAllowed} - > - Duplicate Role - - )} - - {!isNonMutatable && ( + + + + + + + + {(isAllowed) => ( } className={twMerge( - isAllowed - ? "hover:!bg-red-500 hover:!text-white" - : "pointer-events-none cursor-not-allowed opacity-50" + !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} onClick={(e) => { e.stopPropagation(); - handlePopUpOpen("deleteRole", role); + navigate({ + to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, + params: { + projectId: currentWorkspace.id, + roleSlug: slug + } + }); }} disabled={!isAllowed} > - Delete Role + {`${isNonMutatable ? "View" : "Edit"} Role`} )} - )} - - + + {(isAllowed) => ( + } + className={twMerge( + !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" + )} + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("duplicateRole", role); + }} + disabled={!isAllowed} + > + Duplicate Role + + )} + + {!isNonMutatable && ( + + {(isAllowed) => ( + } + className={twMerge( + isAllowed + ? "hover:!bg-red-500 hover:!text-white" + : "pointer-events-none cursor-not-allowed opacity-50", + "transition-colors duration-100" + )} + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("deleteRole", role); + }} + disabled={!isAllowed} + > + Delete Role + + )} + + )} + +
    +
    + {Boolean(filteredRoles?.length) && ( + + )} + {!filteredRoles?.length && !isRolesLoading && ( + + )}
    , state?: boolean) => void; }; -export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { +const ServiceTokenForm = () => { const { t } = useTranslation(); const { currentWorkspace } = useWorkspace(); const { control, - reset, handleSubmit, formState: { isSubmitting } } = useForm({ @@ -152,13 +151,197 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { } }; + return !hasServiceToken ? ( +
    + ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
    + ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + > + + +
    + ))} +
    + +
    + ( + + + + )} + /> + { + const options = [ + { + label: "Read (default)", + value: "read" + }, + { + label: "Write (optional)", + value: "write" + } + ] as const; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
    + + + + +
    + + ) : ( +
    +

    {newToken}

    + + + + {t("common.click-to-copy")} + + +
    + ); +}; + +export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { + const { t } = useTranslation(); + + const { currentWorkspace } = useWorkspace(); + return ( { handlePopUpToggle("createAPIToken", open); - reset(); - setToken(""); }} > { } subTitle={t("section.token.add-dialog.description") as string} > - {!hasServiceToken ? ( -
    - ( - - - - )} - /> - {tokenScopes.map(({ id }, index) => ( -
    - ( - - - - )} - /> - ( - - - - )} - /> - remove(index)} - > - - -
    - ))} -
    - -
    - ( - - - - )} - /> - { - const options = [ - { - label: "Read (default)", - value: "read" - }, - { - label: "Write (optional)", - value: "write" - } - ] as const; - - return ( - - <> - {options.map(({ label, value: optionValue }) => { - return ( - { - onChange({ - ...value, - [optionValue]: state - }); - }} - > - {label} - - ); - })} - - - ); - }} - /> -
    - - - - -
    - - ) : ( -
    -

    {newToken}

    - - - - {t("common.click-to-copy")} - - -
    - )} +
    ); diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx index e66ad17cd..712844e18 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission( return (
    -
    -

    Service Tokens

    +
    +
    +
    +

    Service Tokens

    + +
    + + Docs + +
    +
    +
    +

    + {t("section.token.service-tokens-description")} +

    +
    - Create token + Create Token )}
    -

    {t("section.token.service-tokens-description")}

    void; }; +enum TokensOrderBy { + Name = "name", + Expiration = "expiration" +} + export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); const { data, isPending } = useGetUserWsServiceTokens({ workspaceID: currentWorkspace?.id || "" }); + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(TokensOrderBy.Name, { + initPerPage: getUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, newPerPage); + }; + + const filteredTokens = useMemo( + () => + data + ?.filter((token) => { + const { name, scopes } = token; + + const searchValue = search.trim().toLowerCase(); + + if (name.toLowerCase().includes(searchValue)) { + return true; + } + + return scopes.some( + ({ environment, secretPath }) => + environment.toLowerCase().includes(searchValue) || + secretPath.toLowerCase().includes(searchValue) + ); + }) + .sort((a, b) => { + const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case TokensOrderBy.Expiration: + if (!tokenOne.expiresAt && !tokenTwo.expiresAt) return 0; + if (!tokenOne.expiresAt) return 1; + if (!tokenTwo.expiresAt) return -1; + + return ( + new Date(tokenOne.expiresAt).getTime() - new Date(tokenTwo.expiresAt).getTime() + ); + + case TokensOrderBy.Name: + default: + return tokenOne.name.toLowerCase().localeCompare(tokenTwo.name.toLowerCase()); + } + }) ?? [], + [data, orderDirection, search, orderBy] + ); + + useResetPageHelper({ + totalCount: filteredTokens.length, + offset, + setPage + }); + + const handleSort = (column: TokensOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: TokensOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: TokensOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + return ( - - - - - - - - - - - {isPending && } - {!isPending && - data && - data.map((row) => ( - - - - - - - ))} - {!isPending && data && data?.length === 0 && ( +
    + setSearch(e.target.value)} + leftIcon={} + placeholder="Search service tokens by name, environment or secret path..." + className="flex-1" + containerClassName="mb-4 mt-2" + /> + +
    Token NameEnvironment - Secret PathValid Until -
    {row.name} -
    - {row?.scopes.map(({ secretPath, environment }) => ( -
    -
    {environment}
    - - {secretPath} -
    - ))} -
    -
    {row.expiresAt && new Date(row.expiresAt).toUTCString()} - - {(isAllowed) => ( - - handlePopUpOpen("deleteAPITokenConfirmation", { - name: row.name, - id: row.id - }) - } - colorSchema="danger" - ariaLabel="delete" - isDisabled={!isAllowed} - > - - - )} - -
    + - + + + + - )} - -
    - - +
    + Name + handleSort(TokensOrderBy.Name)} + > + + +
    +
    Environment / Secret Path +
    + Valid Until + handleSort(TokensOrderBy.Expiration)} + > + + +
    +
    -
    + + + {isPending && } + {!isPending && + filteredTokens.slice(offset, perPage * page).map((row) => ( + + {row.name} + +
    + {row?.scopes.map(({ secretPath, environment }) => ( +
    +
    + {environment} +
    + + {secretPath} +
    + ))} +
    + + + {row.expiresAt ? ( + format(row.expiresAt, "MM/dd/yyyy h:mm:ss aa") + ) : ( + N/A + )} + + + + + + + + + + + + {(isAllowed) => ( + } + isDisabled={!isAllowed} + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row.id + }); + }} + > + Delete Token + + )} + + + + + + + ))} + + + {Boolean(filteredTokens.length) && ( + + )} + {!isPending && !filteredTokens?.length && ( + + )} + +
    ); }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal.tsx index 4ac54e973..16be01b67 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal.tsx @@ -5,7 +5,8 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent, Spinner } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useCreateProjectRole, useGetProjectRoleBySlug } from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; import { slugSchema } from "@app/lib/schemas"; @@ -49,9 +50,27 @@ const Content = ({ role, onClose }: ContentProps) => { const navigate = useNavigate(); const handleDuplicateRole = async (form: FormData) => { + const sanitizedPermission = role.permissions.map((permission) => { + if ( + // if contains new secret action the legacy one can be stripped off + // mainly done for duplicating predefined roles + permission.subject === ProjectPermissionSub.Secrets && + (permission.action.includes(ProjectPermissionSecretActions.DescribeSecret) || + permission.action.includes(ProjectPermissionSecretActions.ReadValue)) + ) { + return { + ...permission, + action: (permission.action as string[])?.filter( + (action) => action !== ProjectPermissionSecretActions.DescribeAndReadValue + ) + }; + } + return permission; + }); + const newRole = await createRole.mutateAsync({ projectId: currentWorkspace.id, - permissions: role.permissions, + permissions: sanitizedPermission, ...form }); diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index d7872a223..471246d04 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -291,6 +291,13 @@ export const projectRoleFormSchema = z.object({ }) .array() .default([]), + [ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.extend({ + inverted: z.boolean().optional(), + conditions: ConditionSchema + }) + .array() + .default([]), + [ProjectPermissionSub.Commits]: CommitPolicyActionSchema.array().default([]), [ProjectPermissionSub.Member]: MemberPolicyActionSchema.array().default([]), [ProjectPermissionSub.Groups]: GroupPolicyActionSchema.array().default([]), @@ -342,7 +349,6 @@ export const projectRoleFormSchema = z.object({ .default([]), [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]), - [ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]), [ProjectPermissionSub.Kmip]: KmipPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretScanningDataSources]: SecretScanningDataSourcePolicyActionSchema.array().default([]), @@ -366,7 +372,8 @@ type TConditionalFields = | ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.SshHosts | ProjectPermissionSub.SecretRotation - | ProjectPermissionSub.Identity; + | ProjectPermissionSub.Identity + | ProjectPermissionSub.SecretSyncs; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -379,7 +386,8 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.SshHosts || subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.PkiSubscribers || - subject === ProjectPermissionSub.CertificateTemplates; + subject === ProjectPermissionSub.CertificateTemplates || + subject === ProjectPermissionSub.SecretSyncs; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -484,7 +492,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, - ProjectPermissionSub.SshHostGroups + ProjectPermissionSub.SshHostGroups, + ProjectPermissionSub.SecretSyncs ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -515,6 +524,36 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.SecretSyncs) { + const canRead = action.includes(ProjectPermissionSecretSyncActions.Read); + const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit); + const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete); + const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create); + const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets); + const canImportSecrets = action.includes( + ProjectPermissionSecretSyncActions.ImportSecrets + ); + const canRemoveSecrets = action.includes( + ProjectPermissionSecretSyncActions.RemoveSecrets + ); + + if (!formVal[subject]) formVal[subject] = [{ conditions: [], inverted: false }]; + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + [ProjectPermissionSecretSyncActions.Read]: canRead, + [ProjectPermissionSecretSyncActions.Create]: canCreate, + [ProjectPermissionSecretSyncActions.Edit]: canEdit, + [ProjectPermissionSecretSyncActions.Delete]: canDelete, + [ProjectPermissionSecretSyncActions.SyncSecrets]: canSyncSecrets, + [ProjectPermissionSecretSyncActions.ImportSecrets]: canImportSecrets, + [ProjectPermissionSecretSyncActions.RemoveSecrets]: canRemoveSecrets, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], + inverted + }); + return; + } + if (subject === ProjectPermissionSub.DynamicSecrets) { const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential); const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential); @@ -777,31 +816,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } - if (subject === ProjectPermissionSub.SecretSyncs) { - const canRead = action.includes(ProjectPermissionSecretSyncActions.Read); - const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit); - const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete); - const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create); - const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets); - const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets); - const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets); - - if (!formVal[subject]) formVal[subject] = [{}]; - - // from above statement we are sure it won't be undefined - if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true; - if (canEdit) formVal[subject]![0][ProjectPermissionSecretSyncActions.Edit] = true; - if (canCreate) formVal[subject]![0][ProjectPermissionSecretSyncActions.Create] = true; - if (canDelete) formVal[subject]![0][ProjectPermissionSecretSyncActions.Delete] = true; - if (canSyncSecrets) - formVal[subject]![0][ProjectPermissionSecretSyncActions.SyncSecrets] = true; - if (canImportSecrets) - formVal[subject]![0][ProjectPermissionSecretSyncActions.ImportSecrets] = true; - if (canRemoveSecrets) - formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true; - return; - } - if (subject === ProjectPermissionSub.SecretScanningDataSources) { const canRead = action.includes(ProjectPermissionSecretScanningDataSourceActions.Read); const canEdit = action.includes(ProjectPermissionSecretScanningDataSourceActions.Edit); diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 31f63ac51..09f3920fd 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -35,6 +35,7 @@ import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { SecretPermissionConditions } from "./SecretPermissionConditions"; +import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions"; import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; type Props = { @@ -69,6 +70,10 @@ export const renderConditionalComponents = ( return ; } + if (subject === ProjectPermissionSub.SecretSyncs) { + return ; + } + return ; } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx new file mode 100644 index 000000000..b5f8f5c91 --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretSyncPermissionConditions.tsx @@ -0,0 +1,186 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { + PermissionConditionOperators, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; + +import { + getConditionOperatorHelperInfo, + renderOperatorSelectItems +} from "./PermissionConditionHelpers"; +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const SecretSyncPermissionConditions = ({ position = 0, isDisabled }: Props) => { + const { + control, + watch, + setValue, + formState: { errors } + } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions` + }); + + const conditionErrorMessage = + errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.message || + errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.root?.message; + + return ( +
    +

    Conditions

    +

    + Conditions determine when a policy will be applied (always if no conditions are present). +

    +

    + All conditions must evaluate to true for the policy to take effect. +

    +
    + {items.fields.map((el, index) => { + const condition = watch( + `permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}` + ) as { + lhs: string; + rhs: string; + operator: string; + }; + return ( +
    +
    + ( + + + + )} + /> +
    +
    + ( + + + + )} + /> +
    + + + +
    +
    +
    + ( + + + + )} + /> +
    +
    + items.remove(index)} + > + + +
    +
    + ); + })} +
    + {conditionErrorMessage && ( +
    + + {conditionErrorMessage} +
    + )} +
    + +
    +
    + ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/FlyioSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/FlyioSyncDestinationCol.tsx new file mode 100644 index 000000000..4b69e8348 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/FlyioSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TFlyioSync } from "@app/hooks/api/secretSyncs/types/flyio-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TFlyioSync; +}; + +export const FlyioSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx new file mode 100644 index 000000000..43e9e35d2 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx @@ -0,0 +1,29 @@ +import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; +import { TRenderSync } from "@app/hooks/api/secretSyncs/render-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TRenderSync; +}; + +export const RenderSyncDestinationCol = ({ secretSync }: Props) => { + const { data: services = [], isPending } = useRenderConnectionListServices( + secretSync.connectionId + ); + + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + serviceName: services.find((s) => s.id === secretSync.destinationConfig.serviceId)?.name + } + }); + + if (isPending) { + return ; + } + + 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 7ebaef73d..449ee4b58 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 @@ -8,12 +8,14 @@ import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol"; import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol"; import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol"; import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; +import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; +import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; @@ -61,6 +63,10 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Heroku: return ; + case SecretSync.Render: + return ; + case SecretSync.Flyio: + 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/SecretSyncRow.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx index ddc74b128..3cec5694c 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncRow.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { subject } from "@casl/ability"; import { faBan, faCalendarCheck, @@ -117,6 +118,14 @@ export const SecretSyncRow = ({ const destinationDetails = SECRET_SYNC_MAP[destination]; + const permissionSubject = + environment && folder + ? subject(ProjectPermissionSub.SecretSyncs, { + environment: environment.slug, + secretPath: folder.path + }) + : ProjectPermissionSub.SecretSyncs; + return ( @@ -264,7 +273,7 @@ export const SecretSyncRow = ({ {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( {(isAllowed: boolean) => ( { primaryText = destinationConfig.appName; secondaryText = destinationConfig.app; break; + case SecretSync.Render: + primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; + secondaryText = "Service"; + break; + case SecretSync.Flyio: + primaryText = destinationConfig.appId; + secondaryText = "App ID"; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index c94aef0d5..f34c8695e 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -96,6 +96,10 @@ import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/Overv import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm"; import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm"; +import { + HIDDEN_SECRET_VALUE, + HIDDEN_SECRET_VALUE_API_MASK +} from "../SecretDashboardPage/components/SecretListView/SecretItem"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; @@ -509,15 +513,25 @@ export const OverviewPage = () => { env: string, key: string, value: string, + secretValueHidden: boolean, type = SecretType.Shared ) => { + let secretValue: string | undefined = value; + + if ( + secretValueHidden && + (value === HIDDEN_SECRET_VALUE_API_MASK || value === HIDDEN_SECRET_VALUE) + ) { + secretValue = undefined; + } + try { const result = await updateSecretV3({ environment: env, workspaceId, secretPath, secretKey: key, - secretValue: value, + secretValue, type }); diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index 72eb085c0..4b07e6242 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -50,6 +50,7 @@ type Props = { env: string, key: string, value: string, + secretValueHidden: boolean, type?: SecretType, secretId?: string ) => Promise; @@ -147,6 +148,7 @@ export const SecretEditRow = ({ environment, secretName, value, + secretValueHidden, isOverride ? SecretType.Personal : SecretType.Shared, secretId ); @@ -166,6 +168,7 @@ export const SecretEditRow = ({ environment, secretName, secretValue, + secretValueHidden, isOverride ? SecretType.Personal : SecretType.Shared, secretId ); @@ -257,7 +260,7 @@ export const SecretEditRow = ({ > {(isAllowed) => (
    - + Promise; @@ -96,7 +98,7 @@ export const SecretOverviewTableRow = ({ ); if (secret?.secretValueHidden && !secret?.valueOverride) { - return canEditSecretValue ? "******" : ""; + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; } return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || ""; }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index d417821e4..5180383bb 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -62,6 +62,7 @@ import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { camelCaseToSpaces } from "@app/lib/fn/string"; import { CreateReminderForm } from "./CreateReminderForm"; +import { HIDDEN_SECRET_VALUE } from "./SecretItem"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; type Props = { @@ -897,7 +898,9 @@ export const SecretDetailSidebar = ({
    - {secretValueHidden ? "******" : secretValue?.replace(/./g, "*")} + {secretValueHidden + ? HIDDEN_SECRET_VALUE + : secretValue?.replace(/./g, "*")}