diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index b0914d5c4..d90f28184 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -154,7 +154,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv requestId: z.string().trim() }), body: z.object({ - status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]), + bypassReason: z.string().min(10).max(1000).optional() }), response: { 200: z.object({ @@ -170,7 +171,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, requestId: req.params.requestId, - status: req.body.status + status: req.body.status, + bypassReason: req.body.bypassReason }); return { review }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 2b2758b2e..017356a5d 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -6,6 +6,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { EnforcementLevel } from "@app/lib/types"; import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -22,6 +23,7 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; @@ -323,26 +325,22 @@ export const accessApprovalRequestServiceFactory = ({ status, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + bypassReason }: TReviewAccessRequestDTO) => { const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); if (!accessApprovalRequest) { throw new NotFoundError({ message: `Secret approval request with ID '${requestId}' not found` }); } - const { policy } = accessApprovalRequest; + const { policy, environment } = accessApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this access request has been deleted." }); } - if (!policy.allowedSelfApprovals && actorId === accessApprovalRequest.requestedByUserId) { - throw new BadRequestError({ - message: "Failed to review access approval request. Users are not authorized to review their own request." - }); - } - const { membership, hasRole } = await permissionService.getProjectPermission({ + const { membership, hasRole, permission } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -355,6 +353,20 @@ export const accessApprovalRequestServiceFactory = ({ throw new ForbiddenRequestError({ message: "You are not a member of this project" }); } + const isSelfApproval = actorId === accessApprovalRequest.requestedByUserId; + const isSoftEnforcement = policy.enforcementLevel === EnforcementLevel.Soft; + const canBypassApproval = permission.can( + ProjectPermissionApprovalActions.AllowAccessBypass, + ProjectPermissionSub.SecretApproval + ); + const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypassApproval); + + if (!policy.allowedSelfApprovals && isSelfApproval && cannotBypassUnderSoftEnforcement) { + throw new BadRequestError({ + message: "Failed to review access approval request. Users are not authorized to review their own request." + }); + } + if ( !hasRole(ProjectMembershipRole.Admin) && accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user @@ -363,21 +375,49 @@ export const accessApprovalRequestServiceFactory = ({ throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" }); } + const project = await projectDAL.findById(accessApprovalRequest.projectId); + if (!project) { + throw new NotFoundError({ message: "The project associated with this access request was not found." }); + } + const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); } const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { - const review = await accessApprovalRequestReviewerDAL.findOne( + const isBreakGlassApprovalAttempt = + policy.enforcementLevel === EnforcementLevel.Soft && + actorId === accessApprovalRequest.requestedByUserId && + status === ApprovalStatus.APPROVED; + + let reviewForThisActorProcessing: { + id: string; + requestId: string; + reviewerUserId: string; + status: string; + createdAt: Date; + updatedAt: Date; + }; + + const existingReviewByActorInTx = await accessApprovalRequestReviewerDAL.findOne( { requestId: accessApprovalRequest.id, reviewerUserId: actorId }, tx ); - if (!review) { - const newReview = await accessApprovalRequestReviewerDAL.create( + + // Check if review exists for actor + if (existingReviewByActorInTx) { + // Check if breakglass re-approval + if (isBreakGlassApprovalAttempt && existingReviewByActorInTx.status === ApprovalStatus.APPROVED) { + reviewForThisActorProcessing = existingReviewByActorInTx; + } else { + throw new BadRequestError({ message: "You have already reviewed this request" }); + } + } else { + reviewForThisActorProcessing = await accessApprovalRequestReviewerDAL.create( { status, requestId: accessApprovalRequest.id, @@ -385,19 +425,26 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); + } - const allReviews = [...existingReviews, newReview]; + const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId); + const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing]; - const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals; - // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved. - if (approvedReviews.length === policy.approvals) { + if ( + reviewForThisActorProcessing.status === ApprovalStatus.APPROVED && + (meetsStandardApprovalThreshold || isBreakGlassApprovalAttempt) + ) { + const currentRequestState = await accessApprovalRequestDAL.findById(accessApprovalRequest.id, tx); + let privilegeIdToSet = currentRequestState?.privilegeId || null; + + if (!privilegeIdToSet) { if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { throw new BadRequestError({ message: "Temporary range is required for temporary access" }); } - let privilegeId: string | null = null; - if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { // Permanent access const privilege = await additionalPrivilegeDAL.create( @@ -409,7 +456,7 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } else { // Temporary access const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); @@ -421,23 +468,57 @@ export const accessApprovalRequestServiceFactory = ({ projectId: accessApprovalRequest.projectId, slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions), - isTemporary: true, + isTemporary: true, // Explicitly set to true for the privilege temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, temporaryRange: accessApprovalRequest.temporaryRange!, temporaryAccessStartTime: startTime, - temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + temporaryAccessEndTime: new Date(startTime.getTime() + relativeTempAllocatedTimeInMs) }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } - - await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx); + await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId: privilegeIdToSet }, tx); } - - return newReview; } - throw new BadRequestError({ message: "You have already reviewed this request" }); + + // Send notification if this was a breakglass approval + if (isBreakGlassApprovalAttempt) { + const cfg = getConfig(); + const actingUser = await userDAL.findById(actorId, tx); + + if (actingUser) { + const policyApproverUserIds = policy.approvers + .map((ap) => ap.userId) + .filter((id): id is string => typeof id === "string"); + + if (policyApproverUserIds.length > 0) { + const approverUsersForEmail = await userDAL.find({ $in: { id: policyApproverUserIds } }, { tx }); + const recipientEmails = approverUsersForEmail + .map((appUser) => appUser.email) + .filter((email): email is string => !!email); + + if (recipientEmails.length > 0) { + await smtpService.sendMail({ + recipients: recipientEmails, + subjectLine: "Infisical Secret Access Policy Bypassed", + substitutions: { + projectName: project.name, + requesterFullName: `${actingUser.firstName} ${actingUser.lastName}`, + requesterEmail: actingUser.email, + bypassReason: bypassReason || "No reason provided", + secretPath: policy.secretPath || "/", + environment, + approvalUrl: `${cfg.SITE_URL}/secret-manager/${project.id}/approval`, + requestType: "access" + }, + template: SmtpTemplates.AccessSecretRequestBypassed + }); + } + } + } + } + return reviewForThisActorProcessing; }); return reviewStatus; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 51a5e0ca2..162f8b3c6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -17,6 +17,8 @@ export type TGetAccessRequestCountDTO = { export type TReviewAccessRequestDTO = { requestId: string; status: ApprovalStatus; + envName?: string; + bypassReason?: string; } & Omit; export type TCreateAccessApprovalRequestDTO = { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e9a2ada8b..ceeebbf7a 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -192,6 +192,11 @@ export const licenseServiceFactory = ({ const workspacesUsed = await projectDAL.countOfOrgProjects(orgId); currentPlan.workspacesUsed = workspacesUsed; + const membersUsed = await licenseDAL.countOfOrgMembers(orgId); + currentPlan.membersUsed = membersUsed; + const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); + currentPlan.identitiesUsed = identityUsed; + await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), LICENSE_SERVER_CLOUD_PLAN_TTL, diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 9461227da..2a422d55f 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -61,7 +61,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionApprovalActions.Edit, ProjectPermissionApprovalActions.Create, ProjectPermissionApprovalActions.Delete, - ProjectPermissionApprovalActions.AllowChangeBypass + ProjectPermissionApprovalActions.AllowChangeBypass, + ProjectPermissionApprovalActions.AllowAccessBypass ], ProjectPermissionSub.SecretApproval ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 27cd33855..5d06e085d 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -39,7 +39,8 @@ export enum ProjectPermissionApprovalActions { Create = "create", Edit = "edit", Delete = "delete", - AllowChangeBypass = "allow-change-bypass" + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" } export enum ProjectPermissionCmekActions { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1a30923d5..42207e621 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2084,6 +2084,10 @@ export const AppConnections = { region: "The region identifier in Oracle Cloud Infrastructure where the vault is located.", fingerprint: "The fingerprint of the public key uploaded to the user's API keys.", privateKey: "The private key content in PEM format used to sign API requests." + }, + ONEPASS: { + instanceUrl: "The URL of the 1Password Connect Server instance to authenticate with.", + apiToken: "The API token used to access the 1Password Connect Server." } } }; @@ -2237,6 +2241,9 @@ export const SecretSyncs = { compartmentOcid: "The OCID (Oracle Cloud Identifier) of the compartment where the vault is located.", vaultOcid: "The OCID (Oracle Cloud Identifier) of the vault to sync secrets to.", keyOcid: "The OCID (Oracle Cloud Identifier) of the encryption key to use when creating secrets in the vault." + }, + ONEPASS: { + vaultId: "The ID of the 1Password vault to sync secrets to." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/1password-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/1password-connection-router.ts new file mode 100644 index 000000000..1100776d3 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/1password-connection-router.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { + CreateOnePassConnectionSchema, + SanitizedOnePassConnectionSchema, + UpdateOnePassConnectionSchema +} from "@app/services/app-connection/1password"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerOnePassConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.OnePass, + server, + sanitizedResponseSchema: SanitizedOnePassConnectionSchema, + createSchema: CreateOnePassConnectionSchema, + updateSchema: UpdateOnePassConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/vaults`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + type: z.string(), + items: z.number(), + + attributeVersion: z.number(), + contentVersion: z.number(), + + // Corresponds to ISO8601 date string + createdAt: z.string(), + updatedAt: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const vaults = await server.services.appConnection.onepass.listVaults(connectionId, req.permission); + return vaults; + } + }); +}; 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 33a7ad11c..4f9c96fec 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 @@ -5,6 +5,10 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { + OnePassConnectionListItemSchema, + SanitizedOnePassConnectionSchema +} from "@app/services/app-connection/1password"; import { Auth0ConnectionListItemSchema, SanitizedAuth0ConnectionSchema } from "@app/services/app-connection/auth0"; import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; import { @@ -80,7 +84,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options, - ...SanitizedOCIConnectionSchema.options + ...SanitizedOCIConnectionSchema.options, + ...SanitizedOnePassConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -103,7 +108,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ WindmillConnectionListItemSchema, LdapConnectionListItemSchema, TeamCityConnectionListItemSchema, - OCIConnectionListItemSchema + OCIConnectionListItemSchema, + OnePassConnectionListItemSchema ]); 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 0ca357f8f..a71742e6f 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,6 +1,7 @@ import { registerOCIConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oci-connection-router"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { registerOnePassConnectionRouter } from "./1password-connection-router"; import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; @@ -44,5 +45,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.OnePass, + server, + responseSchema: OnePassSyncSchema, + createSchema: CreateOnePassSyncSchema, + updateSchema: UpdateOnePassSyncSchema + }); 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 c22a40432..fbc636ffc 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -1,6 +1,7 @@ +import { registerOCIVaultSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/oci-vault-sync-router"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; -import { registerOCIVaultSyncRouter } from "../../../../ee/routes/v1/secret-sync-routers/oci-vault-sync-router"; +import { registerOnePassSyncRouter } from "./1password-sync-router"; import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router"; import { registerAwsSecretsManagerSyncRouter } from "./aws-secrets-manager-sync-router"; import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configuration-sync-router"; @@ -33,5 +34,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/1password/1password-connection-enums.ts b/backend/src/services/app-connection/1password/1password-connection-enums.ts new file mode 100644 index 000000000..85b28ee5a --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-enums.ts @@ -0,0 +1,3 @@ +export enum OnePassConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/1password/1password-connection-fns.ts b/backend/src/services/app-connection/1password/1password-connection-fns.ts new file mode 100644 index 000000000..d8a18576f --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-fns.ts @@ -0,0 +1,66 @@ +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 { OnePassConnectionMethod } from "./1password-connection-enums"; +import { TOnePassConnection, TOnePassConnectionConfig, TOnePassVault } from "./1password-connection-types"; + +export const getOnePassInstanceUrl = async (config: TOnePassConnectionConfig) => { + const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getOnePassConnectionListItem = () => { + return { + name: "1Password" as const, + app: AppConnection.OnePass as const, + methods: Object.values(OnePassConnectionMethod) as [OnePassConnectionMethod.ApiToken] + }; +}; + +export const validateOnePassConnectionCredentials = async (config: TOnePassConnectionConfig) => { + const instanceUrl = await getOnePassInstanceUrl(config); + + const { apiToken } = config.credentials; + + try { + await request.get(`${instanceUrl}/v1/vaults`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + } 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 listOnePassVaults = async (appConnection: TOnePassConnection) => { + const instanceUrl = await getOnePassInstanceUrl(appConnection); + const { apiToken } = appConnection.credentials; + + const resp = await request.get(`${instanceUrl}/v1/vaults`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + return resp.data; +}; diff --git a/backend/src/services/app-connection/1password/1password-connection-schemas.ts b/backend/src/services/app-connection/1password/1password-connection-schemas.ts new file mode 100644 index 000000000..da63dc32a --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-schemas.ts @@ -0,0 +1,64 @@ +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 { OnePassConnectionMethod } from "./1password-connection-enums"; + +export const OnePassConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.ONEPASS.apiToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Connect Server instance URL") + .min(1, "Instance URL required") + .describe(AppConnections.CREDENTIALS.ONEPASS.instanceUrl) +}); + +const BaseOnePassConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.OnePass) }); + +export const OnePassConnectionSchema = BaseOnePassConnectionSchema.extend({ + method: z.literal(OnePassConnectionMethod.ApiToken), + credentials: OnePassConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedOnePassConnectionSchema = z.discriminatedUnion("method", [ + BaseOnePassConnectionSchema.extend({ + method: z.literal(OnePassConnectionMethod.ApiToken), + credentials: OnePassConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateOnePassConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(OnePassConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.OnePass).method), + credentials: OnePassConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.OnePass).credentials + ) + }) +]); + +export const CreateOnePassConnectionSchema = ValidateOnePassConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.OnePass) +); + +export const UpdateOnePassConnectionSchema = z + .object({ + credentials: OnePassConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.OnePass).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OnePass)); + +export const OnePassConnectionListItemSchema = z.object({ + name: z.literal("1Password"), + app: z.literal(AppConnection.OnePass), + methods: z.nativeEnum(OnePassConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/1password/1password-connection-service.ts b/backend/src/services/app-connection/1password/1password-connection-service.ts new file mode 100644 index 000000000..8e1df9536 --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listOnePassVaults } from "./1password-connection-fns"; +import { TOnePassConnection } from "./1password-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const onePassConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listVaults = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.OnePass, connectionId, actor); + + try { + const vaults = await listOnePassVaults(appConnection); + return vaults; + } catch (error) { + logger.error(error, "Failed to establish connection with 1Password"); + return []; + } + }; + + return { + listVaults + }; +}; diff --git a/backend/src/services/app-connection/1password/1password-connection-types.ts b/backend/src/services/app-connection/1password/1password-connection-types.ts new file mode 100644 index 000000000..99d6bf94a --- /dev/null +++ b/backend/src/services/app-connection/1password/1password-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateOnePassConnectionSchema, + OnePassConnectionSchema, + ValidateOnePassConnectionCredentialsSchema +} from "./1password-connection-schemas"; + +export type TOnePassConnection = z.infer; + +export type TOnePassConnectionInput = z.infer & { + app: AppConnection.OnePass; +}; + +export type TValidateOnePassConnectionCredentialsSchema = typeof ValidateOnePassConnectionCredentialsSchema; + +export type TOnePassConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TOnePassVault = { + id: string; + name: string; + type: string; + items: number; + + attributeVersion: number; + contentVersion: number; + + createdAt: string; + updatedAt: string; +}; diff --git a/backend/src/services/app-connection/1password/index.ts b/backend/src/services/app-connection/1password/index.ts new file mode 100644 index 000000000..333cc347e --- /dev/null +++ b/backend/src/services/app-connection/1password/index.ts @@ -0,0 +1,4 @@ +export * from "./1password-connection-enums"; +export * from "./1password-connection-fns"; +export * from "./1password-connection-schemas"; +export * from "./1password-connection-types"; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 6f2501636..8d6b0630d 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -18,7 +18,8 @@ export enum AppConnection { HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity", - OCI = "oci" + OCI = "oci", + OnePass = "1password" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 72e6db6cf..fe6661e59 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -14,6 +14,11 @@ import { } from "@app/services/app-connection/shared/sql"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { + getOnePassConnectionListItem, + OnePassConnectionMethod, + validateOnePassConnectionCredentials +} from "./1password"; import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; import { TAppConnectionServiceFactoryDep } from "./app-connection-service"; import { @@ -101,7 +106,8 @@ export const listAppConnectionOptions = () => { getHCVaultConnectionListItem(), getLdapConnectionListItem(), getTeamCityConnectionListItem(), - getOCIConnectionListItem() + getOCIConnectionListItem(), + getOnePassConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -172,7 +178,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.HCVault]: validateHCVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -201,6 +208,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case HumanitecConnectionMethod.ApiToken: case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: + case OnePassConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -266,7 +274,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.HCVault]: platformManagedCredentialsNotSupported, [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future [AppConnection.TeamCity]: platformManagedCredentialsNotSupported, - [AppConnection.OCI]: platformManagedCredentialsNotSupported + [AppConnection.OCI]: platformManagedCredentialsNotSupported, + [AppConnection.OnePass]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index bd88434b6..be1a49d13 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -20,7 +20,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.HCVault]: "Hashicorp Vault", [AppConnection.LDAP]: "LDAP", [AppConnection.TeamCity]: "TeamCity", - [AppConnection.OCI]: "OCI" + [AppConnection.OCI]: "OCI", + [AppConnection.OnePass]: "1Password" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -43,5 +44,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -161,6 +168,7 @@ export type TAppConnectionInput = { id: string } & ( | TLdapConnectionInput | TTeamCityConnectionInput | TOCIConnectionInput + | TOnePassConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput | TMySqlConnectionInput; @@ -192,7 +200,8 @@ export type TAppConnectionConfig = | THCVaultConnectionConfig | TLdapConnectionConfig | TTeamCityConnectionConfig - | TOCIConnectionConfig; + | TOCIConnectionConfig + | TOnePassConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -214,7 +223,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateHCVaultConnectionCredentialsSchema | TValidateLdapConnectionCredentialsSchema | TValidateTeamCityConnectionCredentialsSchema - | TValidateOCIConnectionCredentialsSchema; + | TValidateOCIConnectionCredentialsSchema + | TValidateOnePassConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/secret-sync/1password/1password-sync-constants.ts b/backend/src/services/secret-sync/1password/1password-sync-constants.ts new file mode 100644 index 000000000..01226a026 --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-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 ONEPASS_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "1Password", + destination: SecretSync.OnePass, + connection: AppConnection.OnePass, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/1password/1password-sync-fns.ts b/backend/src/services/secret-sync/1password/1password-sync-fns.ts new file mode 100644 index 000000000..c832fbbdb --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-fns.ts @@ -0,0 +1,226 @@ +import { request } from "@app/lib/config/request"; +import { getOnePassInstanceUrl } from "@app/services/app-connection/1password"; +import { + TDeleteOnePassVariable, + TOnePassListVariables, + TOnePassListVariablesResponse, + TOnePassSyncWithCredentials, + TOnePassVariable, + TOnePassVariableDetails, + TPostOnePassVariable, + TPutOnePassVariable +} from "@app/services/secret-sync/1password/1password-sync-types"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassListVariables) => { + const { data } = await request.get(`${instanceUrl}/v1/vaults/${vaultId}/items`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + const result: Record = {}; + + for await (const s of data) { + const { data: secret } = await request.get( + `${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + + const value = secret.fields.find((f) => f.label === "value")?.value; + const fieldId = secret.fields.find((f) => f.label === "value")?.id; + + // eslint-disable-next-line no-continue + if (!value || !fieldId) continue; + + result[s.title] = { + ...secret, + value, + fieldId + }; + } + + return result; +}; + +const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, itemValue }: TPostOnePassVariable) => { + return request.post( + `${instanceUrl}/v1/vaults/${vaultId}/items`, + { + title: itemTitle, + category: "API_CREDENTIAL", + vault: { + id: vaultId + }, + tags: ["synced-from-infisical"], + fields: [ + { + label: "value", + value: itemValue, + type: "CONCEALED" + } + ] + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const updateOnePassItem = async ({ + instanceUrl, + apiToken, + vaultId, + itemId, + fieldId, + itemTitle, + itemValue +}: TPutOnePassVariable) => { + return request.put( + `${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`, + { + id: itemId, + title: itemTitle, + category: "API_CREDENTIAL", + vault: { + id: vaultId + }, + tags: ["synced-from-infisical"], + fields: [ + { + id: fieldId, + label: "value", + value: itemValue, + type: "CONCEALED" + } + ] + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const deleteOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemId }: TDeleteOnePassVariable) => { + return request.delete(`${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`, { + headers: { + Authorization: `Bearer ${apiToken}` + } + }); +}; + +export const OnePassSyncFns = { + syncSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { vaultId } + } = secretSync; + + const instanceUrl = await getOnePassInstanceUrl(connection); + const { apiToken } = connection.credentials; + + const items = await listOnePassItems({ instanceUrl, apiToken, vaultId }); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + try { + if (key in items) { + await updateOnePassItem({ + instanceUrl, + apiToken, + vaultId, + itemTitle: key, + itemValue: value, + itemId: items[key].id, + fieldId: items[key].fieldId + }); + } else { + await createOnePassItem({ instanceUrl, apiToken, vaultId, itemTitle: key, itemValue: value }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const [key, variable] of Object.entries(items)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + + if (!(key in secretMap)) { + try { + await deleteOnePassItem({ + instanceUrl, + apiToken, + vaultId, + itemId: variable.id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + removeSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { vaultId } + } = secretSync; + + const instanceUrl = await getOnePassInstanceUrl(connection); + const { apiToken } = connection.credentials; + + const items = await listOnePassItems({ instanceUrl, apiToken, vaultId }); + + for await (const [key, item] of Object.entries(items)) { + if (key in secretMap) { + try { + await deleteOnePassItem({ + apiToken, + vaultId, + instanceUrl, + itemId: item.id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TOnePassSyncWithCredentials) => { + const { + connection, + destinationConfig: { vaultId } + } = secretSync; + + const instanceUrl = await getOnePassInstanceUrl(connection); + const { apiToken } = connection.credentials; + + return listOnePassItems({ instanceUrl, apiToken, vaultId }); + } +}; diff --git a/backend/src/services/secret-sync/1password/1password-sync-schemas.ts b/backend/src/services/secret-sync/1password/1password-sync-schemas.ts new file mode 100644 index 000000000..2f77a1dad --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const OnePassSyncDestinationConfigSchema = z.object({ + vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId) +}); + +const OnePassSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const OnePassSyncSchema = BaseSecretSyncSchema(SecretSync.OnePass, OnePassSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.OnePass), + destinationConfig: OnePassSyncDestinationConfigSchema +}); + +export const CreateOnePassSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.OnePass, + OnePassSyncOptionsConfig +).extend({ + destinationConfig: OnePassSyncDestinationConfigSchema +}); + +export const UpdateOnePassSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.OnePass, + OnePassSyncOptionsConfig +).extend({ + destinationConfig: OnePassSyncDestinationConfigSchema.optional() +}); + +export const OnePassSyncListItemSchema = z.object({ + name: z.literal("1Password"), + connection: z.literal(AppConnection.OnePass), + destination: z.literal(SecretSync.OnePass), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/1password/1password-sync-types.ts b/backend/src/services/secret-sync/1password/1password-sync-types.ts new file mode 100644 index 000000000..af4db7369 --- /dev/null +++ b/backend/src/services/secret-sync/1password/1password-sync-types.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { TOnePassConnection } from "@app/services/app-connection/1password"; + +import { CreateOnePassSyncSchema, OnePassSyncListItemSchema, OnePassSyncSchema } from "./1password-sync-schemas"; + +export type TOnePassSync = z.infer; + +export type TOnePassSyncInput = z.infer; + +export type TOnePassSyncListItem = z.infer; + +export type TOnePassSyncWithCredentials = TOnePassSync & { + connection: TOnePassConnection; +}; + +export type TOnePassVariable = { + id: string; + title: string; + category: string; // API_CREDENTIAL, SECURE_NOTE, LOGIN, etc +}; + +export type TOnePassVariableDetails = TOnePassVariable & { + fields: { + id: string; + type: string; // CONCEALED, STRING + label: string; + value: string; + }[]; +}; + +export type TOnePassListVariablesResponse = TOnePassVariable[]; + +export type TOnePassListVariables = { + apiToken: string; + instanceUrl: string; + vaultId: string; +}; + +export type TPostOnePassVariable = TOnePassListVariables & { + itemTitle: string; + itemValue: string; +}; + +export type TPutOnePassVariable = TOnePassListVariables & { + itemId: string; + fieldId: string; + itemTitle: string; + itemValue: string; +}; + +export type TDeleteOnePassVariable = TOnePassListVariables & { + itemId: string; +}; diff --git a/backend/src/services/secret-sync/1password/index.ts b/backend/src/services/secret-sync/1password/index.ts new file mode 100644 index 000000000..db098b299 --- /dev/null +++ b/backend/src/services/secret-sync/1password/index.ts @@ -0,0 +1,4 @@ +export * from "./1password-sync-constants"; +export * from "./1password-sync-fns"; +export * from "./1password-sync-schemas"; +export * from "./1password-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index e829e2131..24f7d05f8 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -13,7 +13,8 @@ export enum SecretSync { Windmill = "windmill", HCVault = "hashicorp-vault", TeamCity = "teamcity", - OCIVault = "oci-vault" + OCIVault = "oci-vault", + OnePass = "1password" } 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 8f58b6fcc..dbf3a3699 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -24,6 +24,7 @@ import { import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { ONEPASS_SYNC_LIST_OPTION, OnePassSyncFns } from "./1password"; import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFactory } from "./azure-app-configuration"; import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; @@ -53,7 +54,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION, [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION, - [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION + [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION, + [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -174,6 +176,8 @@ export const SecretSyncFns = { return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: return OCIVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.OnePass: + return OnePassSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -242,6 +246,9 @@ export const SecretSyncFns = { case SecretSync.OCIVault: secretMap = await OCIVaultSyncFns.getSecrets(secretSync); break; + case SecretSync.OnePass: + secretMap = await OnePassSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -300,6 +307,8 @@ export const SecretSyncFns = { return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: return OCIVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.OnePass: + return OnePassSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 5b2906f1c..832c15bf8 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -16,7 +16,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Windmill]: "Windmill", [SecretSync.HCVault]: "Hashicorp Vault", [SecretSync.TeamCity]: "TeamCity", - [SecretSync.OCIVault]: "OCI Vault" + [SecretSync.OCIVault]: "OCI Vault", + [SecretSync.OnePass]: "1Password" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -34,7 +35,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Windmill]: AppConnection.Windmill, [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity, - [SecretSync.OCIVault]: AppConnection.OCI + [SecretSync.OCIVault]: AppConnection.OCI, + [SecretSync.OnePass]: AppConnection.OnePass }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -52,5 +54,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Windmill]: SecretSyncPlanType.Regular, [SecretSync.HCVault]: SecretSyncPlanType.Regular, [SecretSync.TeamCity]: SecretSyncPlanType.Regular, - [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise + [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, + [SecretSync.OnePass]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index adc4888ab..22f7848ad 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -42,6 +42,12 @@ import { TWindmillSyncWithCredentials } from "@app/services/secret-sync/windmill"; +import { + TOnePassSync, + TOnePassSyncInput, + TOnePassSyncListItem, + TOnePassSyncWithCredentials +} from "./1password/1password-sync-types"; import { TAwsParameterStoreSync, TAwsParameterStoreSyncInput, @@ -102,7 +108,8 @@ export type TSecretSync = | TWindmillSync | THCVaultSync | TTeamCitySync - | TOCIVaultSync; + | TOCIVaultSync + | TOnePassSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -119,7 +126,8 @@ export type TSecretSyncWithCredentials = | TWindmillSyncWithCredentials | THCVaultSyncWithCredentials | TTeamCitySyncWithCredentials - | TOCIVaultSyncWithCredentials; + | TOCIVaultSyncWithCredentials + | TOnePassSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -136,7 +144,8 @@ export type TSecretSyncInput = | TWindmillSyncInput | THCVaultSyncInput | TTeamCitySyncInput - | TOCIVaultSyncInput; + | TOCIVaultSyncInput + | TOnePassSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -153,7 +162,8 @@ export type TSecretSyncListItem = | TWindmillSyncListItem | THCVaultSyncListItem | TTeamCitySyncListItem - | TOCIVaultSyncListItem; + | TOCIVaultSyncListItem + | TOnePassSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx index bad823bd3..a07110aa3 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx @@ -12,6 +12,7 @@ interface SecretApprovalRequestBypassedTemplateProps environment: string; bypassReason: string; approvalUrl: string; + requestType: "change" | "access"; } export const SecretApprovalRequestBypassedTemplate = ({ @@ -22,7 +23,8 @@ export const SecretApprovalRequestBypassedTemplate = ({ secretPath, environment, bypassReason, - approvalUrl + approvalUrl, + requestType = "change" }: SecretApprovalRequestBypassedTemplateProps) => { return ( {requesterEmail} - ) has merged a secret to {secretPath} in the {environment} environment - without obtaining the required approval. + ) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} + {secretPath} in the {environment} environment without obtaining the required + approval. The following reason was provided for bypassing the policy: " diff --git a/docs/api-reference/endpoints/app-connections/1password/available.mdx b/docs/api-reference/endpoints/app-connections/1password/available.mdx new file mode 100644 index 000000000..3797a7556 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/1password/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/create.mdx b/docs/api-reference/endpoints/app-connections/1password/create.mdx new file mode 100644 index 000000000..03562b50f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/1password" +--- + + + Check out the configuration docs for [1Password Connections](/integrations/app-connections/1password) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/1password/delete.mdx b/docs/api-reference/endpoints/app-connections/1password/delete.mdx new file mode 100644 index 000000000..24e7a2b16 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/1password/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/1password/get-by-id.mdx new file mode 100644 index 000000000..bcab50f12 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/1password/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/1password/get-by-name.mdx new file mode 100644 index 000000000..8cb10c351 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/1password/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/list.mdx b/docs/api-reference/endpoints/app-connections/1password/list.mdx new file mode 100644 index 000000000..4fa88de81 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/1password" +--- diff --git a/docs/api-reference/endpoints/app-connections/1password/update.mdx b/docs/api-reference/endpoints/app-connections/1password/update.mdx new file mode 100644 index 000000000..cbd52a6c6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/1password/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/1password/{connectionId}" +--- + + + Check out the configuration docs for [1Password Connections](/integrations/app-connections/1password) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/1password/create.mdx b/docs/api-reference/endpoints/secret-syncs/1password/create.mdx new file mode 100644 index 000000000..b8c8a0d9d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/1password" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/delete.mdx b/docs/api-reference/endpoints/secret-syncs/1password/delete.mdx new file mode 100644 index 000000000..4949636bd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/1password/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/1password/get-by-id.mdx new file mode 100644 index 000000000..522b94499 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/1password/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/1password/get-by-name.mdx new file mode 100644 index 000000000..9a904a6cf --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/1password/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/1password/import-secrets.mdx new file mode 100644 index 000000000..75553aedd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/1password/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/list.mdx b/docs/api-reference/endpoints/secret-syncs/1password/list.mdx new file mode 100644 index 000000000..b7c7ad00d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/1password" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/1password/remove-secrets.mdx new file mode 100644 index 000000000..03ce4de83 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/1password/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/1password/sync-secrets.mdx new file mode 100644 index 000000000..183cd0722 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/1password/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/1password/update.mdx b/docs/api-reference/endpoints/secret-syncs/1password/update.mdx new file mode 100644 index 000000000..c7dcf5f1c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/1password/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/1password/{syncId}" +--- diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index 76cc4b74e..58b21d4eb 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -3,10 +3,10 @@ title: "Access Requests" description: "Learn how to request access to sensitive resources in Infisical." --- -In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. +In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. -This functionality works in the following way: -1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. +This functionality works in the following way: +1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. ![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) ![Access Request Policies](/images/platform/access-controls/access-request-policies.png) @@ -19,9 +19,8 @@ This functionality works in the following way: ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) - If the access request matches with a policy that has a **Soft** enforcement level, the requester may bypass the policy and get access to the resource without full approval. + If the access request matches with a policy that allows break-glass approval bypasses, the requester may bypass the policy and get access to the resource without full approval. -5. As soon as the request is approved, developer is able to access the sought resources. +5. As soon as the request is approved, developer is able to access the sought resources. ![Access Request Dashboard](/images/platform/access-controls/access-requests-completed.png) - diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index ffa85f6c5..610d1fd47 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -33,6 +33,10 @@ First, you would need to create a set of policies for a certain environment. In The enforcement level determines how strict the policy is. A **Hard** enforcement level means that any change that matches the policy will need full approval prior merging. A **Soft** enforcement level allows for break glass functionality on the request. If a change request is bypassed, the approvers will be notified via email. + + Enabling the "Bypass Approvals" toggle during policy creation will create a **Soft** enforcement level. Disabling the toggle makes the enforcement level **Hard**. + + ### Self approvals If the **Self Approvals** option is enabled, users who are designated as approvers on the policy can approve requests that they themselves have submitted. diff --git a/docs/images/app-connections/1password/app-connection-created.png b/docs/images/app-connections/1password/app-connection-created.png new file mode 100644 index 000000000..adfd1b260 Binary files /dev/null and b/docs/images/app-connections/1password/app-connection-created.png differ diff --git a/docs/images/app-connections/1password/app-connection-modal.png b/docs/images/app-connections/1password/app-connection-modal.png new file mode 100644 index 000000000..cf828de3c Binary files /dev/null and b/docs/images/app-connections/1password/app-connection-modal.png differ diff --git a/docs/images/app-connections/1password/app-connection-option.png b/docs/images/app-connections/1password/app-connection-option.png new file mode 100644 index 000000000..bd07c0a80 Binary files /dev/null and b/docs/images/app-connections/1password/app-connection-option.png differ diff --git a/docs/images/app-connections/1password/click-connect-server.png b/docs/images/app-connections/1password/click-connect-server.png new file mode 100644 index 000000000..f3720c2d4 Binary files /dev/null and b/docs/images/app-connections/1password/click-connect-server.png differ diff --git a/docs/images/app-connections/1password/configure-connect-server.png b/docs/images/app-connections/1password/configure-connect-server.png new file mode 100644 index 000000000..89015d499 Binary files /dev/null and b/docs/images/app-connections/1password/configure-connect-server.png differ diff --git a/docs/images/app-connections/1password/deploy-server.png b/docs/images/app-connections/1password/deploy-server.png new file mode 100644 index 000000000..cf29ea2e4 Binary files /dev/null and b/docs/images/app-connections/1password/deploy-server.png differ diff --git a/docs/images/app-connections/1password/developer-page.png b/docs/images/app-connections/1password/developer-page.png new file mode 100644 index 000000000..7df91dfcf Binary files /dev/null and b/docs/images/app-connections/1password/developer-page.png differ diff --git a/docs/images/app-connections/1password/set-up-access-token.png b/docs/images/app-connections/1password/set-up-access-token.png new file mode 100644 index 000000000..c0730d5c3 Binary files /dev/null and b/docs/images/app-connections/1password/set-up-access-token.png differ diff --git a/docs/images/platform/pr-workflows/create-change-policy.png b/docs/images/platform/pr-workflows/create-change-policy.png index afe945b0a..fabcb6716 100644 Binary files a/docs/images/platform/pr-workflows/create-change-policy.png and b/docs/images/platform/pr-workflows/create-change-policy.png differ diff --git a/docs/images/secret-syncs/1password/configure-destination.png b/docs/images/secret-syncs/1password/configure-destination.png new file mode 100644 index 000000000..af5191486 Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-destination.png differ diff --git a/docs/images/secret-syncs/1password/configure-details.png b/docs/images/secret-syncs/1password/configure-details.png new file mode 100644 index 000000000..69ce333e3 Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-details.png differ diff --git a/docs/images/secret-syncs/1password/configure-source.png b/docs/images/secret-syncs/1password/configure-source.png new file mode 100644 index 000000000..ee08db72b Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-source.png differ diff --git a/docs/images/secret-syncs/1password/configure-sync-options.png b/docs/images/secret-syncs/1password/configure-sync-options.png new file mode 100644 index 000000000..f0b3488e2 Binary files /dev/null and b/docs/images/secret-syncs/1password/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/1password/review-configuration.png b/docs/images/secret-syncs/1password/review-configuration.png new file mode 100644 index 000000000..5663e7da2 Binary files /dev/null and b/docs/images/secret-syncs/1password/review-configuration.png differ diff --git a/docs/images/secret-syncs/1password/select-option.png b/docs/images/secret-syncs/1password/select-option.png new file mode 100644 index 000000000..a19b8189d Binary files /dev/null and b/docs/images/secret-syncs/1password/select-option.png differ diff --git a/docs/images/secret-syncs/1password/sync-created.png b/docs/images/secret-syncs/1password/sync-created.png new file mode 100644 index 000000000..fbe8c90d6 Binary files /dev/null and b/docs/images/secret-syncs/1password/sync-created.png differ diff --git a/docs/integrations/app-connections/1password.mdx b/docs/integrations/app-connections/1password.mdx new file mode 100644 index 000000000..0c3926a1b --- /dev/null +++ b/docs/integrations/app-connections/1password.mdx @@ -0,0 +1,123 @@ +--- +title: "1Password Connection" +description: "Learn how to configure a 1Password Connection for Infisical." +--- + +Infisical supports the use of [Service Accounts](https://developer.1password.com/docs/service-accounts) to connect with 1Password. + +## Setup 1Password Connect Server + + + If you already have a Connect Server for your vault you may skip this step. + + + + + ![Developer Page](/images/app-connections/1password/developer-page.png) + + + ![Click Connect Server](/images/app-connections/1password/click-connect-server.png) + + + 1. Input a name for your Connect Server + 2. Click "Choose Vaults" and select the vaults you want to connect + 3. For each selected vault, click **Edit Access** and **Enable All** + 4. Click "Add Environment" + + ![Configure Connect Server](/images/app-connections/1password/configure-connect-server.png) + + + 1. Input a name and expiration for the token + 2. Click "Choose Vaults" and select the vaults you want to connect + 3. For each selected vault, click **Edit Access** and **Enable All** + 4. Click "Issue Token" + + ![Set Up Access Token](/images/app-connections/1password/set-up-access-token.png) + + + Download the Credentials File and set up your Connect Server. + + + Follow [this guide](https://developer.1password.com/docs/connect/get-started#step-2-deploy-1password-connect-server) to deploy a Connect Server. + + + Make sure to save the **Access Token** for later use. + + ![Deploy Server](/images/app-connections/1password/deploy-server.png) + + + +## Create 1Password Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **1Password Connection** option from the available integrations. + + ![Select 1Password Connection](/images/app-connections/1password/app-connection-option.png) + + + Complete the 1Password Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The URL at which your 1Password Connect Server instance is hosted + - The Access Token from earlier steps + + ![1Password Connection Modal](/images/app-connections/1password/app-connection-modal.png) + + + After clicking Create, your **1Password Connection** is established and ready to use with your Infisical projects. + + ![1Password Connection Created](/images/app-connections/1password/app-connection-created.png) + + + + + To create an 1Password Connection, make an API request to the [Create 1Password Connection](/api-reference/endpoints/app-connections/1password/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/1password \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-1password-connection", + "method": "api-token", + "credentials": { + "instanceUrl": "https://1pass.example.com", + "apiToken": "[PRIVATE TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-1password-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "1password", + "method": "api-token", + "credentials": { + "instanceUrl": "https://1pass.example.com" + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/1password.mdx b/docs/integrations/secret-syncs/1password.mdx new file mode 100644 index 000000000..a33f54c8d --- /dev/null +++ b/docs/integrations/secret-syncs/1password.mdx @@ -0,0 +1,163 @@ +--- +title: "1Password Sync" +description: "Learn how to configure a 1Password Sync for Infisical." +--- + +**Prerequisites:** +- Create an [1Password Connection](/integrations/app-connections/1password) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select 1Password](/images/secret-syncs/1password/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/1password/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/1password/configure-destination.png) + + - **1Password Connection**: The 1Password Connection to authenticate with. + - **Vault**: The 1Password vault to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/1password/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over 1Password when keys conflict. + - **Import Secrets (Prioritize 1Password)**: Imports secrets from the destination endpoint before syncing, prioritizing values from 1Password over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your 1Password Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/1password/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your 1Password Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/1password/review-configuration.png) + + + If enabled, your 1Password Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/1password/sync-created.png) + + + + + To create an **1Password Sync**, make an API request to the [Create 1Password Sync](/api-reference/endpoints/secret-syncs/1password/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/1password \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-1password-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": { + "vaultId": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-1password-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": "1password", + "name": "my-1password-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": "1password", + "destinationConfig": { + "vaultId": "..." + } + } + } + ``` + + + +## FAQ + + + + Infisical can only perform CRUD operations on the following item types: + - API Credentials + + diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index dabe0e786..8a12532a4 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -178,13 +178,14 @@ Supports conditions and permission inversion #### Subject: `secret-approval` -| Action | Description | -| --------------------- | ---------------------------------------------------------------------------- | -| `read` | View approval policies and requests | -| `create` | Create new approval policies | -| `edit` | Modify approval policies | -| `delete` | Remove approval policies | -| `allow-change-bypass` | Allow request creators to bypass policy in break-glass situations | +| Action | Description | +| --------------------- | ----------------------------------------------------------------------------------- | +| `read` | View approval policies and requests | +| `create` | Create new approval policies | +| `edit` | Modify approval policies | +| `delete` | Remove approval policies | +| `allow-change-bypass` | Allow request creators to merge changes without approval in break-glass situations | +| `allow-access-bypass` | Allow request creators to access secrets without approval in break-glass situations | #### Subject: `secret-rotation` diff --git a/docs/mint.json b/docs/mint.json index a81c9db67..bf7e8533a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -470,6 +470,7 @@ { "group": "Connections", "pages": [ + "integrations/app-connections/1password", "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", @@ -500,6 +501,7 @@ { "group": "Syncs", "pages": [ + "integrations/secret-syncs/1password", "integrations/secret-syncs/aws-parameter-store", "integrations/secret-syncs/aws-secrets-manager", "integrations/secret-syncs/azure-app-configuration", @@ -1041,6 +1043,18 @@ "pages": [ "api-reference/endpoints/app-connections/list", "api-reference/endpoints/app-connections/options", + { + "group": "1Password", + "pages": [ + "api-reference/endpoints/app-connections/1password/list", + "api-reference/endpoints/app-connections/1password/available", + "api-reference/endpoints/app-connections/1password/get-by-id", + "api-reference/endpoints/app-connections/1password/get-by-name", + "api-reference/endpoints/app-connections/1password/create", + "api-reference/endpoints/app-connections/1password/update", + "api-reference/endpoints/app-connections/1password/delete" + ] + }, { "group": "Auth0", "pages": [ @@ -1276,6 +1290,20 @@ "pages": [ "api-reference/endpoints/secret-syncs/list", "api-reference/endpoints/secret-syncs/options", + { + "group": "1Password", + "pages": [ + "api-reference/endpoints/secret-syncs/1password/list", + "api-reference/endpoints/secret-syncs/1password/get-by-id", + "api-reference/endpoints/secret-syncs/1password/get-by-name", + "api-reference/endpoints/secret-syncs/1password/create", + "api-reference/endpoints/secret-syncs/1password/update", + "api-reference/endpoints/secret-syncs/1password/delete", + "api-reference/endpoints/secret-syncs/1password/sync-secrets", + "api-reference/endpoints/secret-syncs/1password/import-secrets", + "api-reference/endpoints/secret-syncs/1password/remove-secrets" + ] + }, { "group": "AWS Parameter Store", "pages": [ diff --git a/frontend/public/images/integrations/1Password.png b/frontend/public/images/integrations/1Password.png new file mode 100644 index 000000000..8518b41e6 Binary files /dev/null and b/frontend/public/images/integrations/1Password.png differ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx new file mode 100644 index 000000000..1202ec875 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx @@ -0,0 +1,74 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TOnePassVault, + useOnePassConnectionListVaults +} from "@app/hooks/api/appConnections/1password"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const OnePassSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.OnePass } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: vaults, isLoading: isVaultsLoading } = useOnePassConnectionListVaults( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.vaultId", ""); + }} + /> + + ( + +
+ Don't see the vault you're looking for?{" "} + +
+ + } + > + v.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue)?.id ?? null)} + options={vaults} + placeholder="Select a vault..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 2cac1ae20..48541b272 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -3,6 +3,7 @@ import { useFormContext } from "react-hook-form"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; +import { OnePassSyncFields } from "./1PasswordSyncFields"; import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields"; import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields"; @@ -55,6 +56,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.OCIVault: return ; + case SecretSync.OnePass: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 7c2b13936..00d86e700 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -49,6 +49,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Windmill: case SecretSync.HCVault: case SecretSync.TeamCity: + case SecretSync.OnePass: case SecretSync.OCIVault: AdditionalSyncOptionsFieldsComponent = null; break; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/OnePassSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/OnePassSyncReviewFields.tsx new file mode 100644 index 000000000..1c31fb6c3 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/OnePassSyncReviewFields.tsx @@ -0,0 +1,12 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const OnePassSyncReviewFields = () => { + const { watch } = useFormContext(); + const vaultId = watch("destinationConfig.vaultId"); + + return {vaultId}; +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 144ccb2a8..a53eec5e7 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 { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; +import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; @@ -96,6 +97,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.OCIVault: DestinationFieldsComponent = ; break; + case SecretSync.OnePass: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/1password-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/1password-sync-destination-schema.ts new file mode 100644 index 000000000..36b144776 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/1password-sync-destination-schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const OnePassSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.OnePass), + destinationConfig: z.object({ + vaultId: z.string().trim().min(1, "Vault ID required") + }) + }) +); 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 232b8cedf..792226dae 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,5 +1,6 @@ import { z } from "zod"; +import { OnePassSyncDestinationSchema } from "./1password-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"; @@ -31,7 +32,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ WindmillSyncDestinationSchema, HCVaultSyncDestinationSchema, TeamCitySyncDestinationSchema, - OCIVaultSyncDestinationSchema + OCIVaultSyncDestinationSchema, + OnePassSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 96ad12ac3..d49b8bc8e 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -29,7 +29,8 @@ export enum ProjectPermissionApprovalActions { Create = "create", Edit = "edit", Delete = "delete", - AllowChangeBypass = "allow-change-bypass" + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" } export enum ProjectPermissionDynamicSecretActions { diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 379c14585..202a21407 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -24,6 +24,7 @@ import { LdapConnectionMethod, MsSqlConnectionMethod, MySqlConnectionMethod, + OnePassConnectionMethod, PostgresConnectionMethod, TAppConnection, TeamCityConnectionMethod, @@ -65,7 +66,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.HCVault]: { name: "Hashicorp Vault", image: "Vault.png", size: 65 }, [AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 }, [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }, - [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true } + [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, + [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -91,6 +93,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case HumanitecConnectionMethod.ApiToken: case TerraformCloudConnectionMethod.ApiToken: case VercelConnectionMethod.ApiToken: + case OnePassConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 80df92ac3..88a0f7517 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -51,6 +51,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Windmill]: AppConnection.Windmill, [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity, - [SecretSync.OCIVault]: AppConnection.OCI + [SecretSync.OCIVault]: AppConnection.OCI, + [SecretSync.OnePass]: AppConnection.OnePass }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 9fda2377a..c0da7af23 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -131,20 +131,27 @@ export const useReviewAccessRequest = () => { projectSlug: string; envSlug?: string; requestedBy?: string; + bypassReason?: string; } >({ - mutationFn: async ({ requestId, status }) => { + mutationFn: async ({ requestId, status, bypassReason }) => { const { data } = await apiRequest.post( `/api/v1/access-approvals/requests/${requestId}/review`, { - status + status, + bypassReason } ); return data; }, - onSuccess: (_, { projectSlug, envSlug, requestedBy }) => { + onSuccess: (_, { projectSlug, envSlug, requestedBy, bypassReason }) => { queryClient.invalidateQueries({ - queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy) + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envSlug, + requestedBy, + bypassReason + ) }); queryClient.invalidateQueries({ queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug) diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index 1aa40c588..6370f4a59 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -19,8 +19,12 @@ export const accessApprovalKeys = { getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => [{ workspaceId, environment }, "access-approval-policy"] as const, - getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) => - [{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const, + getAccessApprovalRequests: ( + projectSlug: string, + envSlug?: string, + requestedBy?: string, + bypassReason?: string + ) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const, getAccessApprovalRequestCount: (projectSlug: string) => [{ projectSlug }, "access-approval-request-count"] as const }; diff --git a/frontend/src/hooks/api/appConnections/1password/index.ts b/frontend/src/hooks/api/appConnections/1password/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/1password/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/1password/queries.tsx b/frontend/src/hooks/api/appConnections/1password/queries.tsx new file mode 100644 index 000000000..f73562652 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/1password/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TOnePassVault } from "./types"; + +const onePassConnectionKeys = { + all: [...appConnectionKeys.all, "1password"] as const, + listVaults: (connectionId: string) => + [...onePassConnectionKeys.all, "vaults", connectionId] as const +}; + +export const useOnePassConnectionListVaults = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TOnePassVault[], + unknown, + TOnePassVault[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: onePassConnectionKeys.listVaults(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/1password/${connectionId}/vaults` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/1password/types.ts b/frontend/src/hooks/api/appConnections/1password/types.ts new file mode 100644 index 000000000..9386e9ddd --- /dev/null +++ b/frontend/src/hooks/api/appConnections/1password/types.ts @@ -0,0 +1,12 @@ +export type TOnePassVault = { + id: string; + name: string; + type: string; + items: number; + + attributeVersion: number; + contentVersion: number; + + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 880997dd9..84c3ff857 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -18,5 +18,6 @@ export enum AppConnection { HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity", - OCI = "oci" + OCI = "oci", + OnePass = "1password" } diff --git a/frontend/src/hooks/api/appConnections/types/1password-connection.ts b/frontend/src/hooks/api/appConnections/types/1password-connection.ts new file mode 100644 index 000000000..fcf307a15 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/1password-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 OnePassConnectionMethod { + ApiToken = "api-token" +} + +export type TOnePassConnection = TRootAppConnection & { app: AppConnection.OnePass } & { + method: OnePassConnectionMethod.ApiToken; + credentials: { + apiToken: string; + instanceUrl: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 3aa42e348..880737edd 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -92,6 +92,10 @@ export type TOCIConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OCI; }; +export type TOnePassConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.OnePass; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -111,7 +115,8 @@ export type TAppConnectionOption = | TAuth0ConnectionOption | THCVaultConnectionOption | TTeamCityConnectionOption - | TOCIConnectionOption; + | TOCIConnectionOption + | TOnePassConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -134,4 +139,5 @@ export type TAppConnectionOptionMap = { [AppConnection.LDAP]: TLdapConnectionOption; [AppConnection.TeamCity]: TTeamCityConnectionOption; [AppConnection.OCI]: TOCIConnectionOption; + [AppConnection.OnePass]: TOnePassConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index aa69f0cc1..85b8e4423 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -1,4 +1,5 @@ import { AppConnection } from "../enums"; +import { TOnePassConnection } from "./1password-connection"; import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; @@ -21,6 +22,7 @@ import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; +export * from "./1password-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -62,7 +64,8 @@ export type TAppConnection = | THCVaultConnection | TLdapConnection | TTeamCityConnection - | TOCIConnection; + | TOCIConnection + | TOnePassConnection; export type TAvailableAppConnection = Pick; @@ -110,4 +113,5 @@ export type TAppConnectionMap = { [AppConnection.LDAP]: TLdapConnection; [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; + [AppConnection.OnePass]: TOnePassConnection; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 65a31e427..7185563d5 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -13,7 +13,8 @@ export enum SecretSync { Windmill = "windmill", HCVault = "hashicorp-vault", TeamCity = "teamcity", - OCIVault = "oci-vault" + OCIVault = "oci-vault", + OnePass = "1password" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/1password-sync.ts b/frontend/src/hooks/api/secretSyncs/types/1password-sync.ts new file mode 100644 index 000000000..98556d2af --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/1password-sync.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TOnePassSync = TRootSecretSync & { + destination: SecretSync.OnePass; + destinationConfig: { + vaultId: string; + }; + connection: { + app: AppConnection.OnePass; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 85daa07fd..f28a0820b 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -1,6 +1,7 @@ import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; import { DiscriminativePick } from "@app/types"; +import { TOnePassSync } from "./1password-sync"; import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; @@ -39,7 +40,8 @@ export type TSecretSync = | TWindmillSync | THCVaultSync | TTeamCitySync - | TOCIVaultSync; + | TOCIVaultSync + | TOnePassSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/1PasswordConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/1PasswordConnectionForm.tsx new file mode 100644 index 000000000..1c266aeee --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/1PasswordConnectionForm.tsx @@ -0,0 +1,150 @@ +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 { OnePassConnectionMethod, TOnePassConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TOnePassConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.OnePass) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(OnePassConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required"), + instanceUrl: z.string().trim().url("Invalid Connect Server instance URL") + }) + }) +]); + +type FormData = z.infer; + +export const OnePassConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.OnePass, + method: OnePassConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; 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 c493421ce..ab2305b98 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -9,6 +9,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { DiscriminativePick } from "@app/types"; import { AppConnectionHeader } from "../AppConnectionHeader"; +import { OnePassConnectionForm } from "./1PasswordConnectionForm"; import { Auth0ConnectionForm } from "./Auth0ConnectionForm"; import { AwsConnectionForm } from "./AwsConnectionForm"; import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm"; @@ -107,6 +108,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.OCI: return ; + case AppConnection.OnePass: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -183,6 +186,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.OCI: return ; + case AppConnection.OnePass: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index cb3cd015d..b2429fdfc 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -58,7 +58,8 @@ const ApprovalPolicyActionSchema = z.object({ [ProjectPermissionApprovalActions.Edit]: z.boolean().optional(), [ProjectPermissionApprovalActions.Delete]: z.boolean().optional(), [ProjectPermissionApprovalActions.Create]: z.boolean().optional(), - [ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional() + [ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional(), + [ProjectPermissionApprovalActions.AllowAccessBypass]: z.boolean().optional() }); const CmekPolicyActionSchema = z.object({ @@ -578,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canEdit = action.includes(ProjectPermissionApprovalActions.Edit); const canRead = action.includes(ProjectPermissionApprovalActions.Read); const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass); + const canAccessBypass = action.includes(ProjectPermissionApprovalActions.AllowAccessBypass); if (!formVal[subject]) formVal[subject] = [{}]; @@ -588,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true; if (canChangeBypass) formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true; + if (canAccessBypass) + formVal[subject]![0][ProjectPermissionApprovalActions.AllowAccessBypass] = true; return; } @@ -1212,7 +1216,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Create", value: ProjectPermissionApprovalActions.Create }, { label: "Modify", value: ProjectPermissionApprovalActions.Edit }, { label: "Remove", value: ProjectPermissionApprovalActions.Delete }, - { label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass } + { label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass }, + { label: "Allow Access Bypass", value: ProjectPermissionApprovalActions.AllowAccessBypass } ] }, [ProjectPermissionSub.SecretRotation]: { diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/1PasswordSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/1PasswordSyncDestinationCol.tsx new file mode 100644 index 000000000..ad90908c1 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/1PasswordSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TOnePassSync } from "@app/hooks/api/secretSyncs/types/1password-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TOnePassSync; +}; + +export const OnePassSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 898980881..cd5ff2cf7 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 @@ -1,5 +1,6 @@ import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; +import { OnePassSyncDestinationCol } from "./1PasswordSyncDestinationCol"; import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDestinationCol"; import { AwsSecretsManagerSyncDestinationCol } from "./AwsSecretsManagerSyncDestinationCol"; import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfigurationDestinationSyncCol"; @@ -52,6 +53,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.OCIVault: return ; + case SecretSync.OnePass: + 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/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 4bf0eeed2..4943df471 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 @@ -106,6 +106,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.compartmentOcid; secondaryText = destinationConfig.vaultOcid; break; + case SecretSync.OnePass: + primaryText = destinationConfig.vaultId; + secondaryText = "Vault ID"; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx index 5483dc597..a70a6a901 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx @@ -44,8 +44,7 @@ export const SecretApprovalsPage = () => {
{ const [selectedRequest, setSelectedRequest] = useState< | (TAccessApprovalRequest & { - user: TWorkspaceUser["user"] | null; + user: { firstName?: string; lastName?: string; email?: string } | null; isRequestedByCurrentUser: boolean; + isSelfApproveAllowed: boolean; isApprover: boolean; }) | null @@ -100,6 +102,11 @@ export const AccessApprovalRequest = ({ const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); + const canBypassApprovalPermission = permission.can( + ProjectPermissionApprovalActions.AllowAccessBypass, + ProjectPermissionSub.SecretApproval + ); + const { data: members } = useGetWorkspaceUsers(projectId, true); const membersGroupById = members?.reduce>( (prev, curr) => ({ ...prev, [curr.user.id]: curr }), @@ -118,7 +125,7 @@ export const AccessApprovalRequest = ({ projectSlug }); - const { data: requests } = useGetAccessApprovalRequests({ + const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ projectSlug, authorProjectMembershipId: requestedByFilter, envSlug: envFilter @@ -143,56 +150,105 @@ export const AccessApprovalRequest = ({ return requests; }, [requests, statusFilter, requestedByFilter, envFilter]); - const generateRequestDetails = (request: TAccessApprovalRequest) => { - const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; - const isRejectedByAnyone = request.reviewers.some( - ({ status }) => status === ApprovalStatus.REJECTED - ); - const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; - const isAccepted = request.isApproved; - const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; - const isRequestedByCurrentUser = request.requestedByUserId === user.id; - const isSelfApproveAllowed = request.policy.allowedSelfApprovals; - const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; + const generateRequestDetails = useCallback( + (request: TAccessApprovalRequest) => { + const isReviewedByUser = + request.reviewers.findIndex(({ member }) => member === user.id) !== -1; + const isRejectedByAnyone = request.reviewers.some( + ({ status }) => status === ApprovalStatus.REJECTED + ); + const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; + const isAccepted = request.isApproved; + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; + const isRequestedByCurrentUser = request.requestedByUserId === user.id; + const isSelfApproveAllowed = request.policy.allowedSelfApprovals; + const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; - let displayData: { label: string; type: "primary" | "danger" | "success" } = { - label: "", - type: "primary" - }; - - const isExpired = - request.privilege && - request.isApproved && - new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); - - if (isExpired) displayData = { label: "Access Expired", type: "danger" }; - else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; - else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; - else if (userReviewStatus === ApprovalStatus.APPROVED) { - displayData = { - label: `Pending ${request.policy.approvals - request.reviewers.length} review${ - request.policy.approvals - request.reviewers.length > 1 ? "s" : "" - }`, - type: "primary" - }; - } else if (!isReviewedByUser) - displayData = { - label: "Review Required", + let displayData: { label: string; type: "primary" | "danger" | "success" } = { + label: "", type: "primary" }; - return { - displayData, - isReviewedByUser, - isRejectedByAnyone, - isApprover, - userReviewStatus, - isAccepted, - isSoftEnforcement, - isRequestedByCurrentUser, - isSelfApproveAllowed - }; - }; + const isExpired = + request.privilege && + request.isApproved && + new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); + + if (isExpired) displayData = { label: "Access Expired", type: "danger" }; + else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; + else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; + else if (userReviewStatus === ApprovalStatus.APPROVED) { + displayData = { + label: `Pending ${request.policy.approvals - request.reviewers.length} review${ + request.policy.approvals - request.reviewers.length > 1 ? "s" : "" + }`, + type: "primary" + }; + } else if (!isReviewedByUser) + displayData = { + label: "Review Required", + type: "primary" + }; + + return { + displayData, + isReviewedByUser, + isRejectedByAnyone, + isApprover, + userReviewStatus, + isAccepted, + isSoftEnforcement, + isRequestedByCurrentUser, + isSelfApproveAllowed + }; + }, + [user] + ); + + const handleSelectRequest = useCallback( + (request: TAccessApprovalRequest) => { + const details = generateRequestDetails(request); + + // Whether the request has already been approved / rejected / reviewed + const isInactive = + details.isAccepted || details.isReviewedByUser || details.isRejectedByAnyone; + + // Whether the current user can bypass policy + const canBypass = + details.isSoftEnforcement && + details.isRequestedByCurrentUser && + canBypassApprovalPermission; + + // Whether the current user can approve + const canApprove = + details.isApprover && (!details.isRequestedByCurrentUser || details.isSelfApproveAllowed); + + if (isInactive || (!canApprove && !canBypass)) return; + + if (membersGroupById?.[request.requestedByUserId].user || details.isRequestedByCurrentUser) { + setSelectedRequest({ + ...request, + user: + details.isRequestedByCurrentUser || !membersGroupById?.[request.requestedByUserId].user + ? user + : membersGroupById?.[request.requestedByUserId].user, + isRequestedByCurrentUser: details.isRequestedByCurrentUser, + isSelfApproveAllowed: details.isSelfApproveAllowed, + isApprover: details.isApprover + }); + } + + handlePopUpOpen("reviewRequest"); + }, + [ + generateRequestDetails, + canBypassApprovalPermission, + membersGroupById, + user, + setSelectedRequest, + handlePopUpOpen + ] + ); return (
@@ -344,50 +400,10 @@ export const AccessApprovalRequest = ({ className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80" role="button" tabIndex={0} - onClick={() => { - if ( - ((!details.isApprover || - details.isReviewedByUser || - details.isRejectedByAnyone || - details.isAccepted) && - !( - details.isSoftEnforcement && - details.isRequestedByCurrentUser && - !details.isAccepted - )) || - (request.requestedByUserId === user.id && !details.isSelfApproveAllowed) - ) - return; - if (membersGroupById?.[request.requestedByUserId].user) { - setSelectedRequest({ - ...request, - user: membersGroupById?.[request.requestedByUserId].user, - isRequestedByCurrentUser: details.isRequestedByCurrentUser, - isApprover: details.isApprover - }); - } - - handlePopUpOpen("reviewRequest"); - }} + onClick={() => handleSelectRequest(request)} onKeyDown={(evt) => { - if ( - !details.isApprover || - details.isAccepted || - details.isReviewedByUser || - details.isRejectedByAnyone - ) - return; if (evt.key === "Enter") { - if (membersGroupById?.[request.requestedByUserId].user) { - setSelectedRequest({ - ...request, - user: membersGroupById?.[request.requestedByUserId].user, - isRequestedByCurrentUser: details.isRequestedByCurrentUser, - isApprover: details.isApprover - }); - } - - handlePopUpOpen("reviewRequest"); + handleSelectRequest(request); } }} > @@ -453,7 +469,9 @@ export const AccessApprovalRequest = ({ onOpenChange={() => { handlePopUpClose("reviewRequest"); setSelectedRequest(null); + refetchRequests(); }} + canBypassApprovalPermission={canBypassApprovalPermission} /> )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index 37239d987..0b9f84008 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -1,14 +1,16 @@ import { useCallback, useMemo, useState } from "react"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import ms from "ms"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2"; +import { Button, Checkbox, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions } from "@app/context"; import { useReviewAccessRequest } from "@app/hooks/api"; import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; import { EnforcementLevel } from "@app/hooks/api/policies/enums"; -import { TWorkspaceUser } from "@app/hooks/api/types"; export const ReviewAccessRequestModal = ({ isOpen, @@ -16,21 +18,26 @@ export const ReviewAccessRequestModal = ({ request, projectSlug, selectedRequester, - selectedEnvSlug + selectedEnvSlug, + canBypassApprovalPermission }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; request: TAccessApprovalRequest & { - user: TWorkspaceUser["user"] | null; + user: { firstName?: string; lastName?: string; email?: string } | null; isRequestedByCurrentUser: boolean; + isSelfApproveAllowed: boolean; isApprover: boolean; }; projectSlug: string; selectedRequester: string | undefined; selectedEnvSlug: string | undefined; + canBypassApprovalPermission: boolean; }) => { const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); - const [byPassApproval, setByPassApproval] = useState(false); + const [bypassApproval, setBypassApproval] = useState(false); + const [bypassReason, setBypassReason] = useState(""); + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const accessDetails = { @@ -80,31 +87,52 @@ export const ReviewAccessRequestModal = ({ const reviewAccessRequest = useReviewAccessRequest(); - const handleReview = useCallback(async (status: "approved" | "rejected") => { - setIsLoading(status); - try { - await reviewAccessRequest.mutateAsync({ - requestId: request.id, - status, - projectSlug, - envSlug: selectedEnvSlug, - requestedBy: selectedRequester - }); - } catch (error) { - console.error(error); + const handleReview = useCallback( + async (status: "approved" | "rejected") => { + if (bypassApproval && bypassReason.length < 10) { + createNotification({ + title: "Failed to bypass approval", + text: "Reason must be 10 characters or longer", + type: "error" + }); + return; + } + + setIsLoading(status); + try { + await reviewAccessRequest.mutateAsync({ + requestId: request.id, + status, + projectSlug, + envSlug: selectedEnvSlug, + requestedBy: selectedRequester, + bypassReason: bypassApproval ? bypassReason : undefined + }); + + createNotification({ + title: `Request ${status}`, + text: `The request has been ${status}`, + type: status === "approved" ? "success" : "info" + }); + } catch (error) { + console.error(error); + setIsLoading(null); + return; + } + setIsLoading(null); - return; - } - - createNotification({ - title: `Request ${status}`, - text: `The request has been ${status}`, - type: status === "approved" ? "success" : "info" - }); - - setIsLoading(null); - onOpenChange(false); - }, []); + onOpenChange(false); + }, + [ + bypassApproval, + bypassReason, + reviewAccessRequest, + request, + selectedEnvSlug, + selectedRequester, + onOpenChange + ] + ); return ( @@ -115,12 +143,17 @@ export const ReviewAccessRequestModal = ({ >
- - {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) - {" "} + {request.user && + (request.user.firstName || request.user.lastName) && + request.user.email ? ( + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + + ) : ( + A user + )}{" "} is requesting access to the following resource: -
Requested path: @@ -144,12 +177,16 @@ export const ReviewAccessRequestModal = ({
)}
-
- {isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && ( -
- setByPassApproval(checked === true)} - isChecked={byPassApproval} - id="byPassApproval" - checkIndicatorBg="text-white" - className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""} - > - - Approve without waiting for requirements to be met (bypass policy protection) - - -
- )} + {isSoftEnforcement && + request.isRequestedByCurrentUser && + !(request.isApprover && request.isSelfApproveAllowed) && + canBypassApprovalPermission && ( +
+ setBypassApproval(checked === true)} + isChecked={bypassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + bypassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Approve without waiting for requirements to be met (bypass policy protection) + + + {bypassApproval && ( + + setBypassReason(e.currentTarget.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
+ )}
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index b2a8fa08f..abb38b372 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -4,10 +4,9 @@ import { faClose, faLandMineOn, faLockOpen, - faSquareCheck, - faSquareXmark, faTriangleExclamation, - faUserLock + faUserLock, + faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -112,62 +111,76 @@ export const SecretApprovalRequestAction = ({ if (!hasMerged && status === "open") { return ( -
-
- +
+
+
+ +
- {isMergable ? "Good to merge" : "Review required"} - - At least {approvals} approving review required - {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} - - {isSoftEnforcement && !isMergable && canBypassApprovalPermission && ( -
- setByPassApproval(checked === true)} - isChecked={byPassApproval} - id="byPassApproval" - checkIndicatorBg="text-white" - className={twMerge( - "mr-2", - byPassApproval ? "border-red bg-red hover:bg-red-600" : "" - )} - > - - Merge without waiting for approval (bypass secret change policy) - - - {byPassApproval && ( - - setBypassReason(e.target.value)} - placeholder="Enter reason for bypass (min 10 chars)" - leftIcon={} - /> - - )} -
+

+ {isMergable ? "Good to merge" : "Merging is blocked"} +

+ {!isMergable && ( + + At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by + eligible reviewers. + {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} + )}
-
+
+ {isSoftEnforcement && !isMergable && canBypassApprovalPermission && ( +
+ setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + byPassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Merge without waiting for approval (bypass secret change policy) + + + {byPassApproval && ( + + setBypassReason(e.target.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
+ )} +
+
{canApprove || isSoftEnforcement ? ( - <> +
@@ -186,7 +199,7 @@ export const SecretApprovalRequestAction = ({ > Merge - +
) : (
Only approvers can merge
)} @@ -197,13 +210,13 @@ export const SecretApprovalRequestAction = ({ if (hasMerged && status === "close") return ( -
-
+
+
- Secret approval merged + Change request merged - Merged by {statusChangeByEmail} + Merged by {statusChangeByEmail}.
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx index 212f929f7..b7a6a8149 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -1,18 +1,18 @@ -import { faExclamationTriangle, faInfo, faKey } from "@fortawesome/free-solid-svg-icons"; +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable no-nested-ternary */ +import { useState } from "react"; +import { + faCircleXmark, + faExclamationTriangle, + faEye, + faEyeSlash, + faInfo, + faKey +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - SecretInput, - Table, - TableContainer, - Tag, - TBody, - Td, - Th, - THead, - Tooltip, - Tr -} from "@app/components/v2"; +import { Tag, Tooltip } from "@app/components/v2"; import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types"; export type Props = { @@ -29,19 +29,19 @@ export type Props = { const generateItemTitle = (op: CommitType) => { let text = { label: "", color: "" }; - if (op === CommitType.CREATE) text = { label: "create", color: "#16a34a" }; - else if (op === CommitType.UPDATE) text = { label: "change", color: "#ea580c" }; - else text = { label: "deletion", color: "#b91c1c" }; + if (op === CommitType.CREATE) text = { label: "create", color: "#60DD00" }; + else if (op === CommitType.UPDATE) text = { label: "change", color: "#F8EB30" }; + else text = { label: "deletion", color: "#F83030" }; return ( - +
Request for secret {text.label} - +
); }; const generateConflictText = (op: CommitType) => { - if (op === CommitType.CREATE) return
Secret already exist
; + if (op === CommitType.CREATE) return
Secret already exists
; if (op === CommitType.UPDATE) return
Secret not found
; return null; }; @@ -59,10 +59,12 @@ export const SecretApprovalRequestChangeItem = ({ const itemConflict = hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?.id); const hasConflict = Boolean(itemConflict); + const [isOldSecretValueVisible, setIsOldSecretValueVisible] = useState(false); + const [isNewSecretValueVisible, setIsNewSecretValueVisible] = useState(false); return ( -
-
+
+
{generateItemTitle(op)}
{!hasMerged && isStale && (
@@ -79,48 +81,86 @@ export const SecretApprovalRequestChangeItem = ({
)}
- - - - - {op === CommitType.UPDATE && - - - - - - - {op === CommitType.UPDATE ? ( - - - - - - - - - - - - - - - - - - - ) : ( - - - - - - - - - + + +
+
Metadata
+ {newVersion?.secretMetadata?.length ? ( +
+ {newVersion.secretMetadata?.map((el) => ( +
+ + +
{el.key}
+
+ +
+ {el.value} +
+
+
+ ))} +
+ ) : ( +

-

+ )} +
+ + ) : ( +
+ {" "} + Secret not existent in the new version. +
)} -
} - SecretValueCommentTagsMetadata
OLD{secretVersion?.secretKey} +
+
+ {op === CommitType.UPDATE || op === CommitType.DELETE ? ( +
+
+ Legacy Secret +
+ + Deprecated +
+
+
+
Key
+
{secretVersion?.secretKey}
+
+
+
Value
+
{newVersion?.isRotatedSecret ? ( Rotated Secret value will not be affected ) : ( - - )} -
{secretVersion?.secretComment} - {secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( - setIsOldSecretValueVisible(!isOldSecretValueVisible)} + className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2" >
-
{slug}
- - ))} -
+ className={`flex font-mono ${isOldSecretValueVisible || !secretVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`} + > + {isOldSecretValueVisible + ? secretVersion?.secretValue || "EMPTY" + : secretVersion?.secretValue + ? secretVersion?.secretValue?.split("").map(() => "•") + : "EMPTY"}{" "} + + {secretVersion?.secretValue && ( +
+ +
+ )} + + )} + + +
+
Comment
+
+ {secretVersion?.secretComment || ( + - + )}{" "} +
+
+
+
Tags
+
+ {(secretVersion?.tags?.length ?? 0) ? ( + secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
+
{slug}
+ + )) + ) : ( + - + )} +
+
+
+
Metadata
+
{secretVersion?.secretMetadata?.length ? (
{secretVersion.secretMetadata?.map((el) => ( @@ -146,93 +186,77 @@ export const SecretApprovalRequestChangeItem = ({ ) : (

-

)} -
NEW{newVersion?.secretKey} + + + + ) : ( +
+ {" "} + Secret not existent in the previous version. +
+ )} + {op === CommitType.UPDATE || op === CommitType.CREATE ? ( +
+
+ New Secret +
+ + Current +
+
+
+
Key
+
{newVersion?.secretKey}
+
+
+
Value
+
{newVersion?.isRotatedSecret ? ( Rotated Secret value will not be affected ) : ( - - )} -
{newVersion?.secretComment} - {newVersion?.tags?.map(({ slug, id: tagId, color }) => ( - setIsNewSecretValueVisible(!isNewSecretValueVisible)} + className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2" >
-
{slug}
- - ))} -
- {newVersion?.secretMetadata?.length ? ( -
- {newVersion.secretMetadata?.map((el) => ( -
- - -
{el.key}
-
- -
- {el.value} -
-
+ className={`flex font-mono ${isNewSecretValueVisible || !newVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`} + > + {isNewSecretValueVisible + ? newVersion?.secretValue || "EMPTY" + : newVersion?.secretValue + ? newVersion?.secretValue?.split("").map(() => "•") + : "EMPTY"}{" "} +
+ {newVersion?.secretValue && ( +
+
- ))} + )}
- ) : ( -

-

)} -
- {op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.secretKey} - - - - {op === CommitType.CREATE - ? newVersion?.secretComment - : secretVersion?.secretComment} - - {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( - ({ slug, id: tagId, color }) => ( + + +
+
Comment
+
+ {newVersion?.secretComment || ( + - + )}{" "} +
+
+
+
Tags
+
+ {(newVersion?.tags?.length ?? 0) ? ( + newVersion?.tags?.map(({ slug, id: tagId, color }) => (
{slug}
- ) - )} -
- {newVersion?.secretMetadata?.length ? ( -
- {newVersion.secretMetadata?.map((el) => ( -
- - -
{el.key}
-
- -
- {el.value} -
-
-
- ))} -
+ )) ) : ( -

-

+ - )} -
-
+
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 4eebc65f4..17046450e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -13,6 +13,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from "@radix-ui/react-radio-group"; +import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; import z from "zod"; @@ -51,7 +52,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => { text.push( {score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} - created + created ); if (score[CommitType.UPDATE]) @@ -59,7 +60,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => { {Boolean(text.length) && ","} {score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} - + {" "} updated @@ -70,7 +71,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = []) => { {Boolean(text.length) && "and"} {score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} - deleted + deleted ); @@ -221,29 +222,32 @@ export const SecretApprovalRequestChanges = ({
-
+
{generateCommitText(secretApprovalRequestDetails.commits)} {secretApprovalRequestDetails.isReplicated && ( (replication) )}
-
- {secretApprovalRequestDetails?.committerUser?.firstName || ""} - {secretApprovalRequestDetails?.committerUser?.lastName || ""} ( - {secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "} - {secretApprovalRequestDetails.commits.length} secret values in - +
+

+ {secretApprovalRequestDetails?.committerUser?.firstName || ""} + {secretApprovalRequestDetails?.committerUser?.lastName || ""} ( + {secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "} + {secretApprovalRequestDetails.commits.length} secret values in +

+

{secretApprovalRequestDetails.environment} - -

-
+

+
+

-

- -
- {formatReservedPaths(secretApprovalRequestDetails.secretPath)} -
-
+

+

+ {formatReservedPaths(secretApprovalRequestDetails.secretPath)} +

@@ -255,17 +259,14 @@ export const SecretApprovalRequestChanges = ({ onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)} > -
-
-
Finish your review
+
+
Finish your review
)} @@ -320,7 +321,7 @@ export const SecretApprovalRequestChanges = ({
@@ -376,14 +377,14 @@ export const SecretApprovalRequestChanges = ({ ) )}
-
+
{secretApprovalRequestDetails?.policy?.approvers .filter((requiredApprover) => reviewedUsers?.[requiredApprover.userId]) .map((requiredApprover) => { const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
@@ -396,10 +397,11 @@ export const SecretApprovalRequestChanges = ({ > {reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"} {" "} - the request. + the request on{" "} + {format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}.
{reviewer?.comment && ( - + @@ -409,7 +411,7 @@ export const SecretApprovalRequestChanges = ({ ); })}
-
+
-
+
Reviewers
{secretApprovalRequestDetails?.policy?.approvers @@ -435,10 +437,10 @@ export const SecretApprovalRequestChanges = ({ const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
-
+
)} - + {getReviewedStatusSymbol(reviewer?.status)}
diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/1PasswordSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/1PasswordSyncDestinationSection.tsx new file mode 100644 index 000000000..09d598f31 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/1PasswordSyncDestinationSection.tsx @@ -0,0 +1,14 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TOnePassSync } from "@app/hooks/api/secretSyncs/types/1password-sync"; + +type Props = { + secretSync: TOnePassSync; +}; + +export const OnePassSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { vaultId } + } = secretSync; + + return {vaultId}; +}; 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 b0c989ee2..f443c6106 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissi import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; +import { OnePassSyncDestinationSection } from "./1PasswordSyncDestinationSection"; import { AwsParameterStoreSyncDestinationSection } from "./AwsParameterStoreSyncDestinationSection"; import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSyncDestinationSection"; import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; @@ -85,6 +86,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.OCIVault: DestinationComponents = ; break; + case SecretSync.OnePass: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } 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 a53016d53..fbd66b9f5 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -50,6 +50,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.HCVault: case SecretSync.TeamCity: case SecretSync.OCIVault: + case SecretSync.OnePass: AdditionalSyncOptionsComponent = null; break; default: