diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index c386b132e..3fe0fbe21 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -145,3 +145,9 @@ jobs: INFISICAL_CLI_REPO_SIGNING_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_SIGNING_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.INFISICAL_CLI_REPO_AWS_SECRET_ACCESS_KEY }} + - name: Invalidate Cloudfront cache + run: aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths '/deb/dists/stable/*' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.INFISICAL_CLI_REPO_AWS_SECRET_ACCESS_KEY }} + CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.INFISICAL_CLI_REPO_CLOUDFRONT_DISTRIBUTION_ID }} diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e3261db70..441d3ce4c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -136,7 +136,7 @@ declare module "fastify" { rateLimits: RateLimitConfiguration; // passport data passportUser: { - isUserCompleted: string; + isUserCompleted: boolean; providerAuthToken: string; }; kmipUser: { diff --git a/backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts b/backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts new file mode 100644 index 000000000..9c5b4e730 --- /dev/null +++ b/backend/src/db/migrations/20250414203701_add-notification-flag-service-token.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ServiceToken, "expiryNotificationSent"); + if (!hasCol) { + await knex.schema.alterTable(TableName.ServiceToken, (t) => { + t.boolean("expiryNotificationSent").defaultTo(false); + }); + + // Update only tokens where expiresAt is before current time + await knex(TableName.ServiceToken) + .whereRaw(`${TableName.ServiceToken}."expiresAt" < NOW()`) + .whereNotNull("expiresAt") + .update({ expiryNotificationSent: true }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ServiceToken, "expiryNotificationSent"); + if (hasCol) { + await knex.schema.alterTable(TableName.ServiceToken, (t) => { + t.dropColumn("expiryNotificationSent"); + }); + } +} diff --git a/backend/src/db/migrations/20250414234624_add-project-delete-protection.ts b/backend/src/db/migrations/20250414234624_add-project-delete-protection.ts new file mode 100644 index 000000000..f33353e42 --- /dev/null +++ b/backend/src/db/migrations/20250414234624_add-project-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.Project, "hasDeleteProtection"); + if (!hasCol) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.boolean("hasDeleteProtection").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Project, "hasDeleteProtection"); + if (hasCol) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("hasDeleteProtection"); + }); + } +} diff --git a/backend/src/db/migrations/20250416113437_add-oidc-jwt-signature-algorithm.ts b/backend/src/db/migrations/20250416113437_add-oidc-jwt-signature-algorithm.ts new file mode 100644 index 000000000..5adbf71ec --- /dev/null +++ b/backend/src/db/migrations/20250416113437_add-oidc-jwt-signature-algorithm.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { OIDCJWTSignatureAlgorithm } from "@app/ee/services/oidc/oidc-config-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.OidcConfig, "jwtSignatureAlgorithm"))) { + await knex.schema.alterTable(TableName.OidcConfig, (t) => { + t.string("jwtSignatureAlgorithm").defaultTo(OIDCJWTSignatureAlgorithm.RS256).notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.OidcConfig, "jwtSignatureAlgorithm")) { + await knex.schema.alterTable(TableName.OidcConfig, (t) => { + t.dropColumn("jwtSignatureAlgorithm"); + }); + } +} diff --git a/backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts b/backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts new file mode 100644 index 000000000..fb9a12625 --- /dev/null +++ b/backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Organization, "bypassOrgAuthEnabled"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("bypassOrgAuthEnabled").defaultTo(false).notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "bypassOrgAuthEnabled")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("bypassOrgAuthEnabled"); + }); + } +} diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 76923aee8..181df25f0 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -30,9 +30,10 @@ export const OidcConfigsSchema = z.object({ updatedAt: z.date(), orgId: z.string().uuid(), lastUsed: z.date().nullable().optional(), - manageGroupMemberships: z.boolean().default(false), encryptedOidcClientId: zodBuffer, - encryptedOidcClientSecret: zodBuffer + encryptedOidcClientSecret: zodBuffer, + manageGroupMemberships: z.boolean().default(false), + jwtSignatureAlgorithm: z.string().default("RS256") }); export type TOidcConfigs = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index a18e258c7..eea1808e0 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -26,7 +26,8 @@ export const OrganizationsSchema = z.object({ allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), - privilegeUpgradeInitiatedAt: z.date().nullable().optional() + privilegeUpgradeInitiatedAt: z.date().nullable().optional(), + bypassOrgAuthEnabled: z.boolean().default(false) }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 8c1a0386c..2403d6cf4 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -26,7 +26,8 @@ export const ProjectsSchema = z.object({ kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional(), description: z.string().nullable().optional(), type: z.string(), - enforceCapitalization: z.boolean().default(false) + enforceCapitalization: z.boolean().default(false), + hasDeleteProtection: z.boolean().default(true).nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/service-tokens.ts b/backend/src/db/schemas/service-tokens.ts index 720c8fd6f..8ffddb10a 100644 --- a/backend/src/db/schemas/service-tokens.ts +++ b/backend/src/db/schemas/service-tokens.ts @@ -21,7 +21,8 @@ export const ServiceTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), createdBy: z.string(), - projectId: z.string() + projectId: z.string(), + expiryNotificationSent: z.boolean().default(false).nullable().optional() }); export type TServiceTokens = z.infer; diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index 66bced3df..1bfc4d696 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -12,7 +12,7 @@ import RedisStore from "connect-redis"; import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas"; -import { OIDCConfigurationType } from "@app/ee/services/oidc/oidc-config-types"; +import { OIDCConfigurationType, OIDCJWTSignatureAlgorithm } from "@app/ee/services/oidc/oidc-config-types"; import { getConfig } from "@app/lib/config/env"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -30,7 +30,8 @@ const SanitizedOidcConfigSchema = OidcConfigsSchema.pick({ orgId: true, isActive: true, allowedEmailDomains: true, - manageGroupMemberships: true + manageGroupMemberships: true, + jwtSignatureAlgorithm: true }); export const registerOidcRouter = async (server: FastifyZodProvider) => { @@ -170,7 +171,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { isActive: true, orgId: true, allowedEmailDomains: true, - manageGroupMemberships: true + manageGroupMemberships: true, + jwtSignatureAlgorithm: true }).extend({ clientId: z.string(), clientSecret: z.string() @@ -225,7 +227,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { clientId: z.string().trim(), clientSecret: z.string().trim(), isActive: z.boolean(), - manageGroupMemberships: z.boolean().optional() + manageGroupMemberships: z.boolean().optional(), + jwtSignatureAlgorithm: z.nativeEnum(OIDCJWTSignatureAlgorithm).optional() }) .partial() .merge(z.object({ orgSlug: z.string() })), @@ -292,7 +295,11 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { clientSecret: z.string().trim(), isActive: z.boolean(), orgSlug: z.string().trim(), - manageGroupMemberships: z.boolean().optional().default(false) + manageGroupMemberships: z.boolean().optional().default(false), + jwtSignatureAlgorithm: z + .nativeEnum(OIDCJWTSignatureAlgorithm) + .optional() + .default(OIDCJWTSignatureAlgorithm.RS256) }) .superRefine((data, ctx) => { if (data.configurationType === OIDCConfigurationType.CUSTOM) { diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 13ac5dfcb..f2df2fb89 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -223,12 +223,18 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { samlConfigId: z.string().trim() }) }, - preValidation: passport.authenticate("saml", { - session: false, - failureFlash: true, - failureRedirect: "/login/provider/error" - // this is due to zod type difference - }) as any, + preValidation: passport.authenticate( + "saml", + { + session: false + }, + async (req, res, err, user) => { + if (err) { + throw new BadRequestError({ message: `Saml authentication failed. ${err?.message}`, error: err }); + } + req.passportUser = user as { isUserCompleted: boolean; providerAuthToken: string }; + } + ) as any, // this is due to zod type difference handler: (req, res) => { if (req.passportUser.isUserCompleted) { return res.redirect( diff --git a/backend/src/ee/services/oidc/oidc-config-dal.ts b/backend/src/ee/services/oidc/oidc-config-dal.ts index ffdba2cf7..b9b0a2659 100644 --- a/backend/src/ee/services/oidc/oidc-config-dal.ts +++ b/backend/src/ee/services/oidc/oidc-config-dal.ts @@ -1,6 +1,5 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TOidcConfigDALFactory = ReturnType; @@ -8,22 +7,5 @@ export type TOidcConfigDALFactory = ReturnType; export const oidcConfigDALFactory = (db: TDbClient) => { const oidcCfgOrm = ormify(db, TableName.OidcConfig); - const findEnforceableOidcCfg = async (orgId: string) => { - try { - const oidcCfg = await db - .replicaNode()(TableName.OidcConfig) - .where({ - orgId, - isActive: true - }) - .whereNotNull("lastUsed") - .first(); - - return oidcCfg; - } catch (error) { - throw new DatabaseError({ error, name: "Find org by id" }); - } - }; - - return { ...oidcCfgOrm, findEnforceableOidcCfg }; + return oidcCfgOrm; }; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 52c8dd597..adfe92341 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -165,7 +165,8 @@ export const oidcConfigServiceFactory = ({ allowedEmailDomains: oidcCfg.allowedEmailDomains, clientId, clientSecret, - manageGroupMemberships: oidcCfg.manageGroupMemberships + manageGroupMemberships: oidcCfg.manageGroupMemberships, + jwtSignatureAlgorithm: oidcCfg.jwtSignatureAlgorithm }; }; @@ -481,7 +482,8 @@ export const oidcConfigServiceFactory = ({ userinfoEndpoint, clientId, clientSecret, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }: TUpdateOidcCfgDTO) => { const org = await orgDAL.findOne({ slug: orgSlug @@ -536,7 +538,8 @@ export const oidcConfigServiceFactory = ({ jwksUri, isActive, lastUsed: null, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }; if (clientId !== undefined) { @@ -569,7 +572,8 @@ export const oidcConfigServiceFactory = ({ userinfoEndpoint, clientId, clientSecret, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }: TCreateOidcCfgDTO) => { const org = await orgDAL.findOne({ slug: orgSlug @@ -613,6 +617,7 @@ export const oidcConfigServiceFactory = ({ userinfoEndpoint, orgId: org.id, manageGroupMemberships, + jwtSignatureAlgorithm, encryptedOidcClientId: encryptor({ plainText: Buffer.from(clientId) }).cipherTextBlob, encryptedOidcClientSecret: encryptor({ plainText: Buffer.from(clientSecret) }).cipherTextBlob }); @@ -676,7 +681,8 @@ export const oidcConfigServiceFactory = ({ const client = new issuer.Client({ client_id: oidcCfg.clientId, client_secret: oidcCfg.clientSecret, - redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`] + redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`], + id_token_signed_response_alg: oidcCfg.jwtSignatureAlgorithm }); const strategy = new OpenIdStrategy( diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index a6bd6ad67..3b2194375 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -5,6 +5,12 @@ export enum OIDCConfigurationType { DISCOVERY_URL = "discoveryURL" } +export enum OIDCJWTSignatureAlgorithm { + RS256 = "RS256", + HS256 = "HS256", + RS512 = "RS512" +} + export type TOidcLoginDTO = { externalId: string; email: string; @@ -40,6 +46,7 @@ export type TCreateOidcCfgDTO = { isActive: boolean; orgSlug: string; manageGroupMemberships: boolean; + jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm; } & TGenericPermission; export type TUpdateOidcCfgDTO = Partial<{ @@ -56,5 +63,6 @@ export type TUpdateOidcCfgDTO = Partial<{ isActive: boolean; orgSlug: string; manageGroupMemberships: boolean; + jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm; }> & TGenericPermission; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9b23d6113..891d7193e 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { TDbClient } from "@app/db"; import { IdentityProjectMembershipRoleSchema, + OrgMembershipRole, OrgMembershipsSchema, TableName, TProjectRoles, @@ -53,6 +54,7 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), db.ref("groupName").withSchema("userGroups"), @@ -71,6 +73,7 @@ export const permissionDALFactory = (db: TDbClient) => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + bypassOrgAuthEnabled: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() }).parse(el), @@ -571,6 +574,11 @@ export const permissionDALFactory = (db: TDbClient) => { }) .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + .join(TableName.OrgMembership, (qb) => { + void qb + .on(`${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`); + }) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) @@ -670,6 +678,8 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), + db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), db.ref("type").withSchema(TableName.Project).as("projectType"), db.ref("id").withSchema(TableName.Project).as("projectId"), @@ -683,6 +693,7 @@ export const permissionDALFactory = (db: TDbClient) => { orgId, username, orgAuthEnforced, + orgRole, membershipId, groupMembershipId, membershipCreatedAt, @@ -690,10 +701,12 @@ export const permissionDALFactory = (db: TDbClient) => { groupMembershipUpdatedAt, membershipUpdatedAt, projectType, - shouldUseNewPrivilegeSystem + shouldUseNewPrivilegeSystem, + bypassOrgAuthEnabled }) => ({ orgId, orgAuthEnforced, + orgRole: orgRole as OrgMembershipRole, userId, projectId, username, @@ -701,7 +714,8 @@ export const permissionDALFactory = (db: TDbClient) => { id: membershipId || groupMembershipId, createdAt: membershipCreatedAt || groupMembershipCreatedAt, updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt, - shouldUseNewPrivilegeSystem + shouldUseNewPrivilegeSystem, + bypassOrgAuthEnabled }), childrenMapper: [ { diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 88bace1f0..d645e2bec 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -2,7 +2,7 @@ import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability"; import { z } from "zod"; -import { TOrganizations } from "@app/db/schemas"; +import { OrgMembershipRole, TOrganizations } from "@app/db/schemas"; import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; @@ -118,11 +118,20 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { ].includes(actorAuthMethod); } -function validateOrgSSO(actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"]) { +function validateOrgSSO( + actorAuthMethod: ActorAuthMethod, + isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], + orgRole: OrgMembershipRole +) { if (actorAuthMethod === undefined) { throw new UnauthorizedError({ name: "No auth method defined" }); } + if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + return; + } + if ( isOrgSsoEnforced && actorAuthMethod !== null && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 299f509d7..0082c3d17 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -139,7 +139,12 @@ export const permissionServiceFactory = ({ throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); } - validateOrgSSO(authMethod, membership.orgAuthEnforced); + validateOrgSSO( + authMethod, + membership.orgAuthEnforced, + membership.bypassOrgAuthEnabled, + membership.role as OrgMembershipRole + ); const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat( membership?.groups?.map(({ role, customRolePermission }) => ({ @@ -226,7 +231,12 @@ export const permissionServiceFactory = ({ throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); } - validateOrgSSO(authMethod, userProjectPermission.orgAuthEnforced); + validateOrgSSO( + authMethod, + userProjectPermission.orgAuthEnforced, + userProjectPermission.bypassOrgAuthEnabled, + userProjectPermission.orgRole + ); if (actionProjectType !== ActionProjectType.Any && actionProjectType !== userProjectPermission.projectType) { throw new BadRequestError({ diff --git a/backend/src/ee/services/saml-config/saml-config-dal.ts b/backend/src/ee/services/saml-config/saml-config-dal.ts index aff42230f..c82adcb89 100644 --- a/backend/src/ee/services/saml-config/saml-config-dal.ts +++ b/backend/src/ee/services/saml-config/saml-config-dal.ts @@ -1,6 +1,5 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TSamlConfigDALFactory = ReturnType; @@ -8,25 +7,5 @@ export type TSamlConfigDALFactory = ReturnType; export const samlConfigDALFactory = (db: TDbClient) => { const samlCfgOrm = ormify(db, TableName.SamlConfig); - const findEnforceableSamlCfg = async (orgId: string) => { - try { - const samlCfg = await db - .replicaNode()(TableName.SamlConfig) - .where({ - orgId, - isActive: true - }) - .whereNotNull("lastUsed") - .first(); - - return samlCfg; - } catch (error) { - throw new DatabaseError({ error, name: "Find org by id" }); - } - }; - - return { - ...samlCfgOrm, - findEnforceableSamlCfg - }; + return samlCfgOrm; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 9cd05ce97..109b7047c 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -478,7 +478,8 @@ export const PROJECTS = { name: "The new name of the project.", projectDescription: "An optional description label for the project.", autoCapitalization: "Disable or enable auto-capitalization for the project.", - slug: "An optional slug for the project. (must be unique within the organization)" + slug: "An optional slug for the project. (must be unique within the organization)", + hasDeleteProtection: "Enable or disable delete protection for the project." }, GET_KEY: { workspaceId: "The ID of the project to get the key from." @@ -1807,6 +1808,10 @@ export const AppConnections = { CAMUNDA: { clientId: "The client ID used to authenticate with Camunda.", clientSecret: "The client secret used to authenticate with Camunda." + }, + WINDMILL: { + instanceUrl: "The Windmill instance URL to connect with (defaults to https://app.windmill.dev).", + accessToken: "The access token to use to connect with Windmill." } } }; @@ -1942,6 +1947,10 @@ export const SecretSyncs = { env: "The ID of the Vercel environment to sync secrets to.", branch: "The branch to sync preview secrets to.", teamId: "The ID of the Vercel team to sync secrets to." + }, + WINDMILL: { + workspace: "The Windmill workspace to sync secrets to.", + path: "The Windmill workspace path to sync secrets to." } } }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a5aa66cb3..c9f2811b5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1255,7 +1255,8 @@ export const registerRoutes = async ( userDAL, permissionService, projectDAL, - accessTokenQueue + accessTokenQueue, + smtpService }); const identityService = identityServiceFactory({ @@ -1416,7 +1417,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL: secretVersionV2BridgeDAL, - identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL + identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, + serviceTokenService }); const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 2a87cf7cf..da300981c 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -260,7 +260,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ upgradeStatus: true, pitVersionLimit: true, kmsCertificateKeyId: true, - auditLogsRetentionDays: true + auditLogsRetentionDays: true, + hasDeleteProtection: true }); export const SanitizedTagSchema = SecretTagsSchema.pick({ 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 7d60ac8f8..7eb54de30 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 @@ -41,6 +41,10 @@ import { TerraformCloudConnectionListItemSchema } from "@app/services/app-connection/terraform-cloud"; import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; +import { + SanitizedWindmillConnectionSchema, + WindmillConnectionListItemSchema +} from "@app/services/app-connection/windmill"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -58,7 +62,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, - ...SanitizedAzureClientSecretsConnectionSchema.options + ...SanitizedAzureClientSecretsConnectionSchema.options, + ...SanitizedWindmillConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -75,7 +80,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, Auth0ConnectionListItemSchema, - AzureClientSecretsConnectionListItemSchema + AzureClientSecretsConnectionListItemSchema, + WindmillConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 2732daa77..4abffb478 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -14,6 +14,7 @@ import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; +import { registerWindmillConnectionRouter } from "./windmill-connection-router"; export * from "./app-connection-router"; @@ -32,5 +33,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Windmill, + server, + sanitizedResponseSchema: SanitizedWindmillConnectionSchema, + createSchema: CreateWindmillConnectionSchema, + updateSchema: UpdateWindmillConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/workspaces`, + 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 workspaces = await server.services.appConnection.windmill.listWorkspaces(connectionId, req.permission); + + return workspaces; + } + }); +}; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 93376d261..90ca3d255 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -31,7 +31,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { 200: z.object({ organizations: sanitizedOrganizationSchema .extend({ - orgAuthMethod: z.string() + orgAuthMethod: z.string(), + userRole: z.string() }) .array() }) @@ -259,7 +260,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional(), - allowSecretSharingOutsideOrganization: z.boolean().optional() + allowSecretSharingOutsideOrganization: z.boolean().optional(), + bypassOrgAuthEnabled: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 2496d8f62..4182ce389 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -312,6 +312,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .optional() .describe(PROJECTS.UPDATE.projectDescription), autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection), slug: z .string() .trim() @@ -340,6 +341,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { name: req.body.name, description: req.body.description, autoCapitalization: req.body.autoCapitalization, + hasDeleteProtection: req.body.hasDeleteProtection, slug: req.body.slug }, actorAuthMethod: req.permission.authMethod, @@ -390,6 +392,43 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/:workspaceId/delete-protection", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + hasDeleteProtection: z.boolean() + }), + response: { + 200: z.object({ + message: z.string(), + workspace: SanitizedProjectSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const workspace = await server.services.project.toggleDeleteProtection({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + hasDeleteProtection: req.body.hasDeleteProtection + }); + return { + message: "Successfully changed workspace settings", + workspace + }; + } + }); + server.route({ method: "PUT", url: "/:workspaceSlug/version-limit", diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index b55564d80..dbfa715ea 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -39,17 +39,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.CREATE.path), + .describe(FOLDERS.CREATE.path) + .optional(), // backward compatiability with cli directory: z .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.CREATE.directory), + .describe(FOLDERS.CREATE.directory) + .optional(), description: z.string().optional().nullable().describe(FOLDERS.CREATE.description) }), response: { @@ -60,7 +62,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory; + const path = req.body.path || req.body.directory || "/"; const folder = await server.services.folder.createFolder({ actorId: req.permission.id, actor: req.permission.type, @@ -120,17 +122,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.UPDATE.path), + .describe(FOLDERS.UPDATE.path) + .optional(), // backward compatiability with cli directory: z .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.UPDATE.directory), + .describe(FOLDERS.UPDATE.directory) + .optional(), description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description) }), response: { @@ -141,7 +145,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory; + const path = req.body.path || req.body.directory || "/"; const { folder, old } = await server.services.folder.updateFolder({ actorId: req.permission.id, actor: req.permission.type, @@ -271,17 +275,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.path), + .describe(FOLDERS.DELETE.path) + .optional(), // keep this here as cli need directory directory: z .string() .trim() .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) .describe(FOLDERS.DELETE.directory) + .optional() }), response: { 200: z.object({ @@ -291,7 +297,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.body.path || req.body.directory; + const path = req.body.path || req.body.directory || "/"; const folder = await server.services.folder.deleteFolder({ actorId: req.permission.id, actor: req.permission.type, @@ -339,18 +345,18 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => path: z .string() .trim() - .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.path), + .describe(FOLDERS.LIST.path) + .optional(), // backward compatiability with cli directory: z .string() .trim() - .default("/") - .transform(prefixWithSlash) + .transform(prefixWithSlash) // Transformations get skipped if directory is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.directory), + .describe(FOLDERS.LIST.directory) + .optional(), recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive) }), response: { @@ -363,7 +369,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const path = req.query.path || req.query.directory; + const path = req.query.path || req.query.directory || "/"; const folders = await server.services.folder.getFolders({ actorId: req.permission.id, actor: req.permission.type, 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 07567124d..ee407cee3 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -11,6 +11,7 @@ import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; +import { registerWindmillSyncRouter } from "./windmill-sync-router"; export * from "./secret-sync-router"; @@ -25,5 +26,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/windmill-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/windmill-sync-router.ts new file mode 100644 index 000000000..d40a21ef3 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/windmill-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateWindmillSyncSchema, + UpdateWindmillSyncSchema, + WindmillSyncSchema +} from "@app/services/secret-sync/windmill"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerWindmillSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Windmill, + server, + responseSchema: WindmillSyncSchema, + createSchema: CreateWindmillSyncSchema, + updateSchema: UpdateWindmillSyncSchema + }); diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 6e4a8170e..c8f68a926 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -303,7 +303,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), description: z.string().trim().optional().describe(PROJECTS.UPDATE.projectDescription), - autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection) }), response: { 200: SanitizedProjectSchema @@ -321,7 +322,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { update: { name: req.body.name, description: req.body.description, - autoCapitalization: req.body.autoCapitalization + autoCapitalization: req.body.autoCapitalization, + hasDeleteProtection: req.body.hasDeleteProtection }, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 39b85a60c..17b545a6a 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -12,6 +12,7 @@ export enum AppConnection { Postgres = "postgres", MsSql = "mssql", Camunda = "camunda", + Windmill = "windmill", Auth0 = "auth0" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 4ce91da94..2f6263893 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -55,6 +55,11 @@ import { } from "./terraform-cloud"; import { VercelConnectionMethod } from "./vercel"; import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; +import { + getWindmillConnectionListItem, + validateWindmillConnectionCredentials, + WindmillConnectionMethod +} from "./windmill"; export const listAppConnectionOptions = () => { return [ @@ -71,6 +76,7 @@ export const listAppConnectionOptions = () => { getMsSqlConnectionListItem(), getCamundaConnectionListItem(), getAzureClientSecretsConnectionListItem(), + getWindmillConnectionListItem(), getAuth0ConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -136,7 +142,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureClientSecrets]: - validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator + validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -168,6 +175,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return "Username & Password"; + case WindmillConnectionMethod.AccessToken: + return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; default: @@ -214,5 +223,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, [AppConnection.AzureClientSecrets]: platformManagedCredentialsNotSupported, + [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index d2d1c7530..030410600 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -14,5 +14,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server", [AppConnection.Camunda]: "Camunda", + [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index ce745e8ba..e9f9cc52d 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -51,6 +51,8 @@ import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-c import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; import { vercelConnectionService } from "./vercel/vercel-connection-service"; +import { ValidateWindmillConnectionCredentialsSchema } from "./windmill"; +import { windmillConnectionService } from "./windmill/windmill-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -74,6 +76,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => { const { credentials: inputCredentials } = config; - let response: AxiosResponse | null = null; - try { - response = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + await request.get(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, { headers: { Authorization: `Bearer ${inputCredentials.apiToken}` } @@ -38,17 +36,14 @@ export const validateVercelConnectionCredentials = async (config: TVercelConnect } catch (error: unknown) { if (error instanceof AxiosError) { throw new BadRequestError({ - message: `Failed to validate credentials: ${error.message || "Unknown error"}` + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to validate credentials: ${ + error.response?.data ? JSON.stringify(error.response?.data) : error.message || "Unknown error" + }` }); } throw new BadRequestError({ - message: "Unable to validate connection - verify credentials" - }); - } - - if (!response?.data) { - throw new InternalServerError({ - message: "Failed to get organizations: Response was empty" + message: `Unable to validate connection: ${(error as Error).message || "Verify credentials"}` }); } diff --git a/backend/src/services/app-connection/windmill/index.ts b/backend/src/services/app-connection/windmill/index.ts new file mode 100644 index 000000000..835562171 --- /dev/null +++ b/backend/src/services/app-connection/windmill/index.ts @@ -0,0 +1,4 @@ +export * from "./windmill-connection-enums"; +export * from "./windmill-connection-fns"; +export * from "./windmill-connection-schemas"; +export * from "./windmill-connection-types"; diff --git a/backend/src/services/app-connection/windmill/windmill-connection-enums.ts b/backend/src/services/app-connection/windmill/windmill-connection-enums.ts new file mode 100644 index 000000000..ffc01234f --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-enums.ts @@ -0,0 +1,3 @@ +export enum WindmillConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/windmill/windmill-connection-fns.ts b/backend/src/services/app-connection/windmill/windmill-connection-fns.ts new file mode 100644 index 000000000..8e478f8bb --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-fns.ts @@ -0,0 +1,65 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { WindmillConnectionMethod } from "./windmill-connection-enums"; +import { TWindmillConnection, TWindmillConnectionConfig, TWindmillWorkspace } from "./windmill-connection-types"; + +export const getWindmillInstanceUrl = async (config: TWindmillConnectionConfig) => { + const instanceUrl = config.credentials.instanceUrl + ? removeTrailingSlash(config.credentials.instanceUrl) + : "https://app.windmill.dev"; + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getWindmillConnectionListItem = () => { + return { + name: "Windmill" as const, + app: AppConnection.Windmill as const, + methods: Object.values(WindmillConnectionMethod) as [WindmillConnectionMethod.AccessToken] + }; +}; + +export const validateWindmillConnectionCredentials = async (config: TWindmillConnectionConfig) => { + const instanceUrl = await getWindmillInstanceUrl(config); + const { accessToken } = config.credentials; + + try { + await request.get(`${instanceUrl}/api/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + } 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 listWindmillWorkspaces = async (appConnection: TWindmillConnection) => { + const instanceUrl = await getWindmillInstanceUrl(appConnection); + const { accessToken } = appConnection.credentials; + + const resp = await request.get(`${instanceUrl}/api/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + return resp.data.filter((workspace) => !workspace.deleted); +}; diff --git a/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts b/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts new file mode 100644 index 000000000..eb7f74ecd --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts @@ -0,0 +1,70 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { WindmillConnectionMethod } from "./windmill-connection-enums"; + +export const WindmillConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .describe(AppConnections.CREDENTIALS.WINDMILL.accessToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .optional() + .describe(AppConnections.CREDENTIALS.WINDMILL.instanceUrl) +}); + +const BaseWindmillConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Windmill) }); + +export const WindmillConnectionSchema = BaseWindmillConnectionSchema.extend({ + method: z.literal(WindmillConnectionMethod.AccessToken), + credentials: WindmillConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedWindmillConnectionSchema = z.discriminatedUnion("method", [ + BaseWindmillConnectionSchema.extend({ + method: z.literal(WindmillConnectionMethod.AccessToken), + credentials: WindmillConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateWindmillConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(WindmillConnectionMethod.AccessToken) + .describe(AppConnections.CREATE(AppConnection.Windmill).method), + credentials: WindmillConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Windmill).credentials + ) + }) +]); + +export const CreateWindmillConnectionSchema = ValidateWindmillConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Windmill) +); + +export const UpdateWindmillConnectionSchema = z + .object({ + credentials: WindmillConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Windmill).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Windmill)); + +export const WindmillConnectionListItemSchema = z.object({ + name: z.literal("Windmill"), + app: z.literal(AppConnection.Windmill), + methods: z.nativeEnum(WindmillConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/windmill/windmill-connection-service.ts b/backend/src/services/app-connection/windmill/windmill-connection-service.ts new file mode 100644 index 000000000..89306985f --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-service.ts @@ -0,0 +1,28 @@ +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listWindmillWorkspaces } from "./windmill-connection-fns"; +import { TWindmillConnection } from "./windmill-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const windmillConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listWorkspaces = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Windmill, connectionId, actor); + + try { + const workspaces = await listWindmillWorkspaces(appConnection); + return workspaces; + } catch (error) { + return []; + } + }; + + return { + listWorkspaces + }; +}; diff --git a/backend/src/services/app-connection/windmill/windmill-connection-types.ts b/backend/src/services/app-connection/windmill/windmill-connection-types.ts new file mode 100644 index 000000000..9747ce14b --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-types.ts @@ -0,0 +1,27 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateWindmillConnectionSchema, + ValidateWindmillConnectionCredentialsSchema, + WindmillConnectionSchema +} from "./windmill-connection-schemas"; + +export type TWindmillConnection = z.infer; + +export type TWindmillConnectionInput = z.infer & { + app: AppConnection.Windmill; +}; + +export type TValidateWindmillConnectionCredentialsSchema = typeof ValidateWindmillConnectionCredentialsSchema; + +export type TWindmillConnectionConfig = DiscriminativePick< + TWindmillConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TWindmillWorkspace = { id: string; name: string; deleted: boolean }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 8dfe69643..0e0f999dd 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -2,7 +2,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { TUsers, UserDeviceSchema } from "@app/db/schemas"; +import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; @@ -174,20 +174,25 @@ export const authLoginServiceFactory = ({ const userEnc = await userDAL.findUserEncKeyByUsername({ username: email }); + const serverCfg = await getServerCfg(); + if (!userEnc || (userEnc && !userEnc.isAccepted)) { + throw new Error("Failed to find user"); + } + if ( serverCfg.enabledLoginMethods && !serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) && !providerAuthToken ) { - throw new BadRequestError({ - message: "Login with email is disabled by administrator." - }); - } - - if (!userEnc || (userEnc && !userEnc.isAccepted)) { - throw new Error("Failed to find user"); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(userEnc.userId); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with email is disabled by administrator." + }); + } } if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) { @@ -573,28 +578,40 @@ export const authLoginServiceFactory = ({ switch (authMethod) { case AuthMethod.GITHUB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) { - throw new BadRequestError({ - message: "Login with Github is disabled by administrator.", - name: "Oauth 2 login" - }); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with Github is disabled by administrator.", + name: "Oauth 2 login" + }); + } } break; } case AuthMethod.GOOGLE: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GOOGLE)) { - throw new BadRequestError({ - message: "Login with Google is disabled by administrator.", - name: "Oauth 2 login" - }); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with Google is disabled by administrator.", + name: "Oauth 2 login" + }); + } } break; } case AuthMethod.GITLAB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITLAB)) { - throw new BadRequestError({ - message: "Login with Gitlab is disabled by administrator.", - name: "Oauth 2 login" - }); + // bypass server configuration when user is an organization admin - this is to prevent lockout + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) { + throw new BadRequestError({ + message: "Login with Gitlab is disabled by administrator.", + name: "Oauth 2 login" + }); + } } break; } diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 85d348854..02bf58321 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -96,7 +96,9 @@ export const orgDALFactory = (db: TDbClient) => { }; // special query - const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => { + const findAllOrgsByUserId = async ( + userId: string + ): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => { try { const org = (await db .replicaNode()(TableName.OrgMembership) @@ -117,6 +119,7 @@ export const orgDALFactory = (db: TDbClient) => { ); }) .select(selectAllTableCols(TableName.Organization)) + .select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole")) .select( db.raw(` CASE @@ -125,7 +128,7 @@ export const orgDALFactory = (db: TDbClient) => { ELSE '' END as "orgAuthMethod" `) - )) as (TOrganizations & { orgAuthMethod: string })[]; + )) as (TOrganizations & { orgAuthMethod: string; userRole: string })[]; return org; } catch (error) { diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ef49d8178..2aa793c04 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -16,5 +16,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ allowSecretSharingOutsideOrganization: true, shouldUseNewPrivilegeSystem: true, privilegeUpgradeInitiatedByUsername: true, - privilegeUpgradeInitiatedAt: true + privilegeUpgradeInitiatedAt: true, + bypassOrgAuthEnabled: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 98b35f68f..3a6373575 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -110,8 +110,8 @@ type TOrgServiceFactoryDep = { projectKeyDAL: Pick; orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; - samlConfigDAL: Pick; - oidcConfigDAL: Pick; + samlConfigDAL: Pick; + oidcConfigDAL: Pick; smtpService: TSmtpService; tokenService: TAuthTokenServiceFactory; permissionService: TPermissionServiceFactory; @@ -349,7 +349,8 @@ export const orgServiceFactory = ({ defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod, - allowSecretSharingOutsideOrganization + allowSecretSharingOutsideOrganization, + bypassOrgAuthEnabled } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -402,13 +403,33 @@ export const orgServiceFactory = ({ } if (authEnforced) { - const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId); - const oidcCfg = await oidcConfigDAL.findEnforceableOidcCfg(orgId); + const samlCfg = await samlConfigDAL.findOne({ + orgId, + isActive: true + }); + const oidcCfg = await oidcConfigDAL.findOne({ + orgId, + isActive: true + }); if (!samlCfg && !oidcCfg) throw new NotFoundError({ message: `SAML or OIDC configuration for organization with ID '${orgId}' not found` }); + + if (samlCfg && !samlCfg.lastUsed) { + throw new BadRequestError({ + message: + "To apply the new SAML auth enforcement, please log in via SAML at least once. This step is required to enforce SAML-based authentication." + }); + } + + if (oidcCfg && !oidcCfg.lastUsed) { + throw new BadRequestError({ + message: + "To apply the new OIDC auth enforcement, please log in via OIDC at least once. This step is required to enforce OIDC-based authentication." + }); + } } let defaultMembershipRole: string | undefined; @@ -429,7 +450,8 @@ export const orgServiceFactory = ({ defaultMembershipRole, enforceMfa, selectedMfaMethod, - allowSecretSharingOutsideOrganization + allowSecretSharingOutsideOrganization, + bypassOrgAuthEnabled }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 9d14b092e..8a1698015 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -73,6 +73,7 @@ export type TUpdateOrgDTO = { enforceMfa: boolean; selectedMfaMethod: MfaMethod; allowSecretSharingOutsideOrganization: boolean; + bypassOrgAuthEnabled: boolean; }>; } & TOrgPermission; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 1f45734b3..9b7c29c1b 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -86,6 +86,7 @@ import { TProjectAccessRequestDTO, TSearchProjectsDTO, TToggleProjectAutoCapitalizationDTO, + TToggleProjectDeleteProtectionDTO, TUpdateAuditLogsRetentionDTO, TUpdateProjectDTO, TUpdateProjectKmsDTO, @@ -482,6 +483,12 @@ export const projectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); + if (project.hasDeleteProtection) { + throw new ForbiddenRequestError({ + message: "Project delete protection is enabled" + }); + } + const deletedProject = await projectDAL.transaction(async (tx) => { // delete these so that project custom roles can be deleted in cascade effect // direct deletion of project without these will cause fk error @@ -616,6 +623,7 @@ export const projectServiceFactory = ({ description: update.description, autoCapitalization: update.autoCapitalization, enforceCapitalization: update.autoCapitalization, + hasDeleteProtection: update.hasDeleteProtection, slug: update.slug }); @@ -648,6 +656,29 @@ export const projectServiceFactory = ({ return updatedProject; }; + const toggleDeleteProtection = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + hasDeleteProtection + }: TToggleProjectDeleteProtectionDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + const updatedProject = await projectDAL.updateById(projectId, { hasDeleteProtection }); + + return updatedProject; + }; + const updateVersionLimit = async ({ actor, actorId, @@ -1499,6 +1530,7 @@ export const projectServiceFactory = ({ getProjectUpgradeStatus, getAProject, toggleAutoCapitalization, + toggleDeleteProtection, updateName, upgradeProject, listProjectCas, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 4346ae2c4..444f6309c 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -66,6 +66,10 @@ export type TToggleProjectAutoCapitalizationDTO = { autoCapitalization: boolean; } & TProjectPermission; +export type TToggleProjectDeleteProtectionDTO = { + hasDeleteProtection: boolean; +} & TProjectPermission; + export type TUpdateProjectVersionLimitDTO = { pitVersionLimit: number; workspaceSlug: string; @@ -86,6 +90,7 @@ export type TUpdateProjectDTO = { name?: string; description?: string; autoCapitalization?: boolean; + hasDeleteProtection?: boolean; slug?: string; }; } & Omit; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index f0d579cf7..32f180636 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -10,6 +10,7 @@ import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; +import { TServiceTokenServiceFactory } from "../service-token/service-token-service"; type TDailyResourceCleanUpQueueServiceFactoryDep = { auditLogDAL: Pick; @@ -21,6 +22,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; + serviceTokenService: Pick; queueService: TQueueServiceFactory; }; @@ -36,7 +38,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL, - identityUniversalAuthClientSecretDAL + identityUniversalAuthClientSecretDAL, + serviceTokenService }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); @@ -50,6 +53,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretVersionDAL.pruneExcessVersions(); await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); + await serviceTokenService.notifyExpiringTokens(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 571ae8b73..d21003b10 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -819,10 +819,14 @@ export const secretImportServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) - ); + if ( + permission.cannot( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ) + ) { + return []; + } const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) return []; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 9349e2197..86273a4ff 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -9,7 +9,8 @@ export enum SecretSync { Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Camunda = "camunda", - Vercel = "vercel" + Vercel = "vercel", + Windmill = "windmill" } 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 a3efa4bc1..0b821b593 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -29,6 +29,7 @@ import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; +import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -41,7 +42,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, - [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION + [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, + [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -136,6 +138,8 @@ export const SecretSyncFns = { }).syncSecrets(secretSync, secretMap); case SecretSync.Vercel: return VercelSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.Windmill: + return WindmillSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -192,6 +196,9 @@ export const SecretSyncFns = { case SecretSync.Vercel: secretMap = await VercelSyncFns.getSecrets(secretSync); break; + case SecretSync.Windmill: + secretMap = await WindmillSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -243,6 +250,8 @@ export const SecretSyncFns = { }).removeSecrets(secretSync, secretMap); case SecretSync.Vercel: return VercelSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.Windmill: + return WindmillSyncFns.removeSecrets(secretSync, secretMap); 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 661815814..a9099543d 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -12,7 +12,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Humanitec]: "Humanitec", [SecretSync.TerraformCloud]: "Terraform Cloud", [SecretSync.Camunda]: "Camunda", - [SecretSync.Vercel]: "Vercel" + [SecretSync.Vercel]: "Vercel", + [SecretSync.Windmill]: "Windmill" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -26,5 +27,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Humanitec]: AppConnection.Humanitec, [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, - [SecretSync.Vercel]: AppConnection.Vercel + [SecretSync.Vercel]: AppConnection.Vercel, + [SecretSync.Windmill]: AppConnection.Windmill }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 17257b8e2..9f4e283a9 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -76,6 +76,7 @@ type TSecretSyncQueueFactoryDep = { | "findBySecretKeys" | "bulkUpdate" | "deleteMany" + | "invalidateSecretCacheByProjectId" >; secretImportDAL: Pick; secretSyncDAL: Pick; @@ -382,6 +383,9 @@ export const secretSyncQueueFactory = ({ }); } + if (secretsToUpdate.length || secretsToCreate.length) + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); + return importedSecretMap; }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index d3207918c..03e92a57a 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -29,6 +29,12 @@ import { } from "@app/services/secret-sync/github"; import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; +import { + TWindmillSync, + TWindmillSyncInput, + TWindmillSyncListItem, + TWindmillSyncWithCredentials +} from "@app/services/secret-sync/windmill"; import { TAwsParameterStoreSync, @@ -74,7 +80,8 @@ export type TSecretSync = | THumanitecSync | TTerraformCloudSync | TCamundaSync - | TVercelSync; + | TVercelSync + | TWindmillSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -87,7 +94,8 @@ export type TSecretSyncWithCredentials = | THumanitecSyncWithCredentials | TTerraformCloudSyncWithCredentials | TCamundaSyncWithCredentials - | TVercelSyncWithCredentials; + | TVercelSyncWithCredentials + | TWindmillSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -100,7 +108,8 @@ export type TSecretSyncInput = | THumanitecSyncInput | TTerraformCloudSyncInput | TCamundaSyncInput - | TVercelSyncInput; + | TVercelSyncInput + | TWindmillSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -113,7 +122,8 @@ export type TSecretSyncListItem = | THumanitecSyncListItem | TTerraformCloudSyncListItem | TCamundaSyncListItem - | TVercelSyncListItem; + | TVercelSyncListItem + | TWindmillSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/windmill/index.ts b/backend/src/services/secret-sync/windmill/index.ts new file mode 100644 index 000000000..94897da0f --- /dev/null +++ b/backend/src/services/secret-sync/windmill/index.ts @@ -0,0 +1,4 @@ +export * from "./windmill-sync-constants"; +export * from "./windmill-sync-fns"; +export * from "./windmill-sync-schemas"; +export * from "./windmill-sync-types"; diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-constants.ts b/backend/src/services/secret-sync/windmill/windmill-sync-constants.ts new file mode 100644 index 000000000..d52c704b7 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-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 WINDMILL_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Windmill", + destination: SecretSync.Windmill, + connection: AppConnection.Windmill, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts new file mode 100644 index 000000000..2e2c36740 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts @@ -0,0 +1,241 @@ +import { request } from "@app/lib/config/request"; +import { getWindmillInstanceUrl } from "@app/services/app-connection/windmill"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { + TDeleteWindmillVariable, + TPostWindmillVariable, + TWindmillListVariables, + TWindmillListVariablesResponse, + TWindmillSyncWithCredentials, + TWindmillVariable +} from "@app/services/secret-sync/windmill/windmill-sync-types"; + +import { TSecretMap } from "../secret-sync-types"; + +const PAGE_LIMIT = 100; + +const listWindmillVariables = async ({ instanceUrl, workspace, accessToken, path }: TWindmillListVariables) => { + const variables: Record = {}; + + // windmill paginates but doesn't return if there's more pages so we need to check if page size full + let page: number | null = 1; + + while (page) { + // eslint-disable-next-line no-await-in-loop + const { data: variablesPage } = await request.get( + `${instanceUrl}/api/w/${workspace}/variables/list`, + { + headers: { + Authorization: `Bearer ${accessToken}` + }, + params: { + page, + limit: PAGE_LIMIT, + path_start: path + } + } + ); + + for (const variable of variablesPage) { + const variableName = variable.path.replace(path, ""); + + if (variable.is_secret) { + // eslint-disable-next-line no-await-in-loop + const { data: variableValue } = await request.get( + `${instanceUrl}/api/w/${workspace}/variables/get_value/${variable.path}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + variables[variableName] = { + ...variable, + value: variableValue + }; + } else { + variables[variableName] = variable; + } + } + + if (variablesPage.length >= PAGE_LIMIT) { + page += 1; + } else { + page = null; + } + } + + return variables; +}; + +const createWindmillVariable = async ({ + path, + value, + instanceUrl, + accessToken, + workspace, + description +}: TPostWindmillVariable) => + request.post( + `${instanceUrl}/api/w/${workspace}/variables/create`, + { + path, + value, + is_secret: true, + description + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + +const updateWindmillVariable = async ({ + path, + value, + instanceUrl, + accessToken, + workspace, + description +}: TPostWindmillVariable) => + request.post( + `${instanceUrl}/api/w/${workspace}/variables/update/${path}`, + { + value, + is_secret: true, + description + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + +const deleteWindmillVariable = async ({ path, instanceUrl, accessToken, workspace }: TDeleteWindmillVariable) => + request.delete(`${instanceUrl}/api/w/${workspace}/variables/delete/${path}`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + +export const WindmillSyncFns = { + syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { path }, + syncOptions: { disableSecretDeletion } + } = secretSync; + + // url needs to be lowercase + const workspace = secretSync.destinationConfig.workspace.toLowerCase(); + + const instanceUrl = await getWindmillInstanceUrl(connection); + + const { accessToken } = connection.credentials; + + const variables = await listWindmillVariables({ instanceUrl, accessToken, workspace, path }); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value, comment = "" }] = entry; + + try { + const payload = { + instanceUrl, + workspace, + path: path + key, + value, + accessToken, + description: comment + }; + if (key in variables) { + if (variables[key].value !== value || variables[key].description !== comment) + await updateWindmillVariable(payload); + } else { + await createWindmillVariable(payload); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const [key, variable] of Object.entries(variables)) { + if (!(key in secretMap)) { + try { + await deleteWindmillVariable({ + instanceUrl, + workspace, + path: variable.path, + accessToken + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + removeSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { path } + } = secretSync; + + // url needs to be lowercase + const workspace = secretSync.destinationConfig.workspace.toLowerCase(); + + const instanceUrl = await getWindmillInstanceUrl(connection); + + const { accessToken } = connection.credentials; + + const variables = await listWindmillVariables({ instanceUrl, accessToken, workspace, path }); + + for await (const [key, variable] of Object.entries(variables)) { + if (key in secretMap) { + try { + await deleteWindmillVariable({ + path: variable.path, + instanceUrl, + workspace, + accessToken + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TWindmillSyncWithCredentials) => { + const { + connection, + destinationConfig: { path } + } = secretSync; + + // url needs to be lowercase + const workspace = secretSync.destinationConfig.workspace.toLowerCase(); + + const instanceUrl = await getWindmillInstanceUrl(connection); + + const { accessToken } = connection.credentials; + + const variables = await listWindmillVariables({ instanceUrl, accessToken, workspace, path }); + + return Object.fromEntries( + Object.entries(variables).map(([key, variable]) => [key, { value: variable.value ?? "" }]) + ); + } +}; diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts b/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts new file mode 100644 index 000000000..5740e21c9 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; +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 pathCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen +]); + +const WindmillSyncDestinationConfigSchema = z.object({ + workspace: z.string().trim().min(1, "Workspace required").describe(SecretSyncs.DESTINATION_CONFIG.WINDMILL.workspace), + path: z + .string() + .trim() + .min(1, "Path required") + .refine( + (val) => + (val.startsWith("u/") || val.startsWith("f/")) && + val.endsWith("/") && + val.split("/").length >= 3 && + val + .split("/") + .slice(0, -1) // Remove last empty segment from trailing slash + .every((segment) => segment && pathCharacterValidator(segment)), + 'Invalid path - must follow Windmill path format. ex: "f/folder/path/"' + ) + .describe(SecretSyncs.DESTINATION_CONFIG.WINDMILL.path) +}); + +const WindmillSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const WindmillSyncSchema = BaseSecretSyncSchema(SecretSync.Windmill, WindmillSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Windmill), + destinationConfig: WindmillSyncDestinationConfigSchema +}); + +export const CreateWindmillSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Windmill, + WindmillSyncOptionsConfig +).extend({ + destinationConfig: WindmillSyncDestinationConfigSchema +}); + +export const UpdateWindmillSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Windmill, + WindmillSyncOptionsConfig +).extend({ + destinationConfig: WindmillSyncDestinationConfigSchema.optional() +}); + +export const WindmillSyncListItemSchema = z.object({ + name: z.literal("Windmill"), + connection: z.literal(AppConnection.Windmill), + destination: z.literal(SecretSync.Windmill), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-types.ts b/backend/src/services/secret-sync/windmill/windmill-sync-types.ts new file mode 100644 index 000000000..9837599d7 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-types.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { TWindmillConnection } from "@app/services/app-connection/windmill"; + +import { CreateWindmillSyncSchema, WindmillSyncListItemSchema, WindmillSyncSchema } from "./windmill-sync-schemas"; + +export type TWindmillSync = z.infer; + +export type TWindmillSyncInput = z.infer; + +export type TWindmillSyncListItem = z.infer; + +export type TWindmillSyncWithCredentials = TWindmillSync & { + connection: TWindmillConnection; +}; + +export type TWindmillVariable = { + path: string; + value: string; + is_secret: boolean; + is_oauth: boolean; + description: string; +}; + +export type TWindmillListVariablesResponse = TWindmillVariable[]; + +export type TWindmillListVariables = { + accessToken: string; + instanceUrl: string; + path: string; + workspace: string; + description?: string; +}; + +export type TPostWindmillVariable = TWindmillListVariables & { + value: string; +}; + +export type TDeleteWindmillVariable = TWindmillListVariables; diff --git a/backend/src/services/service-token/service-token-dal.ts b/backend/src/services/service-token/service-token-dal.ts index ed9c5de7e..adb2f325a 100644 --- a/backend/src/services/service-token/service-token-dal.ts +++ b/backend/src/services/service-token/service-token-dal.ts @@ -28,5 +28,36 @@ export const serviceTokenDALFactory = (db: TDbClient) => { } }; - return { ...stOrm, findById }; + const findExpiringTokens = async (tx?: Knex, batchSize = 500, offset = 0) => { + try { + const batch: { name: string; projectName: string; createdByEmail: string; id: string; projectId: string }[] = + await (tx || db.replicaNode())(TableName.ServiceToken) + .leftJoin( + TableName.Users, + `${TableName.Users}.id`, + db.raw(`${TableName.ServiceToken}."createdBy"::uuid`) + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.ServiceToken}.projectId`) + .whereRaw( + `${TableName.ServiceToken}."expiresAt" < NOW() + INTERVAL '1 day' AND ${TableName.ServiceToken}."expiryNotificationSent" = false` + ) + .whereNotNull(`${TableName.Users}.email`) + .select( + db.ref("id").withSchema(TableName.ServiceToken), + db.ref("name").withSchema(TableName.ServiceToken), + db.ref("projectId").withSchema(TableName.ServiceToken), + db.ref("createdBy").withSchema(TableName.ServiceToken), + db.ref("email").withSchema(TableName.Users).as("createdByEmail"), + db.ref("name").withSchema(TableName.Project).as("projectName") + ) + .limit(batchSize) + .offset(offset); + + return batch; + } catch (err) { + throw new DatabaseError({ error: err, name: "FindExpiredTokens" }); + } + }; + + return { ...stOrm, findById, findExpiringTokens }; }; diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 9b87c29f8..bbd306bb5 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -12,11 +12,13 @@ import { } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue"; import { ActorType } from "../auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TServiceTokenDALFactory } from "./service-token-dal"; import { @@ -33,6 +35,7 @@ type TServiceTokenServiceFactoryDep = { projectEnvDAL: Pick; projectDAL: Pick; accessTokenQueue: Pick; + smtpService: Pick; }; export type TServiceTokenServiceFactory = ReturnType; @@ -43,7 +46,8 @@ export const serviceTokenServiceFactory = ({ permissionService, projectEnvDAL, projectDAL, - accessTokenQueue + accessTokenQueue, + smtpService }: TServiceTokenServiceFactoryDep) => { const createServiceToken = async ({ iv, @@ -185,11 +189,56 @@ export const serviceTokenServiceFactory = ({ return { ...serviceToken, lastUsed: new Date(), orgId: project.orgId }; }; + const notifyExpiringTokens = async () => { + const appCfg = getConfig(); + let processedCount = 0; + let hasMoreRecords = true; + let offset = 0; + const batchSize = 500; + + while (hasMoreRecords) { + // eslint-disable-next-line no-await-in-loop + const expiringTokens = await serviceTokenDAL.findExpiringTokens(undefined, batchSize, offset); + + if (expiringTokens.length === 0) { + hasMoreRecords = false; + break; + } + + // eslint-disable-next-line no-await-in-loop + await Promise.all( + expiringTokens.map(async (token) => { + try { + await smtpService.sendMail({ + recipients: [token.createdByEmail], + subjectLine: "Service Token Expiry Notice", + template: SmtpTemplates.ServiceTokenExpired, + substitutions: { + tokenName: token.name, + projectName: token.projectName, + url: `${appCfg.SITE_URL}/secret-manager/${token.projectId}/access-management?selectedTab=service-tokens` + } + }); + await serviceTokenDAL.update({ id: token.id }, { expiryNotificationSent: true }); + } catch (error) { + logger.error(error, `Failed to send expiration notification for token ${token.id}:`); + } + }) + ); + + processedCount += expiringTokens.length; + offset += batchSize; + } + + return processedCount; + }; + return { createServiceToken, deleteServiceToken, getServiceToken, getProjectServiceTokens, - fnValidateServiceToken + fnValidateServiceToken, + notifyExpiringTokens }; }; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 452283235..25f5f3949 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -43,7 +43,8 @@ export enum SmtpTemplates { SecretRequestCompleted = "secretRequestCompleted.handlebars", SecretRotationFailed = "secretRotationFailed.handlebars", ProjectAccessRequest = "projectAccess.handlebars", - OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars" + OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars", + ServiceTokenExpired = "serviceTokenExpired.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/serviceTokenExpired.handlebars b/backend/src/services/smtp/templates/serviceTokenExpired.handlebars new file mode 100644 index 000000000..199150c05 --- /dev/null +++ b/backend/src/services/smtp/templates/serviceTokenExpired.handlebars @@ -0,0 +1,19 @@ + + + + + + Service Token Expiring Soon + + + +

Service Token Expiry Notice

+

Your service token "{{tokenName}}" will expire within 24 hours.

+ +

This token is currently being used on project "{{projectName}}". If this token is still needed for your workflow, please create a new one before it expires.

+ + Create New Token + + {{emailFooter}} + + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/windmill/available.mdx b/docs/api-reference/endpoints/app-connections/windmill/available.mdx new file mode 100644 index 000000000..c202bf368 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/windmill/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/create.mdx b/docs/api-reference/endpoints/app-connections/windmill/create.mdx new file mode 100644 index 000000000..0894dad27 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/windmill" +--- + + + Check out the configuration docs for [Windmill Connections](/integrations/app-connections/windmill) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/windmill/delete.mdx b/docs/api-reference/endpoints/app-connections/windmill/delete.mdx new file mode 100644 index 000000000..7a966c338 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/windmill/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/windmill/get-by-id.mdx new file mode 100644 index 000000000..95ae6dc5c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/windmill/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/windmill/get-by-name.mdx new file mode 100644 index 000000000..fdfdbcb48 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/windmill/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/list.mdx b/docs/api-reference/endpoints/app-connections/windmill/list.mdx new file mode 100644 index 000000000..a69d46451 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/windmill" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/update.mdx b/docs/api-reference/endpoints/app-connections/windmill/update.mdx new file mode 100644 index 000000000..a700ffea9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/windmill/{connectionId}" +--- + + + Check out the configuration docs for [Windmill Connections](/integrations/app-connections/windmill) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/create.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/create.mdx new file mode 100644 index 000000000..422cab9c1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/windmill" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/delete.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/delete.mdx new file mode 100644 index 000000000..05d039cb8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/windmill/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-id.mdx new file mode 100644 index 000000000..25f040de5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/windmill/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-name.mdx new file mode 100644 index 000000000..cf10c1c6e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/windmill/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/import-secrets.mdx new file mode 100644 index 000000000..c2cf0cdc4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/windmill/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/list.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/list.mdx new file mode 100644 index 000000000..175a72f9c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/windmill" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/remove-secrets.mdx new file mode 100644 index 000000000..8f6bfb02e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/windmill/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/sync-secrets.mdx new file mode 100644 index 000000000..040345641 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/windmill/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/update.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/update.mdx new file mode 100644 index 000000000..2b846691e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/windmill/{syncId}" +--- diff --git a/docs/documentation/platform/access-controls/role-based-access-controls.mdx b/docs/documentation/platform/access-controls/role-based-access-controls.mdx index 9d1170f69..341c20868 100644 --- a/docs/documentation/platform/access-controls/role-based-access-controls.mdx +++ b/docs/documentation/platform/access-controls/role-based-access-controls.mdx @@ -25,7 +25,7 @@ By default, every user in a project is either a **viewer**, **developer**, or an As such: - **Admin**: This role enables identities to have access to all environments, folders, secrets, and actions within the project. -- **Developers**: This role restricts identities from performing project control actions, updating Approval Workflow policies, managing roles/members, and more. +- **Developers**: This role restricts identities from performing project control actions, updating Approval Workflow policies, managing roles, editing and removing project members, and more. - **Viewer**: The most limiting bulit-in role on the project level – it forbids user and machine identities to perform any action and rather shows them in the read-only mode. ![Project member role](/images/platform/access-controls/rbac.png) diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index 9419d0976..bde87f42f 100644 --- a/docs/documentation/platform/sso/auth0-oidc.mdx +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -42,7 +42,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." 3.1. Back in Infisical, in the Organization settings > Security > OIDC, click **Connect**. ![OIDC auth0 manage org Infisical](../../../images/sso/auth0-oidc/org-oidc-overview.png) - 3.2. For configuration type, select **Discovery URL**. Then, set **Discovery Document URL**, **Client ID**, and **Client Secret** from step 2.1 and 2.2. + 3.2. For configuration type, select **Discovery URL**. Then, set **Discovery Document URL**, **JWT Signature Algorithm**, **Client ID**, and **Client Secret** from step 2.1 and 2.2. ![OIDC auth0 paste values into Infisical](../../../images/sso/auth0-oidc/org-update-oidc.png) Once you've done that, press **Update** to complete the required configuration. @@ -65,7 +65,9 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." We recommend ensuring that your account is provisioned using the application in Auth0 prior to enforcing OIDC SSO to prevent any unintended issues. - + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/auth0-saml.mdx b/docs/documentation/platform/sso/auth0-saml.mdx index b77c733f7..b426d1aae 100644 --- a/docs/documentation/platform/sso/auth0-saml.mdx +++ b/docs/documentation/platform/sso/auth0-saml.mdx @@ -72,6 +72,10 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Auth0 user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 21236cd5b..282cddae5 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -106,6 +106,9 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." We recommend ensuring that your account is provisioned the application in Azure prior to enforcing SAML SSO to prevent any unintended issues. + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/general-oidc.mdx b/docs/documentation/platform/sso/general-oidc.mdx index 7e3a76ff0..11216b893 100644 --- a/docs/documentation/platform/sso/general-oidc.mdx +++ b/docs/documentation/platform/sso/general-oidc.mdx @@ -66,6 +66,9 @@ Prerequisites: We recommend ensuring that your account is provisioned using the identity provider prior to enforcing OIDC SSO to prevent any unintended issues. + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 7e47d1137..87ffa8412 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -81,6 +81,9 @@ description: "Learn how to configure Google SAML for Infisical SSO." We recommend ensuring that your account is provisioned the application in Google prior to enforcing SAML SSO to prevent any unintended issues. + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index ec876b9da..6ca20c752 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -86,6 +86,9 @@ description: "Learn how to configure JumpCloud SAML for Infisical SSO." We recommend ensuring that your account is provisioned the application in JumpCloud prior to enforcing SAML SSO to prevent any unintended issues. + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 4f5bc689e..6d8f4e4c8 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -69,7 +69,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." 3.1. Back in Infisical, in the Organization settings > Security > OIDC, click Connect. ![OIDC keycloak manage org Infisical](/images/sso/keycloak-oidc/manage-org-oidc.png) - 3.2. For configuration type, select Discovery URL. Then, set the appropriate values for **Discovery Document URL**, **Client ID**, and **Client Secret**. + 3.2. For configuration type, select Discovery URL. Then, set the appropriate values for **Discovery Document URL**, **JWT Signature Algorithm**, **Client ID**, and **Client Secret**. ![OIDC keycloak paste values into Infisical](/images/sso/keycloak-oidc/create-oidc.png) Once you've done that, press **Update** to complete the required configuration. @@ -92,7 +92,9 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." We recommend ensuring that your account is provisioned using the application in Keycloak prior to enforcing OIDC SSO to prevent any unintended issues. - + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/keycloak-saml.mdx b/docs/documentation/platform/sso/keycloak-saml.mdx index 86352bb9d..7e4004122 100644 --- a/docs/documentation/platform/sso/keycloak-saml.mdx +++ b/docs/documentation/platform/sso/keycloak-saml.mdx @@ -127,6 +127,9 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." We recommend ensuring that your account is provisioned the application in Keycloak prior to enforcing SAML SSO to prevent any unintended issues. + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 9f28f4c4e..1abd03d6f 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -94,6 +94,9 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." We recommend ensuring that your account is provisioned the application in Okta prior to enforcing SAML SSO to prevent any unintended issues. + + In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/images/app-connections/windmill/create-windmill-access-token.png b/docs/images/app-connections/windmill/create-windmill-access-token.png new file mode 100644 index 000000000..0c4ae06f9 Binary files /dev/null and b/docs/images/app-connections/windmill/create-windmill-access-token.png differ diff --git a/docs/images/app-connections/windmill/select-windmill-connection.png b/docs/images/app-connections/windmill/select-windmill-connection.png new file mode 100644 index 000000000..dabead13e Binary files /dev/null and b/docs/images/app-connections/windmill/select-windmill-connection.png differ diff --git a/docs/images/app-connections/windmill/windmill-access-token-created.png b/docs/images/app-connections/windmill/windmill-access-token-created.png new file mode 100644 index 000000000..6035bf0ad Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-access-token-created.png differ diff --git a/docs/images/app-connections/windmill/windmill-account-settings.png b/docs/images/app-connections/windmill/windmill-account-settings.png new file mode 100644 index 000000000..b2a63d997 Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-account-settings.png differ diff --git a/docs/images/app-connections/windmill/windmill-copy-token.png b/docs/images/app-connections/windmill/windmill-copy-token.png new file mode 100644 index 000000000..4c5a5e8ca Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-copy-token.png differ diff --git a/docs/images/app-connections/windmill/windmill-create-token.png b/docs/images/app-connections/windmill/windmill-create-token.png new file mode 100644 index 000000000..c5a4ac004 Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-create-token.png differ diff --git a/docs/images/app-connections/windmill/windmill-new-token.png b/docs/images/app-connections/windmill/windmill-new-token.png new file mode 100644 index 000000000..9f103ad41 Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-new-token.png differ diff --git a/docs/images/secret-syncs/windmill/select-windmill-option.png b/docs/images/secret-syncs/windmill/select-windmill-option.png new file mode 100644 index 000000000..0108dd2da Binary files /dev/null and b/docs/images/secret-syncs/windmill/select-windmill-option.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-created.png b/docs/images/secret-syncs/windmill/windmill-sync-created.png new file mode 100644 index 000000000..37abbba49 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-created.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-destination.png b/docs/images/secret-syncs/windmill/windmill-sync-destination.png new file mode 100644 index 000000000..48b54ad79 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-destination.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-details.png b/docs/images/secret-syncs/windmill/windmill-sync-details.png new file mode 100644 index 000000000..9871cc57d Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-details.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-options.png b/docs/images/secret-syncs/windmill/windmill-sync-options.png new file mode 100644 index 000000000..37ca24cc4 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-options.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-review.png b/docs/images/secret-syncs/windmill/windmill-sync-review.png new file mode 100644 index 000000000..2a8a35e9c Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-review.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-source.png b/docs/images/secret-syncs/windmill/windmill-sync-source.png new file mode 100644 index 000000000..b7d94c048 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-source.png differ diff --git a/docs/images/sso/auth0-oidc/org-update-oidc.png b/docs/images/sso/auth0-oidc/org-update-oidc.png index 0b9e96b5b..bd61584a5 100644 Binary files a/docs/images/sso/auth0-oidc/org-update-oidc.png and b/docs/images/sso/auth0-oidc/org-update-oidc.png differ diff --git a/docs/images/sso/keycloak-oidc/create-oidc.png b/docs/images/sso/keycloak-oidc/create-oidc.png index 358af1330..bf8aceb05 100644 Binary files a/docs/images/sso/keycloak-oidc/create-oidc.png and b/docs/images/sso/keycloak-oidc/create-oidc.png differ diff --git a/docs/integrations/app-connections/windmill.mdx b/docs/integrations/app-connections/windmill.mdx new file mode 100644 index 000000000..ca4aa7da4 --- /dev/null +++ b/docs/integrations/app-connections/windmill.mdx @@ -0,0 +1,112 @@ +--- +title: "Windmill Connection" +description: "Learn how to configure a Windmill Connection for Infisical." +--- + +Infisical supports connecting to Windmill using an **Access Token** to securely sync your secrets to Windmill. + +## Get a Windmill Access Token + +Ensure the user generating the access token has the required role and permissions based on your use-case: + + + + The user generating the access token should be at least a `Developer` in the configured workspace and have `write` permissions for the workspace path secrets will be synced to. + + + + + + + + In Windmill, click on your user in the sidebar and select **Account Settings**. + ![Windmill Account Settings](/images/app-connections/windmill/windmill-account-settings.png) + + + In the **Tokens** section on the drawer, click **Create token**. + ![Windmill Create Token](/images/app-connections/windmill/windmill-create-token.png) + + + Give your token a name and click **New token**. + + If you configure an expiry date for your access token, you must manually rotate to a new token before the expiration date to prevent service interruption. + + ![Windmill New Token](/images/app-connections/windmill/windmill-new-token.png) + + + Copy your new access token and save it for the steps below. + ![Windmill Copy Token](/images/app-connections/windmill/windmill-copy-token.png) + + + + +## Setup Windmill Connection in Infisical + + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Windmill Connection** option. + ![Select Windmill Connection](/images/app-connections/windmill/select-windmill-connection.png) + + + Configure your Windmill Connection using the access token generated in the steps above. Then click **Connect to Windmill**. + ![Windmill Configure Connection](/images/app-connections/windmill/create-windmill-access-token.png) + + - **Name**: The name of the connection to be created. Must be slug-friendly. + - **Description**: An optional description to provide details about this connection. + - **Instance URL**: The URL of your Windmill instance. If you are not self-hosting Windmill you can leave this field blank. + - **Access Token**: The access token generated in the steps above. + + + Your Windmill Connection is now available for use. + ![Windmill Connection Created](/images/app-connections/windmill/windmill-access-token-created.png) + + + + + To create a Windmill Connection, make an API request to the [Create Windmill + Connection](/api-reference/endpoints/app-connections/windmill/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/windmill \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-windmill-connection", + "method": "access-token", + "credentials": { + "token": "...", + "instanceUrl": "https://app.windmill.dev" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-windmill-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "windmill", + "method": "access-token", + "credentials": { + "instanceUrl": "https://app.windmill.dev" + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/windmill.mdx b/docs/integrations/secret-syncs/windmill.mdx new file mode 100644 index 000000000..90d35f8b8 --- /dev/null +++ b/docs/integrations/secret-syncs/windmill.mdx @@ -0,0 +1,147 @@ +--- +title: "Windmill Sync" +description: "Learn how to configure a Windmill Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Windmill Connection](/integrations/app-connections/windmill) with the required **Secret Sync** permissions + + + + 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 **Windmill** option. + ![Select Windmill](/images/secret-syncs/windmill/select-windmill-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/windmill/windmill-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/windmill/windmill-sync-destination.png) + + - **Windmill Connection**: The Windmill Connection to authenticate with. + - **Workspace**: The Windmill workspace to sync secrets to. + - **Path**: The workspace path to sync secrets to. + + + Workspace path must conform to Windmill's [owner path convention](https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path). + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/windmill/windmill-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 destination endpoint before syncing, prioritizing values from Infisical over Windmill when keys conflict. + - **Import Secrets (Prioritize Windmill)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Windmill over Infisical when keys conflict. + - **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 Windmill Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/windmill/windmill-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Windmill Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/windmill/windmill-sync-review.png) + + 8. If enabled, your Windmill Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/windmill/windmill-sync-created.png) + + + + To create an **Windmill Sync**, make an API request to the [Create Windmill Sync](/api-reference/endpoints/secret-syncs/windmill/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/windmill \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-windmill-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": { + "workspace": "my-workspace", + "path": "f/folder/path/" + } + }' + ``` + + + Workspace path must conform to Windmill's [owner path convention](https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path). + + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-windmill-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": "windmill", + "name": "my-windmill-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": "windmill", + "destinationConfig": { + "workspace": "my-workspace", + "path": "f/folder/path/" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index 38d44afa9..6c2f51a96 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -429,7 +429,8 @@ "integrations/app-connections/mssql", "integrations/app-connections/postgres", "integrations/app-connections/terraform-cloud", - "integrations/app-connections/vercel" + "integrations/app-connections/vercel", + "integrations/app-connections/windmill" ] } ] @@ -451,7 +452,8 @@ "integrations/secret-syncs/github", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/terraform-cloud", - "integrations/secret-syncs/vercel" + "integrations/secret-syncs/vercel", + "integrations/secret-syncs/windmill" ] } ] @@ -1085,6 +1087,18 @@ "api-reference/endpoints/app-connections/vercel/update", "api-reference/endpoints/app-connections/vercel/delete" ] + }, + { + "group": "Windmill", + "pages": [ + "api-reference/endpoints/app-connections/windmill/list", + "api-reference/endpoints/app-connections/windmill/available", + "api-reference/endpoints/app-connections/windmill/get-by-id", + "api-reference/endpoints/app-connections/windmill/get-by-name", + "api-reference/endpoints/app-connections/windmill/create", + "api-reference/endpoints/app-connections/windmill/update", + "api-reference/endpoints/app-connections/windmill/delete" + ] } ] }, @@ -1238,8 +1252,22 @@ "api-reference/endpoints/secret-syncs/vercel/update", "api-reference/endpoints/secret-syncs/vercel/delete", "api-reference/endpoints/secret-syncs/vercel/sync-secrets", - "api-reference/endpoints/secret-syncs/vercel/remove-secrets", - "api-reference/endpoints/secret-syncs/vercel/import-secrets" + "api-reference/endpoints/secret-syncs/vercel/import-secrets", + "api-reference/endpoints/secret-syncs/vercel/remove-secrets" + ] + }, + { + "group": "Windmill", + "pages": [ + "api-reference/endpoints/secret-syncs/windmill/list", + "api-reference/endpoints/secret-syncs/windmill/get-by-id", + "api-reference/endpoints/secret-syncs/windmill/get-by-name", + "api-reference/endpoints/secret-syncs/windmill/create", + "api-reference/endpoints/secret-syncs/windmill/update", + "api-reference/endpoints/secret-syncs/windmill/delete", + "api-reference/endpoints/secret-syncs/windmill/sync-secrets", + "api-reference/endpoints/secret-syncs/windmill/import-secrets", + "api-reference/endpoints/secret-syncs/windmill/remove-secrets" ] } ] diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx index 344b0a663..4cf75a5c6 100644 --- a/docs/sdks/languages/csharp.mdx +++ b/docs/sdks/languages/csharp.mdx @@ -9,6 +9,12 @@ If you're working with C#, the official [Infisical C# SDK](https://github.com/In - [Nuget Package](https://www.nuget.org/packages/Infisical.Sdk) - [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/csharp) + + **Deprecation Notice** + + All versions prior to **2.3.9** should be considered deprecated and are no longer supported by Infisical. Please update to version **2.3.9** or newer. All changes are fully backwards compatible with older versions. + + ## Basic Usage ```cs diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index b5792611d..b12b442a8 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -28,7 +28,7 @@ func main() { AutoTokenRefresh: true, // Wether or not to let the SDK handle the access token lifecycle. Defaults to true if not specified. }) - _, err = client.Auth().UniversalAuthLogin("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET") + _, err := client.Auth().UniversalAuthLogin("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET") if err != nil { fmt.Printf("Authentication failed: %v", err) diff --git a/docs/sdks/languages/ruby.mdx b/docs/sdks/languages/ruby.mdx index 617594957..b6fe0863a 100644 --- a/docs/sdks/languages/ruby.mdx +++ b/docs/sdks/languages/ruby.mdx @@ -6,11 +6,17 @@ icon: "diamond" -If you're working with Ruby , the official [Infisical Ruby SDK](https://github.com/infisical/sdk) package is the easiest way to fetch and work with secrets for your application. +If you're working with Ruby, the official [Infisical Ruby SDK](https://github.com/infisical/sdk) package is the easiest way to fetch and work with secrets for your application. - [Ruby Package](https://rubygems.org/gems/infisical-sdk) - [Github Repository](https://github.com/infisical/sdk) + + **Deprecation Notice** + + All versions prior to **2.3.9** should be considered deprecated and are no longer supported by Infisical. Please update to version **2.3.9** or newer. All changes are fully backwards compatible with older versions. + + ## Basic Usage ```ruby diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 103c6400e..8318869f2 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -34,8 +34,14 @@ Used to configure platform-specific security and operational settings this to `false`. - - Determines whether App Connections and Dynamic Secrets are permitted to connect with internal/private IP addresses. + + Determines whether App Connections and Dynamic Secrets are permitted to + connect with internal/private IP addresses. ## CORS @@ -45,24 +51,31 @@ The following environment variables can be used to configure the Infisical Rest - Specify a list of origins that are allowed to access the Infisical API. +Specify a list of origins that are allowed to access the Infisical API. - An example value would be `CORS_ALLOWED_ORIGINS=["https://example.com"]`. +An example value would be `CORS_ALLOWED_ORIGINS=["https://example.com"]`. + +Defaults to the same value as your `SITE_URL` environment variable. - Defaults to the same value as your `SITE_URL` environment variable. Array of HTTP methods allowed for CORS requests. - Defaults to reflecting the headers specified in the request's Access-Control-Request-Headers header. - +Defaults to reflecting the headers specified in the request's Access-Control-Request-Headers header. + ## Data Layer The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks +### PostgreSQL + + + Please note that the database user must have **CREATE** privileges along with ability to create and modify tables. This is needed for Infisical to run schema migrations. + + Postgres database connection string. @@ -73,10 +86,6 @@ The platform utilizes Postgres to persist all of its data and Redis for caching `echo "" | base64` - - Redis connection string. - - Postgres database read replica connection strings. It accepts a JSON string. ``` @@ -97,6 +106,12 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] +### Redis + + + Redis connection string. + + ## Email Service Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. @@ -455,6 +470,7 @@ You can configure third-party app connections for re-use across Infisical Projec The AWS IAM User secret key for assuming roles + @@ -477,6 +493,7 @@ You can configure third-party app connections for re-use across Infisical Projec The private key for the GitHub App + @@ -487,6 +504,7 @@ You can configure third-party app connections for re-use across Infisical Projec The OAuth2 client secret for GitHub OAuth Connection + ## Native Secret Integrations diff --git a/frontend/public/images/sso/Auth0.png b/frontend/public/images/sso/Auth0.png new file mode 100644 index 000000000..e86d76c06 Binary files /dev/null and b/frontend/public/images/sso/Auth0.png differ diff --git a/frontend/public/images/sso/Google.png b/frontend/public/images/sso/Google.png new file mode 100644 index 000000000..b3ed76596 Binary files /dev/null and b/frontend/public/images/sso/Google.png differ diff --git a/frontend/public/images/sso/JumpCloud.png b/frontend/public/images/sso/JumpCloud.png new file mode 100644 index 000000000..94d15f71e Binary files /dev/null and b/frontend/public/images/sso/JumpCloud.png differ diff --git a/frontend/public/images/sso/Keycloak.png b/frontend/public/images/sso/Keycloak.png new file mode 100644 index 000000000..86405f3af Binary files /dev/null and b/frontend/public/images/sso/Keycloak.png differ diff --git a/frontend/public/images/sso/Microsoft Azure.png b/frontend/public/images/sso/Microsoft Azure.png new file mode 100644 index 000000000..c9388d612 Binary files /dev/null and b/frontend/public/images/sso/Microsoft Azure.png differ diff --git a/frontend/public/images/sso/Okta.png b/frontend/public/images/sso/Okta.png new file mode 100644 index 000000000..d742d4347 Binary files /dev/null and b/frontend/public/images/sso/Okta.png differ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 854c841dd..b2008b4f6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -14,6 +14,7 @@ import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; +import { WindmillSyncFields } from "./WindmillSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -43,6 +44,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Vercel: return ; + case SecretSync.Windmill: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/WindmillSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/WindmillSyncFields.tsx new file mode 100644 index 000000000..8578fa63c --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/WindmillSyncFields.tsx @@ -0,0 +1,105 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Input, Tooltip } from "@app/components/v2"; +import { + TWindmillWorkspace, + useWindmillConnectionListWorkspaces +} from "@app/hooks/api/appConnections/windmill"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const WindmillSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Windmill } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: workspaces, isLoading: isWorkspacesLoading } = useWindmillConnectionListWorkspaces( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.workspace", ""); + }} + /> + + ( + +
+ Don't see the workspace you're looking for?{" "} + +
+ + } + > + workspace.name === value) ?? null} + onChange={(option) => + onChange((option as SingleValue)?.name ?? null) + } + options={workspaces} + placeholder="Select a workspace..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + ( + + The workspace path where secrets should be synced to. Path must follow Windmill{" "} + + + owner path convention + + . + + + } + isError={Boolean(error)} + errorText={error?.message} + label="Path" + > + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 5e8ff01d1..79c437d27 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -42,6 +42,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.TerraformCloud: case SecretSync.Camunda: case SecretSync.Vercel: + case SecretSync.Windmill: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 4e9e89427..99182f207 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -24,6 +24,7 @@ import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; +import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -84,6 +85,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Vercel: DestinationFieldsComponent = ; break; + case SecretSync.Windmill: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/WindmillSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/WindmillSyncReviewFields.tsx new file mode 100644 index 000000000..4d7f9e3d1 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/WindmillSyncReviewFields.tsx @@ -0,0 +1,18 @@ +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 WindmillSyncReviewFields = () => { + const { watch } = useFormContext(); + const workspace = watch("destinationConfig.workspace"); + const path = watch("destinationConfig.path"); + + return ( + <> + {workspace} + {path} + + ); +}; 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 4eb1094ae..d58e60b19 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 @@ -1,17 +1,17 @@ import { z } from "zod"; -import { AwsSecretsManagerSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema"; -import { DatabricksSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/databricks-sync-destination-schema"; -import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema"; - import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; +import { AwsSecretsManagerSyncDestinationSchema } from "./aws-secrets-manager-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; +import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; +import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; +import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -24,7 +24,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ HumanitecSyncDestinationSchema, TerraformCloudSyncDestinationSchema, CamundaSyncDestinationSchema, - VercelSyncDestinationSchema + VercelSyncDestinationSchema, + WindmillSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/windmill-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/windmill-sync-destination-schema.ts new file mode 100644 index 000000000..242f77140 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/windmill-sync-destination-schema.ts @@ -0,0 +1,21 @@ +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 WindmillSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Windmill), + destinationConfig: z.object({ + workspace: z.string().trim().min(1, "Workspace required"), + path: z + .string() + .trim() + .min(1, "Path required") + .regex( + /^([uf])\/([a-zA-Z0-9_-]+)(\/[a-zA-Z0-9_-]+)*\/$/, + 'Invalid path - must follow Windmill path format. ex: "f/folder/path/"' + ) + }) + }) +); diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx index 62002b492..4ce4a7ffa 100644 --- a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -112,9 +112,8 @@ const PasswordGeneratorModal = ({ className="w-full max-w-lg rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-xl" >
-

Password Generator

-

Generate strong unique passwords

- +

Generate Random Value

+

Generate strong unique values

{password}
@@ -146,18 +145,18 @@ const PasswordGeneratorModal = ({
-
setPasswordOptions({ ...passwordOptions, length: value })} className="mb-1" - aria-labelledby="password-length-label" + aria-labelledby="value-length-label" />
@@ -219,7 +218,7 @@ const PasswordGeneratorModal = ({ onClick={usePassword} className="ml-2" > - Use Password + Use Value )}
@@ -251,12 +250,13 @@ export const PasswordGenerator = ({ return ( <> diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 917ddf8cd..674dd2836 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -17,7 +17,8 @@ import { PostgresConnectionMethod, TAppConnection, TerraformCloudConnectionMethod, - VercelConnectionMethod + VercelConnectionMethod, + WindmillConnectionMethod } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record< @@ -46,6 +47,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, + [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 } }; @@ -75,6 +77,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; + case WindmillConnectionMethod.AccessToken: + return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; default: diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index 4e26e1b15..dcf80d452 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -1,6 +1,6 @@ import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types"; -enum OrgMembershipRole { +export enum OrgMembershipRole { Admin = "admin", Member = "member", NoAccess = "no-access" diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index fd7159c02..291b6f80a 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -35,6 +35,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Humanitec]: AppConnection.Humanitec, [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, - [SecretSync.Vercel]: AppConnection.Vercel + [SecretSync.Vercel]: AppConnection.Vercel, + [SecretSync.Windmill]: AppConnection.Windmill }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 1c4eb1a39..ebbcec1ec 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -12,5 +12,6 @@ export enum AppConnection { Postgres = "postgres", MsSql = "mssql", Camunda = "camunda", + Windmill = "windmill", Auth0 = "auth0" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 55b9f9316..b46aa4f6a 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -64,6 +64,10 @@ export type TCamundaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Camunda; }; +export type TWindmillConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Windmill; +}; + export type TAuth0ConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Auth0; }; @@ -82,6 +86,7 @@ export type TAppConnectionOption = | TPostgresConnectionOption | TMsSqlConnectionOption | TCamundaConnectionOption + | TWindmillConnectionOption | TAuth0ConnectionOption; export type TAppConnectionOptionMap = { @@ -98,5 +103,6 @@ export type TAppConnectionOptionMap = { [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; [AppConnection.Camunda]: TCamundaConnectionOption; + [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index db6026815..dc398d0ab 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -14,6 +14,7 @@ import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; +import { TWindmillConnection } from "./windmill-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; @@ -29,6 +30,7 @@ export * from "./mssql-connection"; export * from "./postgres-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; +export * from "./windmill-connection"; export type TAppConnection = | TAwsConnection @@ -44,6 +46,7 @@ export type TAppConnection = | TPostgresConnection | TMsSqlConnection | TCamundaConnection + | TWindmillConnection | TAuth0Connection; export type TAvailableAppConnection = Pick; @@ -85,5 +88,6 @@ export type TAppConnectionMap = { [AppConnection.MsSql]: TMsSqlConnection; [AppConnection.Camunda]: TCamundaConnection; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; + [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; }; diff --git a/frontend/src/hooks/api/appConnections/types/windmill-connection.ts b/frontend/src/hooks/api/appConnections/types/windmill-connection.ts new file mode 100644 index 000000000..85de7dd7e --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/windmill-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum WindmillConnectionMethod { + AccessToken = "access-token" +} + +export type TWindmillConnection = TRootAppConnection & { app: AppConnection.Windmill } & { + method: WindmillConnectionMethod.AccessToken; + credentials: { + accessToken: string; + instanceUrl?: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/windmill/index.ts b/frontend/src/hooks/api/appConnections/windmill/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/windmill/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/windmill/queries.tsx b/frontend/src/hooks/api/appConnections/windmill/queries.tsx new file mode 100644 index 000000000..40cc061d3 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/windmill/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TWindmillWorkspace } from "./types"; + +const windmillConnectionKeys = { + all: [...appConnectionKeys.all, "windmill"] as const, + listWorkspaces: (connectionId: string) => + [...windmillConnectionKeys.all, "workspaces", connectionId] as const +}; + +export const useWindmillConnectionListWorkspaces = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TWindmillWorkspace[], + unknown, + TWindmillWorkspace[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: windmillConnectionKeys.listWorkspaces(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/windmill/${connectionId}/workspaces` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/windmill/types.ts b/frontend/src/hooks/api/appConnections/windmill/types.ts new file mode 100644 index 000000000..b08a40856 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/windmill/types.ts @@ -0,0 +1,4 @@ +export type TWindmillWorkspace = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index 9c7927458..4cf4ede93 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -4,6 +4,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { oidcConfigKeys } from "./queries"; +import { OIDCJWTSignatureAlgorithm } from "./types"; export const useUpdateOIDCConfig = () => { const queryClient = useQueryClient(); @@ -21,7 +22,8 @@ export const useUpdateOIDCConfig = () => { clientSecret, isActive, orgSlug, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }: { allowedEmailDomains?: string; issuer?: string; @@ -36,6 +38,7 @@ export const useUpdateOIDCConfig = () => { configurationType?: string; orgSlug: string; manageGroupMemberships?: boolean; + jwtSignatureAlgorithm?: OIDCJWTSignatureAlgorithm; }) => { const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { issuer, @@ -50,7 +53,8 @@ export const useUpdateOIDCConfig = () => { orgSlug, clientSecret, isActive, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }); return data; @@ -78,7 +82,8 @@ export const useCreateOIDCConfig = () => { clientSecret, isActive, orgSlug, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }: { issuer?: string; configurationType: string; @@ -93,6 +98,7 @@ export const useCreateOIDCConfig = () => { orgSlug: string; allowedEmailDomains?: string; manageGroupMemberships?: boolean; + jwtSignatureAlgorithm?: OIDCJWTSignatureAlgorithm; }) => { const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { issuer, @@ -107,7 +113,8 @@ export const useCreateOIDCConfig = () => { clientSecret, isActive, orgSlug, - manageGroupMemberships + manageGroupMemberships, + jwtSignatureAlgorithm }); return data; diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts index 3359e4487..7c41d3400 100644 --- a/frontend/src/hooks/api/oidcConfig/types.ts +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -13,4 +13,11 @@ export type OIDCConfigData = { clientSecret: string; allowedEmailDomains?: string; manageGroupMemberships: boolean; + jwtSignatureAlgorithm: OIDCJWTSignatureAlgorithm; }; + +export enum OIDCJWTSignatureAlgorithm { + RS256 = "RS256", + HS256 = "HS256", + RS512 = "RS512" +} diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 90e525cdf..902bb6b09 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -110,7 +110,8 @@ export const useUpdateOrg = () => { defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod, - allowSecretSharingOutsideOrganization + allowSecretSharingOutsideOrganization, + bypassOrgAuthEnabled }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -120,7 +121,8 @@ export const useUpdateOrg = () => { defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod, - allowSecretSharingOutsideOrganization + allowSecretSharingOutsideOrganization, + bypassOrgAuthEnabled }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index c563d8c43..e0687922d 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,6 +9,7 @@ export type Organization = { createAt: string; updatedAt: string; authEnforced: boolean; + bypassOrgAuthEnabled: boolean; orgAuthMethod: string; scimEnabled: boolean; slug: string; @@ -17,6 +18,7 @@ export type Organization = { selectedMfaMethod?: MfaMethod; shouldUseNewPrivilegeSystem: boolean; allowSecretSharingOutsideOrganization?: boolean; + userRole: string; }; export type UpdateOrgDTO = { @@ -29,6 +31,7 @@ export type UpdateOrgDTO = { enforceMfa?: boolean; selectedMfaMethod?: MfaMethod; allowSecretSharingOutsideOrganization?: boolean; + bypassOrgAuthEnabled?: boolean; }; export type BillingDetails = { diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 449ddf7e0..b0d3fd7bb 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -9,7 +9,8 @@ export enum SecretSync { Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Camunda = "camunda", - Vercel = "vercel" + Vercel = "vercel", + Windmill = "windmill" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index c6e5f2762..21fda56c7 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -1,17 +1,18 @@ import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; -import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync"; -import { TDatabricksSync } from "@app/hooks/api/secretSyncs/types/databricks-sync"; -import { TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync"; import { DiscriminativePick } from "@app/types"; +import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; +import { TDatabricksSync } from "./databricks-sync"; import { TGcpSync } from "./gcp-sync"; +import { TGitHubSync } from "./github-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; +import { TWindmillSync } from "./windmill-sync"; export type TSecretSyncOption = { name: string; @@ -30,7 +31,8 @@ export type TSecretSync = | THumanitecSync | TTerraformCloudSync | TCamundaSync - | TVercelSync; + | TVercelSync + | TWindmillSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/windmill-sync.ts b/frontend/src/hooks/api/secretSyncs/types/windmill-sync.ts new file mode 100644 index 000000000..220a7c165 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/windmill-sync.ts @@ -0,0 +1,16 @@ +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 TWindmillSync = TRootSecretSync & { + destination: SecretSync.Windmill; + destinationConfig: { + workspace: string; + path: string; + }; + connection: { + app: AppConnection.Windmill; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7d94972ea..ca8feb6b6 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -33,6 +33,7 @@ import { TGetUpgradeProjectStatusDTO, TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, + ToggleDeleteProjectProtectionDTO, TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, @@ -306,6 +307,25 @@ export const useToggleAutoCapitalization = () => { }); }; +export const useToggleDeleteProjectProtection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ workspaceID, state }) => { + const { data } = await apiRequest.post<{ workspace: Workspace }>( + `/api/v1/workspace/${workspaceID}/delete-protection`, + { + hasDeleteProtection: state + } + ); + return data.workspace; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getAllUserWorkspace() }); + } + }); +}; + export const useUpdateWorkspaceVersionLimit = () => { const queryClient = useQueryClient(); @@ -316,8 +336,8 @@ export const useUpdateWorkspaceVersionLimit = () => { }); return data.workspace; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: workspaceKeys.getAllUserWorkspace() }); + onSuccess: (dto) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getAllUserWorkspace(dto.type) }); } }); }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index afeb9a6c4..814a920c2 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -36,6 +36,7 @@ export type Workspace = { slug: string; createdAt: string; roles?: TProjectRole[]; + hasDeleteProtection: boolean; }; export type WorkspaceEnv = { @@ -80,6 +81,7 @@ export type UpdateProjectDTO = { export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; export type UpdateAuditLogsRetentionDTO = { projectSlug: string; auditLogsRetentionDays: number }; export type ToggleAutoCapitalizationDTO = { workspaceID: string; state: boolean }; +export type ToggleDeleteProjectProtectionDTO = { workspaceID: string; state: boolean }; export type DeleteWorkspaceDTO = { workspaceID: string }; diff --git a/frontend/src/pages/auth/AdminLoginPage/route.tsx b/frontend/src/pages/auth/AdminLoginPage/route.tsx new file mode 100644 index 000000000..221c6a34e --- /dev/null +++ b/frontend/src/pages/auth/AdminLoginPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LoginPage } from "../LoginPage/LoginPage"; + +export const Route = createFileRoute("/_restrict-login-signup/login/admin")({ + component: () => +}); diff --git a/frontend/src/pages/auth/LoginPage/Login.utils.tsx b/frontend/src/pages/auth/LoginPage/Login.utils.tsx index 11fcf0d2d..87164fc5d 100644 --- a/frontend/src/pages/auth/LoginPage/Login.utils.tsx +++ b/frontend/src/pages/auth/LoginPage/Login.utils.tsx @@ -33,14 +33,21 @@ export const useNavigateToSelectOrganization = () => { const { config } = useServerConfig(); const navigate = useNavigate(); - const navigateToSelectOrganization = async (cliCallbackPort?: string) => { + const navigateToSelectOrganization = async ( + cliCallbackPort?: string, + isFromAdminLogin?: boolean + ) => { if (!config.defaultAuthOrgId) { queryClient.invalidateQueries({ queryKey: userKeys.getUser }); } navigate({ to: "/login/select-organization", - search: { callback_port: cliCallbackPort, org_id: config.defaultAuthOrgId } + search: { + callback_port: cliCallbackPort, + org_id: config.defaultAuthOrgId, + is_admin_login: isFromAdminLogin + } }); }; diff --git a/frontend/src/pages/auth/LoginPage/LoginPage.tsx b/frontend/src/pages/auth/LoginPage/LoginPage.tsx index 74ced95ef..97bb528c6 100644 --- a/frontend/src/pages/auth/LoginPage/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage/LoginPage.tsx @@ -8,7 +8,7 @@ import { isLoggedIn } from "@app/hooks/api/reactQuery"; import { InitialStep, SSOStep } from "./components"; import { useNavigateToSelectOrganization } from "./Login.utils"; -export const LoginPage = () => { +export const LoginPage = ({ isAdmin }: { isAdmin?: boolean }) => { const { t } = useTranslation(); const [step, setStep] = useState(0); const [email, setEmail] = useState(""); @@ -44,6 +44,7 @@ export const LoginPage = () => { case 0: return ( void; password: string; setPassword: (email: string) => void; + isAdmin?: boolean; }; -export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => { +export const InitialStep = ({ + setStep, + email, + setEmail, + password, + setPassword, + isAdmin +}: Props) => { const navigate = useNavigate(); const { t } = useTranslation(); @@ -62,7 +70,9 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }; useEffect(() => { - if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug); + if (serverDetails?.samlDefaultOrgSlug && !isAdmin) { + redirectToSaml(serverDetails.samlDefaultOrgSlug); + } }, [serverDetails?.samlDefaultOrgSlug]); const handleSaml = () => { @@ -82,7 +92,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }; const shouldDisplayLoginMethod = (method: LoginMethod) => - !config.enabledLoginMethods || config.enabledLoginMethods.includes(method); + isAdmin || !config.enabledLoginMethods || config.enabledLoginMethods.includes(method); const handleLogin = async (e: FormEvent) => { e.preventDefault(); @@ -120,7 +130,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: if (isLoginSuccessful && isLoginSuccessful.success) { // case: login was successful - navigateToSelectOrganization(); + navigateToSelectOrganization(undefined, isAdmin); createNotification({ text: "Successfully logged in", type: "success" @@ -160,7 +170,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: setIsLoading(false); }; - if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) { + if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod && !isAdmin) { return (
{ const queryParams = new URLSearchParams(window.location.search); const orgId = queryParams.get("org_id"); const callbackPort = queryParams.get("callback_port"); + const isAdminLogin = queryParams.get("is_admin_login") === "true"; const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); const logout = useLogoutUser(true); @@ -68,7 +70,12 @@ export const SelectOrganizationPage = () => { const handleSelectOrganization = useCallback( async (organization: Organization) => { - if (organization.authEnforced) { + const canBypassOrgAuth = + organization.bypassOrgAuthEnabled && + organization.userRole === OrgMembershipRole.Admin && + isAdminLogin; + + if (organization.authEnforced && !canBypassOrgAuth) { // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO await logout.mutateAsync(); diff --git a/frontend/src/pages/auth/SelectOrgPage/route.tsx b/frontend/src/pages/auth/SelectOrgPage/route.tsx index 5d617ff5a..445479b3f 100644 --- a/frontend/src/pages/auth/SelectOrgPage/route.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/route.tsx @@ -6,13 +6,16 @@ import { SelectOrganizationPage } from "./SelectOrgPage"; export const SelectOrganizationPageQueryParams = z.object({ org_id: z.string().optional().catch(""), - callback_port: z.coerce.number().optional().catch(undefined) + callback_port: z.coerce.number().optional().catch(undefined), + is_admin_login: z.boolean().optional().catch(false) }); export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({ component: SelectOrganizationPage, validateSearch: zodValidator(SelectOrganizationPageQueryParams), search: { - middlewares: [stripSearchParams({ org_id: "", callback_port: undefined })] + middlewares: [ + stripSearchParams({ org_id: "", callback_port: undefined, is_admin_login: false }) + ] } }); diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 7b424b262..f5a56ee42 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -23,6 +23,7 @@ import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; +import { WindmillConnectionForm } from "./WindmillConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -87,6 +88,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.AzureClientSecrets: return ; + case AppConnection.Windmill: + return ; case AppConnection.Auth0: return ; default: @@ -151,6 +154,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.AzureClientSecrets: return ; + case AppConnection.Windmill: + return ; case AppConnection.Auth0: return ; default: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/WindmillConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/WindmillConnectionForm.tsx new file mode 100644 index 000000000..a1cc2209c --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/WindmillConnectionForm.tsx @@ -0,0 +1,158 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TWindmillConnection, WindmillConnectionMethod } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TWindmillConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Windmill) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(WindmillConnectionMethod.AccessToken), + credentials: z.object({ + accessToken: z.string().trim().min(1, "Access Token required"), + instanceUrl: z + .string() + .trim() + .transform((value) => value || undefined) + .refine((value) => (!value ? true : z.string().url().safeParse(value).success), { + message: "Invalid instance URL" + }) + .optional() + }) + }) +]); + +type FormData = z.infer; + +export const WindmillConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Windmill, + method: WindmillConnectionMethod.AccessToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + + + {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx index 30807bda9..4fff131a7 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -18,6 +18,7 @@ import { useOrganization } from "@app/context"; import { useToggle } from "@app/hooks"; import { useGetOIDCConfig } from "@app/hooks/api"; import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { OIDCJWTSignatureAlgorithm } from "@app/hooks/api/oidcConfig/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; enum ConfigurationType { @@ -43,7 +44,8 @@ const schema = z userinfoEndpoint: z.string().optional(), clientId: z.string().min(1), clientSecret: z.string().min(1), - allowedEmailDomains: z.string().optional() + allowedEmailDomains: z.string().optional(), + jwtSignatureAlgorithm: z.nativeEnum(OIDCJWTSignatureAlgorithm).optional() }) .superRefine((data, ctx) => { if (data.configurationType === ConfigurationType.CUSTOM) { @@ -159,6 +161,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele setValue("clientSecret", data.clientSecret); setValue("allowedEmailDomains", data.allowedEmailDomains); setValue("configurationType", data.configurationType); + setValue("jwtSignatureAlgorithm", data.jwtSignatureAlgorithm); } }, [data]); @@ -172,7 +175,8 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele configurationType, discoveryURL, clientId, - clientSecret + clientSecret, + jwtSignatureAlgorithm }: OIDCFormData) => { try { if (!currentOrg) { @@ -192,7 +196,8 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele clientId, clientSecret, isActive: true, - orgSlug: currentOrg.slug + orgSlug: currentOrg.slug, + jwtSignatureAlgorithm }); } else { await updateMutateAsync({ @@ -207,7 +212,8 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele clientId, clientSecret, isActive: true, - orgSlug: currentOrg.slug + orgSlug: currentOrg.slug, + jwtSignatureAlgorithm }); } @@ -362,6 +368,29 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele /> )} + ( + + + + )} + /> { } }; + const handleEnableBypassOrgAuthToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + if (!subscription?.samlSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + bypassOrgAuthEnabled: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, + type: "success" + }); + } catch (err) { + console.error(err); + } + }; + return ( <> {/*
@@ -72,7 +97,9 @@ export const OrgGeneralAuthSection = () => {
*/}
-

Enforce SAML SSO

+
+ Enforce SAML SSO +
{(isAllowed) => ( {

- Enforce members to authenticate via SAML to access this organization + Enforce users to authenticate via SAML to access this organization

+ {currentOrg?.authEnforced && ( +
+
+
+ Enable Admin SSO Bypass + + + When this is enabled, we strongly recommend enforcing MFA at the organization + level. + +

+ In case of a lockout, admins can use the admin login portal at{" "} + + {window.location.origin}/login/admin + +

+
+ } + > + + +
+ + {(isAllowed) => ( + handleEnableBypassOrgAuthToggle(value)} + isDisabled={!isAllowed} + /> + )} + +
+

+ + Allow organization admins to bypass SAML enforcement when SSO is unavailable, + misconfigured, or inaccessible. + +

+
+ )} handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx index bec65bd8f..f9d7b939c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -82,6 +82,28 @@ export const OrgOIDCSection = (): JSX.Element => { } }; + const handleEnableBypassOrgAuthToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await updateOrg({ + orgId: currentOrg?.id, + bypassOrgAuthEnabled: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, + type: "success" + }); + } catch (err) { + console.error(err); + } + }; + const handleOIDCGroupManagement = async (value: boolean) => { try { if (!currentOrg?.id) return; @@ -158,7 +180,9 @@ export const OrgOIDCSection = (): JSX.Element => { )}
-

Enforce OIDC SSO

+
+ Enforce OIDC SSO +
{(isAllowed) => ( {

- Enforce members to authenticate via OIDC to access this organization + Enforce users to authenticate via OIDC to access this organization.

+ {currentOrg?.authEnforced && ( +
+
+
+ Enable Admin SSO Bypass + + + When this is enabled, we strongly recommend enforcing MFA at the organization + level. + +

+ In case of a lockout, admins can use the admin login portal at{" "} + + {window.location.origin}/login/admin + +

+
+ } + > + + +
+ + {(isAllowed) => ( + handleEnableBypassOrgAuthToggle(value)} + isDisabled={!isAllowed} + /> + )} + +
+

+ + Allow organization admins to bypass OIDC enforcement when SSO is unavailable, + misconfigured, or inaccessible. + +

+
+ )}
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/SSOModal.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/SSOModal.tsx index 9bb793c80..32fa681e2 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/SSOModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/SSOModal.tsx @@ -20,6 +20,8 @@ import { useToggle } from "@app/hooks"; import { useCreateSSOConfig, useGetSSOConfig, useUpdateSSOConfig } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { SSOModalHeader } from "./SSOModalHeader"; + enum AuthProvider { OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", @@ -30,12 +32,32 @@ enum AuthProvider { } const ssoAuthProviders = [ - { label: "Okta SAML", value: AuthProvider.OKTA_SAML }, - { label: "Azure / Entra SAML", value: AuthProvider.AZURE_SAML }, - { label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML }, - { label: "Keycloak SAML", value: AuthProvider.KEYCLOAK_SAML }, - { label: "Google SAML", value: AuthProvider.GOOGLE_SAML }, - { label: "Auth0 SAML", value: AuthProvider.AUTH0_SAML } + { label: "Okta SAML", value: AuthProvider.OKTA_SAML, image: "Okta.png", docsUrl: "okta" }, + { + label: "Azure / Entra SAML", + value: AuthProvider.AZURE_SAML, + image: "Microsoft Azure.png", + docsUrl: "azure" + }, + { + label: "JumpCloud SAML", + value: AuthProvider.JUMPCLOUD_SAML, + image: "JumpCloud.png", + docsUrl: "jumpcloud" + }, + { + label: "Keycloak SAML", + value: AuthProvider.KEYCLOAK_SAML, + image: "Keycloak.png", + docsUrl: "keycloak-saml" + }, + { + label: "Google SAML", + value: AuthProvider.GOOGLE_SAML, + image: "Google.png", + docsUrl: "google-saml" + }, + { label: "Auth0 SAML", value: AuthProvider.AUTH0_SAML, image: "Auth0.png", docsUrl: "auth0-saml" } ]; const schema = z @@ -231,6 +253,10 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelet }} > + provider.value === authProvider)!} + isConnected={Boolean(data)} + />
{ + return ( +
+ {`${providerDetails.label} +
+
+ {providerDetails.label} + +
+ + Docs + +
+
+
+

+ {isConnected + ? `${providerDetails.label} Connection` + : `Connect to ${providerDetails.label}`} +

+
+
+ ); +}; 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 700bc5ce3..03371c8ce 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 @@ -11,6 +11,7 @@ import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; +import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -40,6 +41,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Vercel: return ; + case SecretSync.Windmill: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/WindmillSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/WindmillSyncDestinationCol.tsx new file mode 100644 index 000000000..6da83ec6b --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/WindmillSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TWindmillSync } from "@app/hooks/api/secretSyncs/types/windmill-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TWindmillSync; +}; + +export const WindmillSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 13e921e4b..7ef5644ed 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -90,6 +90,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.appName || destinationConfig.app; secondaryText = destinationConfig.env; break; + case SecretSync.Windmill: + primaryText = destinationConfig.workspace; + secondaryText = destinationConfig.path; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index d2ef25975..a3c948b94 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -9,18 +9,19 @@ import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; -import { AwsParameterStoreSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection"; -import { AwsSecretsManagerSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection"; -import { DatabricksSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection"; -import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection"; +import { AwsParameterStoreSyncDestinationSection } from "./AwsParameterStoreSyncDestinationSection"; +import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSyncDestinationSection"; import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; +import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; +import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; +import { WindmillSyncDestinationSection } from "./WindmillSyncDestinationSection"; type Props = { secretSync: TSecretSync; @@ -69,6 +70,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Vercel: DestinationComponents = ; break; + case SecretSync.Windmill: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/WindmillSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/WindmillSyncDestinationSection.tsx new file mode 100644 index 000000000..65149b90e --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/WindmillSyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TWindmillSync } from "@app/hooks/api/secretSyncs/types/windmill-sync"; + +type Props = { + secretSync: TWindmillSync; +}; + +export const WindmillSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { path, workspace } + } = secretSync; + + return ( + <> + {workspace} + {path} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index e37294ed8..4fe701c8a 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -51,6 +51,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.TerraformCloud: case SecretSync.Camunda: case SecretSync.Vercel: + case SecretSync.Windmill: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/DeleteProjectProtection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/DeleteProjectProtection.tsx new file mode 100644 index 000000000..f8ef239d9 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/DeleteProjectProtection.tsx @@ -0,0 +1,57 @@ +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Checkbox } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useToggleDeleteProjectProtection } from "@app/hooks/api/workspace/queries"; + +export const DeleteProjectProtection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync } = useToggleDeleteProjectProtection(); + + const handleToggleDeleteProjectProtection = async (state: boolean) => { + try { + if (!currentWorkspace?.id) return; + + await mutateAsync({ + workspaceID: currentWorkspace.id, + state + }); + + const text = `Successfully ${state ? "enabled" : "disabled"} delete protection`; + createNotification({ + text, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update delete protection", + type: "error" + }); + } + }; + + return ( +
+

Delete Protection

+ + {(isAllowed) => ( +
+ { + handleToggleDeleteProjectProtection(state as boolean); + }} + > + Protects the project from being deleted accidentally. While this option is enabled, + you can't delete the project. + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/index.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/index.tsx new file mode 100644 index 000000000..38761cf0a --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/index.tsx @@ -0,0 +1 @@ +export { DeleteProjectProtection } from "./DeleteProjectProtection"; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx index 042adff1f..2f11523c4 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -144,7 +144,7 @@ export const DeleteProjectSection = () => { {(isAllowed) => (
); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7cd0b5ea9..e3ab6df7d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as authSignUpSsoPageRouteImport } from './pages/auth/SignUpSsoPag import { Route as authLoginSsoPageRouteImport } from './pages/auth/LoginSsoPage/route' import { Route as authSelectOrgPageRouteImport } from './pages/auth/SelectOrgPage/route' import { Route as authLoginLdapPageRouteImport } from './pages/auth/LoginLdapPage/route' +import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route' import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route' import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route' import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route' @@ -393,6 +394,12 @@ const authLoginLdapPageRouteRoute = authLoginLdapPageRouteImport.update({ getParentRoute: () => RestrictLoginSignupLoginRoute, } as any) +const authAdminLoginPageRouteRoute = authAdminLoginPageRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => RestrictLoginSignupLoginRoute, +} as any) + const adminSignUpPageRouteRoute = adminSignUpPageRouteImport.update({ id: '/admin/signup', path: '/admin/signup', @@ -1775,6 +1782,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof adminSignUpPageRouteImport parentRoute: typeof middlewaresRestrictLoginSignupImport } + '/_restrict-login-signup/login/admin': { + id: '/_restrict-login-signup/login/admin' + path: '/admin' + fullPath: '/login/admin' + preLoaderRoute: typeof authAdminLoginPageRouteImport + parentRoute: typeof RestrictLoginSignupLoginImport + } '/_restrict-login-signup/login/ldap': { id: '/_restrict-login-signup/login/ldap' path: '/ldap' @@ -3655,6 +3669,7 @@ const middlewaresAuthenticateRouteWithChildren = interface RestrictLoginSignupLoginRouteChildren { authLoginPageRouteRoute: typeof authLoginPageRouteRoute + authAdminLoginPageRouteRoute: typeof authAdminLoginPageRouteRoute authLoginLdapPageRouteRoute: typeof authLoginLdapPageRouteRoute authSelectOrgPageRouteRoute: typeof authSelectOrgPageRouteRoute authLoginSsoPageRouteRoute: typeof authLoginSsoPageRouteRoute @@ -3665,6 +3680,7 @@ interface RestrictLoginSignupLoginRouteChildren { const RestrictLoginSignupLoginRouteChildren: RestrictLoginSignupLoginRouteChildren = { authLoginPageRouteRoute: authLoginPageRouteRoute, + authAdminLoginPageRouteRoute: authAdminLoginPageRouteRoute, authLoginLdapPageRouteRoute: authLoginLdapPageRouteRoute, authSelectOrgPageRouteRoute: authSelectOrgPageRouteRoute, authLoginSsoPageRouteRoute: authLoginSsoPageRouteRoute, @@ -3739,6 +3755,7 @@ export interface FileRoutesByFullPath { '/signup/': typeof authSignUpPageRouteRoute '/organization/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute + '/login/admin': typeof authAdminLoginPageRouteRoute '/login/ldap': typeof authLoginLdapPageRouteRoute '/login/select-organization': typeof authSelectOrgPageRouteRoute '/login/sso': typeof authLoginSsoPageRouteRoute @@ -3918,6 +3935,7 @@ export interface FileRoutesByTo { '/signup': typeof authSignUpPageRouteRoute '/organization/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute + '/login/admin': typeof authAdminLoginPageRouteRoute '/login/ldap': typeof authLoginLdapPageRouteRoute '/login/select-organization': typeof authSelectOrgPageRouteRoute '/login/sso': typeof authLoginSsoPageRouteRoute @@ -4096,6 +4114,7 @@ export interface FileRoutesById { '/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute '/_authenticate/organization/none': typeof organizationNoOrgPageRouteRoute '/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute + '/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute '/_restrict-login-signup/login/ldap': typeof authLoginLdapPageRouteRoute '/_restrict-login-signup/login/select-organization': typeof authSelectOrgPageRouteRoute '/_restrict-login-signup/login/sso': typeof authLoginSsoPageRouteRoute @@ -4286,6 +4305,7 @@ export interface FileRouteTypes { | '/signup/' | '/organization/none' | '/admin/signup' + | '/login/admin' | '/login/ldap' | '/login/select-organization' | '/login/sso' @@ -4464,6 +4484,7 @@ export interface FileRouteTypes { | '/signup' | '/organization/none' | '/admin/signup' + | '/login/admin' | '/login/ldap' | '/login/select-organization' | '/login/sso' @@ -4640,6 +4661,7 @@ export interface FileRouteTypes { | '/_restrict-login-signup/signup/' | '/_authenticate/organization/none' | '/_restrict-login-signup/admin/signup' + | '/_restrict-login-signup/login/admin' | '/_restrict-login-signup/login/ldap' | '/_restrict-login-signup/login/select-organization' | '/_restrict-login-signup/login/sso' @@ -4928,6 +4950,7 @@ export const routeTree = rootRoute "parent": "/_restrict-login-signup", "children": [ "/_restrict-login-signup/login/", + "/_restrict-login-signup/login/admin", "/_restrict-login-signup/login/ldap", "/_restrict-login-signup/login/select-organization", "/_restrict-login-signup/login/sso", @@ -4959,6 +4982,10 @@ export const routeTree = rootRoute "filePath": "admin/SignUpPage/route.tsx", "parent": "/_restrict-login-signup" }, + "/_restrict-login-signup/login/admin": { + "filePath": "auth/AdminLoginPage/route.tsx", + "parent": "/_restrict-login-signup/login" + }, "/_restrict-login-signup/login/ldap": { "filePath": "auth/LoginLdapPage/route.tsx", "parent": "/_restrict-login-signup/login" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 47f2d6ab1..d6793ea1a 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -328,6 +328,7 @@ export const routes = rootRoute("root.tsx", [ route("/admin/signup", "admin/SignUpPage/route.tsx"), route("/login", [ index("auth/LoginPage/route.tsx"), + route("/admin", "auth/AdminLoginPage/route.tsx"), route("/select-organization", "auth/SelectOrgPage/route.tsx"), route("/sso", "auth/LoginSsoPage/route.tsx"), route("/ldap", "auth/LoginLdapPage/route.tsx"),