diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 84041d172e..8bad830ad6 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -112,6 +112,7 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + orgId?: string; identityAuthInfo?: { identityId: string; oidc?: { diff --git a/backend/src/db/migrations/20250430174352_email-case-change.ts b/backend/src/db/migrations/20250430174352_email-case-change.ts new file mode 100644 index 0000000000..d6b9b39801 --- /dev/null +++ b/backend/src/db/migrations/20250430174352_email-case-change.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEmail = await knex.schema.hasColumn(TableName.Users, "email"); + const hasUsername = await knex.schema.hasColumn(TableName.Users, "username"); + if (hasEmail) { + await knex(TableName.Users) + .where({ isGhost: false }) + .update({ + // @ts-expect-error email assume string this is expected + email: knex.raw("lower(email)") + }); + } + if (hasUsername) { + await knex.schema.raw(` + CREATE INDEX IF NOT EXISTS ${TableName.Users}_lower_username_idx + ON ${TableName.Users} (LOWER(username)) + `); + + const duplicatesSubquery = knex(TableName.Users) + .select(knex.raw("lower(username) as lowercase_username")) + .groupBy("lowercase_username") + .having(knex.raw("count(*)"), ">", 1); + + // Update usernames to lowercase where they won't create duplicates + await knex(TableName.Users) + .where({ isGhost: false }) + .whereRaw("username <> lower(username)") // Only update if not already lowercase + // @ts-expect-error username assume string this is expected + .whereNotIn(knex.raw("lower(username)"), duplicatesSubquery) + .update({ + // @ts-expect-error username assume string this is expected + username: knex.raw("lower(username)") + }); + } +} + +export async function down(knex: Knex): Promise { + const hasUsername = await knex.schema.hasColumn(TableName.Users, "username"); + if (hasUsername) { + await knex.schema.raw(` + DROP INDEX IF EXISTS ${TableName.Users}_lower_username_idx +`); + } +} diff --git a/backend/src/db/migrations/20250521061831_increase-name-sizes.ts b/backend/src/db/migrations/20250521061831_increase-name-sizes.ts new file mode 100644 index 0000000000..b872793394 --- /dev/null +++ b/backend/src/db/migrations/20250521061831_increase-name-sizes.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.SecretSync, (t) => { + t.string("name", 64).notNullable().alter(); + }); + await knex.schema.alterTable(TableName.ProjectTemplates, (t) => { + t.string("name", 64).notNullable().alter(); + }); + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.string("name", 64).notNullable().alter(); + }); + await knex.schema.alterTable(TableName.SecretRotationV2, (t) => { + t.string("name", 64).notNullable().alter(); + }); +} + +export async function down(): Promise { + // No down migration or it will error +} 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 b0914d5c45..d90f281845 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/server/routes/v1/app-connection-routers/oci-connection-router.ts b/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts similarity index 94% rename from backend/src/server/routes/v1/app-connection-routers/oci-connection-router.ts rename to backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts index d78eee3d95..e87e5b69eb 100644 --- a/backend/src/server/routes/v1/app-connection-routers/oci-connection-router.ts +++ b/backend/src/ee/routes/v1/app-connection-routers/oci-connection-router.ts @@ -1,16 +1,16 @@ import z from "zod"; -import { readLimit } from "@app/server/config/rateLimiter"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { CreateOCIConnectionSchema, SanitizedOCIConnectionSchema, UpdateOCIConnectionSchema -} from "@app/services/app-connection/oci"; +} from "@app/ee/services/app-connections/oci"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AuthMode } from "@app/services/auth/auth-type"; -import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; +import { registerAppConnectionEndpoints } from "../../../../server/routes/v1/app-connection-routers/app-connection-endpoints"; export const registerOCIConnectionRouter = async (server: FastifyZodProvider) => { registerAppConnectionEndpoints({ diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 8648ade7c3..c8395d6087 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ externalId: profile.nameID, - email, + email: email.toLowerCase(), firstName, lastName: lastName as string, relayState: (req.body as { RelayState?: string }).RelayState, diff --git a/backend/src/server/routes/v1/secret-sync-routers/oci-vault-sync-router.ts b/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts similarity index 73% rename from backend/src/server/routes/v1/secret-sync-routers/oci-vault-sync-router.ts rename to backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts index b46f27a50b..2efe3e3f53 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/oci-vault-sync-router.ts +++ b/backend/src/ee/routes/v1/secret-sync-routers/oci-vault-sync-router.ts @@ -2,11 +2,10 @@ import { CreateOCIVaultSyncSchema, OCIVaultSyncSchema, UpdateOCIVaultSyncSchema -} from "@app/services/secret-sync/oci-vault"; +} from "@app/ee/services/secret-sync/oci-vault"; +import { registerSyncSecretsEndpoints } from "@app/server/routes/v1/secret-sync-routers/secret-sync-endpoints"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; -import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; - export const registerOCIVaultSyncRouter = async (server: FastifyZodProvider) => registerSyncSecretsEndpoints({ destination: SecretSync.OCIVault, diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 6b5014acc2..17176162b1 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -98,7 +98,7 @@ export const accessApprovalPolicyServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionApprovalActions.Create, ProjectPermissionSub.SecretApproval ); const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); @@ -256,7 +256,10 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Edit, + ProjectPermissionSub.SecretApproval + ); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( @@ -341,7 +344,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionApprovalActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -432,7 +435,10 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ); return policy; }; 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 2b2758b2e6..017356a5d7 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 51a5e0ca2a..162f8b3c65 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/services/app-connection/oci/index.ts b/backend/src/ee/services/app-connections/oci/index.ts similarity index 100% rename from backend/src/services/app-connection/oci/index.ts rename to backend/src/ee/services/app-connections/oci/index.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-enums.ts b/backend/src/ee/services/app-connections/oci/oci-connection-enums.ts similarity index 100% rename from backend/src/services/app-connection/oci/oci-connection-enums.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-enums.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-fns.ts b/backend/src/ee/services/app-connections/oci/oci-connection-fns.ts similarity index 100% rename from backend/src/services/app-connection/oci/oci-connection-fns.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-fns.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-schemas.ts b/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts similarity index 100% rename from backend/src/services/app-connection/oci/oci-connection-schemas.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts diff --git a/backend/src/services/app-connection/oci/oci-connection-service.ts b/backend/src/ee/services/app-connections/oci/oci-connection-service.ts similarity index 68% rename from backend/src/services/app-connection/oci/oci-connection-service.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-service.ts index 2d72135e50..c2e60399c9 100644 --- a/backend/src/services/app-connection/oci/oci-connection-service.ts +++ b/backend/src/ee/services/app-connections/oci/oci-connection-service.ts @@ -1,7 +1,9 @@ +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; -import { AppConnection } from "../app-connection-enums"; +import { AppConnection } from "../../../../services/app-connection/app-connection-enums"; +import { TLicenseServiceFactory } from "../../license/license-service"; import { listOCICompartments, listOCIVaultKeys, listOCIVaults } from "./oci-connection-fns"; import { TOCIConnection } from "./oci-connection-types"; @@ -22,8 +24,23 @@ type TListOCIVaultKeysDTO = { vaultOcid: string; }; -export const ociConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +// Enterprise check +export const checkPlan = async (licenseService: Pick, orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.enterpriseAppConnections) + throw new BadRequestError({ + message: + "Failed to use app connection due to plan restriction. Upgrade plan to access enterprise app connections." + }); +}; + +export const ociConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + licenseService: Pick +) => { const listCompartments = async (connectionId: string, actor: OrgServiceActor) => { + await checkPlan(licenseService, actor.orgId); + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); try { @@ -36,6 +53,8 @@ export const ociConnectionService = (getAppConnection: TGetAppConnectionFunc) => }; const listVaults = async ({ connectionId, compartmentOcid }: TListOCIVaultsDTO, actor: OrgServiceActor) => { + await checkPlan(licenseService, actor.orgId); + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); try { @@ -51,6 +70,8 @@ export const ociConnectionService = (getAppConnection: TGetAppConnectionFunc) => { connectionId, compartmentOcid, vaultOcid }: TListOCIVaultKeysDTO, actor: OrgServiceActor ) => { + await checkPlan(licenseService, actor.orgId); + const appConnection = await getAppConnection(AppConnection.OCI, connectionId, actor); try { diff --git a/backend/src/services/app-connection/oci/oci-connection-types.ts b/backend/src/ee/services/app-connections/oci/oci-connection-types.ts similarity index 87% rename from backend/src/services/app-connection/oci/oci-connection-types.ts rename to backend/src/ee/services/app-connections/oci/oci-connection-types.ts index 74ddfe0c80..e07554f29a 100644 --- a/backend/src/services/app-connection/oci/oci-connection-types.ts +++ b/backend/src/ee/services/app-connections/oci/oci-connection-types.ts @@ -2,7 +2,7 @@ import z from "zod"; import { DiscriminativePick } from "@app/lib/types"; -import { AppConnection } from "../app-connection-enums"; +import { AppConnection } from "../../../../services/app-connection/app-connection-enums"; import { CreateOCIConnectionSchema, OCIConnectionSchema, diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 2c97bd2c0d..c1c046101f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,3 +1,4 @@ +import { ProjectType } from "@app/db/schemas"; import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO @@ -315,7 +316,6 @@ export enum EventType { CREATE_PROJECT_TEMPLATE = "create-project-template", UPDATE_PROJECT_TEMPLATE = "update-project-template", DELETE_PROJECT_TEMPLATE = "delete-project-template", - APPLY_PROJECT_TEMPLATE = "apply-project-template", GET_APP_CONNECTIONS = "get-app-connections", GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details", GET_APP_CONNECTION = "get-app-connection", @@ -383,7 +383,13 @@ export enum EventType { PIT_ROLLBACK_COMMIT = "pit-rollback-commit", PIT_REVERT_COMMIT = "pit-revert-commit", PIT_GET_FOLDER_STATE = "pit-get-folder-state", - PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states" + PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states", + + UPDATE_ORG = "update-org", + + CREATE_PROJECT = "create-project", + UPDATE_PROJECT = "update-project", + DELETE_PROJECT = "delete-project" } export const filterableSecretEvents: EventType[] = [ @@ -2459,14 +2465,6 @@ interface DeleteProjectTemplateEvent { }; } -interface ApplyProjectTemplateEvent { - type: EventType.APPLY_PROJECT_TEMPLATE; - metadata: { - template: string; - projectId: string; - }; -} - interface GetAppConnectionsEvent { type: EventType.GET_APP_CONNECTIONS; metadata: { @@ -2990,6 +2988,59 @@ interface PitCompareFolderStatesEvent { diffsCount: string; environment: string; folderPath: string; + } +} + +interface OrgUpdateEvent { + type: EventType.UPDATE_ORG; + metadata: { + name?: string; + slug?: string; + authEnforced?: boolean; + scimEnabled?: boolean; + defaultMembershipRoleSlug?: string; + enforceMfa?: boolean; + selectedMfaMethod?: string; + allowSecretSharingOutsideOrganization?: boolean; + bypassOrgAuthEnabled?: boolean; + userTokenExpiration?: string; + secretsProductEnabled?: boolean; + pkiProductEnabled?: boolean; + kmsProductEnabled?: boolean; + sshProductEnabled?: boolean; + scannerProductEnabled?: boolean; + shareSecretsProductEnabled?: boolean; + }; +} + +interface ProjectCreateEvent { + type: EventType.CREATE_PROJECT; + metadata: { + name: string; + slug?: string; + type: ProjectType; + }; +} + +interface ProjectUpdateEvent { + type: EventType.UPDATE_PROJECT; + metadata: { + name?: string; + description?: string; + autoCapitalization?: boolean; + hasDeleteProtection?: boolean; + slug?: string; + secretSharing?: boolean; + pitVersionLimit?: number; + auditLogsRetentionDays?: number; + }; +} + +interface ProjectDeleteEvent { + type: EventType.DELETE_PROJECT; + metadata: { + id: string; + name: string; }; } @@ -3197,7 +3248,6 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent | GetAppConnectionsEvent | GetAvailableAppConnectionsDetailsEvent | GetAppConnectionEvent @@ -3266,4 +3316,8 @@ export type Event = | GetProjectPitCommitCountEvent | PitRevertCommitEvent | PitCompareFolderStatesEvent - | PitGetFolderStateEvent; + | PitGetFolderStateEvent + | OrgUpdateEvent + | ProjectCreateEvent + | ProjectUpdateEvent + | ProjectDeleteEvent; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 1d33cafd6f..801f52fc0a 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => { } if (search) { - void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", "username") ilike ?`, [`%${search}%`]); + void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", lower("username")) ilike ?`, [`%${search}%`]); } else if (username) { - void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); + void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`); } switch (filter) { diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index b9206771e6..cc31259186 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -30,7 +30,7 @@ import { import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { - userDAL: Pick; + userDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" @@ -380,7 +380,10 @@ export const groupServiceFactory = ({ details: { missingPermissions: permissionBoundary.missingPermissions } }); - const user = await userDAL.findOne({ username }); + const usersWithUsername = await userDAL.findUserByUsername(username); + // akhilmhdh: case sensitive email resolution + const user = + usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await addUsersToGroupByUserIds({ @@ -461,7 +464,10 @@ export const groupServiceFactory = ({ details: { missingPermissions: permissionBoundary.missingPermissions } }); - const user = await userDAL.findOne({ username }); + const usersWithUsername = await userDAL.findUserByUsername(username); + // akhilmhdh: case sensitive email resolution + const user = + usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await removeUsersFromGroupByUserIds({ diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index e22b18e1b9..c98873879f 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -380,7 +380,7 @@ export const ldapConfigServiceFactory = ({ if (serverCfg.trustLdapEmails) { newUser = await userDAL.findOne( { - email, + email: email.toLowerCase(), isEmailVerified: true }, tx @@ -391,8 +391,8 @@ export const ldapConfigServiceFactory = ({ const uniqueUsername = await normalizeUsername(username, userDAL); newUser = await userDAL.create( { - username: serverCfg.trustLdapEmails ? email : uniqueUsername, - email, + username: serverCfg.trustLdapEmails ? email.toLowerCase() : uniqueUsername, + email: email.toLowerCase(), isEmailVerified: serverCfg.trustLdapEmails, firstName, lastName, @@ -429,7 +429,7 @@ export const ldapConfigServiceFactory = ({ await orgMembershipDAL.create( { userId: newUser.id, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 6a8f807ad7..5259d4616b 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -29,7 +29,9 @@ export const getDefaultOnPremFeatures = () => { secretApproval: true, secretRotation: true, caCrl: false, - sshHostGroups: false + sshHostGroups: false, + enterpriseSecretSyncs: false, + enterpriseAppConnections: false }; }; diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index cab428e86f..88a2dadf66 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -19,7 +19,7 @@ export const licenseDALFactory = (db: TDbClient) => { .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) .count(); - return Number(doc?.[0].count); + return Number(doc?.[0]?.count ?? 0); } catch (error) { throw new DatabaseError({ error, name: "Count of Org Members" }); } diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index b7ae6f7ee2..d8ca362bd6 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -2,6 +2,7 @@ import axios, { AxiosError } from "axios"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; import { TFeatureSet } from "./license-types"; @@ -54,7 +55,9 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ projectTemplates: false, kmip: false, gateway: false, - sshHostGroups: false + sshHostGroups: false, + enterpriseSecretSyncs: false, + enterpriseAppConnections: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { @@ -98,9 +101,10 @@ export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string (response) => response, async (err) => { const originalRequest = (err as AxiosError).config; - + const errStatusCode = Number((err as AxiosError)?.response?.status); + logger.error((err as AxiosError)?.response?.data, "License server call error"); // eslint-disable-next-line - if ((err as AxiosError)?.response?.status === 401 && !(originalRequest as any)._retry) { + if ((errStatusCode === 401 || errStatusCode === 403) && !(originalRequest as any)._retry) { // eslint-disable-next-line (originalRequest as any)._retry = true; // injected diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index cf98186580..ceeebbf7a7 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -92,6 +92,10 @@ export const licenseServiceFactory = ({ const { data: { currentPlan } } = await licenseServerOnPremApi.request.get<{ currentPlan: TFeatureSet }>("/api/license/v1/plan"); + + const workspacesUsed = await projectDAL.countOfOrgProjects(null); + currentPlan.workspacesUsed = workspacesUsed; + onPremFeatures = currentPlan; logger.info("Successfully synchronized license key features"); } catch (error) { @@ -185,6 +189,14 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( `/api/license-server/v1/customers/${org.customerId}/cloud-plan` ); + 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, @@ -348,8 +360,8 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, { - success_url: `${appCfg.SITE_URL}/dashboard`, - cancel_url: `${appCfg.SITE_URL}/dashboard` + success_url: `${appCfg.SITE_URL}/organization/billing`, + cancel_url: `${appCfg.SITE_URL}/organization/billing` } ); @@ -362,7 +374,7 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, { - return_url: `${appCfg.SITE_URL}/dashboard` + return_url: `${appCfg.SITE_URL}/organization/billing` } ); @@ -379,7 +391,7 @@ export const licenseServiceFactory = ({ message: `Organization with ID '${orgId}' not found` }); } - if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { + if (instanceType === InstanceType.Cloud) { const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` ); @@ -407,11 +419,38 @@ export const licenseServiceFactory = ({ message: `Organization with ID '${orgId}' not found` }); } - if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { - const { data } = await licenseServerCloudApi.request.get( - `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` - ); - return data; + + const orgMembersUsed = await orgDAL.countAllOrgMembers(orgId); + const identityUsed = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId }); + const projects = await projectDAL.find({ orgId }); + const projectCount = projects.length; + + if (instanceType === InstanceType.Cloud) { + const { data } = await licenseServerCloudApi.request.get<{ + head: { name: string }[]; + rows: { name: string; allowed: boolean }[]; + }>(`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`); + + const formattedData = { + head: data.head, + rows: data.rows.map((el) => { + let used = "-"; + + if (el.name === BillingPlanRows.MemberLimit.name) { + used = orgMembersUsed.toString(); + } else if (el.name === BillingPlanRows.WorkspaceLimit.name) { + used = projectCount.toString(); + } else if (el.name === BillingPlanRows.IdentityLimit.name) { + used = (identityUsed + orgMembersUsed).toString(); + } + + return { + ...el, + used + }; + }) + }; + return formattedData; } const mappedRows = await Promise.all( @@ -420,14 +459,11 @@ export const licenseServiceFactory = ({ let used = "-"; if (field === BillingPlanRows.MemberLimit.field) { - const orgMemberships = await orgDAL.countAllOrgMembers(orgId); - used = orgMemberships.toString(); + used = orgMembersUsed.toString(); } else if (field === BillingPlanRows.WorkspaceLimit.field) { - const projects = await projectDAL.find({ orgId }); - used = projects.length.toString(); + used = projectCount.toString(); } else if (field === BillingPlanRows.IdentityLimit.field) { - const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId }); - used = identities.toString(); + used = identityUsed.toString(); } return { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 358849fb23..f509c71273 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -27,7 +27,7 @@ export type TFeatureSet = { slug: null; tier: -1; workspaceLimit: null; - workspacesUsed: 0; + workspacesUsed: number; dynamicSecret: false; memberLimit: null; membersUsed: number; @@ -72,6 +72,8 @@ export type TFeatureSet = { kmip: false; gateway: false; sshHostGroups: false; + enterpriseSecretSyncs: false; + enterpriseAppConnections: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 6accb69e96..d933835e48 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({ }; const oidcLogin = async ({ - externalId, email, + externalId, firstName, lastName, orgId, @@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({ const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined); oidcLogin({ - email: claims.email, + email: claims.email.toLowerCase(), externalId: claims.sub, firstName: claims.given_name ?? "", lastName: claims.family_name ?? "", diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index aceeec3dd3..f7ca2abcf0 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, + ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -26,7 +27,6 @@ const buildAdminPermissionRules = () => { [ ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, - ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Role, ProjectPermissionSub.Integrations, ProjectPermissionSub.Webhooks, @@ -56,6 +56,18 @@ const buildAdminPermissionRules = () => { ); }); + can( + [ + ProjectPermissionApprovalActions.Read, + ProjectPermissionApprovalActions.Edit, + ProjectPermissionApprovalActions.Create, + ProjectPermissionApprovalActions.Delete, + ProjectPermissionApprovalActions.AllowChangeBypass, + ProjectPermissionApprovalActions.AllowAccessBypass + ], + ProjectPermissionSub.SecretApproval + ); + can( [ ProjectPermissionCertificateActions.Read, @@ -254,7 +266,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Commits ); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); + can([ProjectPermissionApprovalActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); @@ -402,7 +414,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionApprovalActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 74b73ad5c1..761e8eddd8 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -39,6 +39,15 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } +export enum ProjectPermissionApprovalActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" +} + export enum ProjectPermissionCmekActions { Read = "read", Create = "create", @@ -248,7 +257,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionSecretRotationActions, ( @@ -446,7 +455,7 @@ const PkiSubscriberConditionSchema = z const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalActions).describe( "Describe what action an entity can take." ) }), @@ -618,7 +627,7 @@ const GeneralPermissionSchema = [ }) ]; -// Do not update this schema anymore, as it's kept purely for backwards compatability. Update V2 schema only. +// Do not update this schema anymore, as it's kept purely for backwards compatibility. Update V2 schema only. export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ z.object({ subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 84cced88fc..4aad13ab80 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -342,7 +342,7 @@ export const scimServiceFactory = ({ orgMembership = await orgMembershipDAL.create( { userId: userAlias.userId, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -364,7 +364,7 @@ export const scimServiceFactory = ({ if (trustScimEmails) { user = await userDAL.findOne( { - email, + email: email.toLowerCase(), isEmailVerified: true }, tx @@ -379,8 +379,8 @@ export const scimServiceFactory = ({ ); user = await userDAL.create( { - username: trustScimEmails ? email : uniqueUsername, - email, + username: trustScimEmails ? email.toLowerCase() : uniqueUsername, + email: email.toLowerCase(), isEmailVerified: trustScimEmails, firstName, lastName, @@ -396,7 +396,7 @@ export const scimServiceFactory = ({ userId: user.id, aliasType, externalId, - emails: email ? [email] : [], + emails: email ? [email.toLowerCase()] : [], orgId }, tx @@ -418,7 +418,7 @@ export const scimServiceFactory = ({ orgMembership = await orgMembershipDAL.create( { userId: user.id, - inviteEmail: email, + inviteEmail: email.toLowerCase(), orgId, role, roleId, @@ -529,7 +529,7 @@ export const scimServiceFactory = ({ membership.userId, { firstName: scimUser.name.givenName, - email: scimUser.emails[0].value, + email: scimUser.emails[0].value.toLowerCase(), lastName: scimUser.name.familyName, isEmailVerified: hasEmailChanged ? trustScimEmails : undefined }, @@ -606,7 +606,7 @@ export const scimServiceFactory = ({ membership.userId, { firstName, - email, + email: email?.toLowerCase(), lastName, isEmailVerified: org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 4c212e6cd5..bc2877ef2a 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -3,7 +3,7 @@ import picomatch from "picomatch"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { containsGlobPatterns } from "@app/lib/picomatch"; @@ -89,7 +89,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionApprovalActions.Create, ProjectPermissionSub.SecretApproval ); @@ -204,7 +204,10 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Edit, + ProjectPermissionSub.SecretApproval + ); const plan = await licenseService.getPlan(actorOrgId); if (!plan.secretApproval) { @@ -301,7 +304,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionApprovalActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -340,7 +343,10 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ); const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; @@ -413,7 +419,10 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ); return sapPolicy; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 054374c7a1..6dbdc74b6c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -63,7 +63,11 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; +import { + ProjectPermissionApprovalActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; @@ -507,7 +511,7 @@ export const secretApprovalRequestServiceFactory = ({ }); } - const { hasRole } = await permissionService.getProjectPermission({ + const { hasRole, permission } = await permissionService.getProjectPermission({ actor: ActorType.USER, actorId, projectId, @@ -534,7 +538,13 @@ export const secretApprovalRequestServiceFactory = ({ ).length; const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; - if (!hasMinApproval && !isSoftEnforcement) + if ( + !hasMinApproval && + !( + isSoftEnforcement && + permission.can(ProjectPermissionApprovalActions.AllowChangeBypass, ProjectPermissionSub.SecretApproval) + ) + ) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts index 0fd01b7533..07cf97a7ee 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts @@ -1,4 +1,4 @@ -import ldap from "ldapjs"; +import ldap, { Client, SearchOptions } from "ldapjs"; import { TRotationFactory, @@ -8,26 +8,73 @@ import { TRotationFactoryRotateCredentials } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { logger } from "@app/lib/logger"; +import { DistinguishedNameRegex } from "@app/lib/regex"; import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap"; import { generatePassword } from "../shared/utils"; import { + LdapPasswordRotationMethod, TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationInput, TLdapPasswordRotationWithConnection } from "./ldap-password-rotation-types"; const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le"); +const getDN = async (dn: string, client: Client): Promise => { + if (DistinguishedNameRegex.test(dn)) return dn; + + const opts: SearchOptions = { + filter: `(userPrincipalName=${dn})`, + scope: "sub", + attributes: ["dn"] + }; + + const base = dn + .split("@")[1] + .split(".") + .map((dc) => `dc=${dc}`) + .join(","); + + return new Promise((resolve, reject) => { + // Perform the search + client.search(base, opts, (err, res) => { + if (err) { + logger.error(err, "LDAP Failed to get DN"); + reject(new Error(`Provider Resolve DN Error: ${err.message}`)); + } + + let userDn: string | null; + + res.on("searchEntry", (entry) => { + userDn = entry.objectName; + }); + + res.on("error", (error) => { + logger.error(error, "LDAP Failed to get DN"); + reject(new Error(`Provider Resolve DN Error: ${error.message}`)); + }); + + res.on("end", () => { + if (userDn) { + resolve(userDn); + } else { + reject(new Error(`Unable to resolve DN for ${dn}.`)); + } + }); + }); + }); +}; + export const ldapPasswordRotationFactory: TRotationFactory< TLdapPasswordRotationWithConnection, - TLdapPasswordRotationGeneratedCredentials + TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationInput["temporaryParameters"] > = (secretRotation, appConnectionDAL, kmsService) => { - const { - connection, - parameters: { dn, passwordRequirements }, - secretsMapping - } = secretRotation; + const { connection, parameters, secretsMapping, activeIndex } = secretRotation; + + const { dn, passwordRequirements } = parameters; const $verifyCredentials = async (credentials: Pick) => { try { @@ -40,13 +87,21 @@ export const ldapPasswordRotationFactory: TRotationFactory< } }; - const $rotatePassword = async () => { + const $rotatePassword = async (currentPassword?: string) => { const { credentials, orgId } = connection; if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection"); - const client = await getLdapConnectionClient(credentials); - const isPersonalRotation = credentials.dn === dn; + const client = await getLdapConnectionClient( + currentPassword + ? { + ...credentials, + password: currentPassword, + dn + } + : credentials + ); + const isConnectionRotation = credentials.dn === dn; const password = generatePassword(passwordRequirements); @@ -58,8 +113,8 @@ export const ldapPasswordRotationFactory: TRotationFactory< const encodedPassword = getEncodedPassword(password); // service account vs personal password rotation require different changes - if (isPersonalRotation) { - const currentEncodedPassword = getEncodedPassword(credentials.password); + if (isConnectionRotation || currentPassword) { + const currentEncodedPassword = getEncodedPassword(currentPassword || credentials.password); changes = [ new ldap.Change({ @@ -93,8 +148,9 @@ export const ldapPasswordRotationFactory: TRotationFactory< } try { + const userDn = await getDN(dn, client); await new Promise((resolve, reject) => { - client.modify(dn, changes, (err) => { + client.modify(userDn, changes, (err) => { if (err) { logger.error(err, "LDAP Password Rotation Failed"); reject(new Error(`Provider Modify Error: ${err.message}`)); @@ -110,7 +166,7 @@ export const ldapPasswordRotationFactory: TRotationFactory< await $verifyCredentials({ dn, password }); - if (isPersonalRotation) { + if (isConnectionRotation) { const updatedCredentials: TLdapConnection["credentials"] = { ...credentials, password @@ -128,29 +184,41 @@ export const ldapPasswordRotationFactory: TRotationFactory< return { dn, password }; }; - const issueCredentials: TRotationFactoryIssueCredentials = async ( - callback - ) => { - const credentials = await $rotatePassword(); + const issueCredentials: TRotationFactoryIssueCredentials< + TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationInput["temporaryParameters"] + > = async (callback, temporaryParameters) => { + const credentials = await $rotatePassword( + parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal + ? temporaryParameters?.password + : undefined + ); return callback(credentials); }; const revokeCredentials: TRotationFactoryRevokeCredentials = async ( - _, + credentialsToRevoke, callback ) => { + const currentPassword = credentialsToRevoke[activeIndex].password; + // we just rotate to a new password, essentially revoking old credentials - await $rotatePassword(); + await $rotatePassword( + parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? currentPassword : undefined + ); return callback(); }; const rotateCredentials: TRotationFactoryRotateCredentials = async ( _, - callback + callback, + activeCredentials ) => { - const credentials = await $rotatePassword(); + const credentials = await $rotatePassword( + parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? activeCredentials.password : undefined + ); return callback(credentials); }; diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts index e99569d9af..741cd3ce12 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts @@ -1,6 +1,6 @@ -import RE2 from "re2"; import { z } from "zod"; +import { LdapPasswordRotationMethod } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types"; import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { BaseCreateSecretRotationSchema, @@ -9,7 +9,7 @@ import { } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general"; import { SecretRotations } from "@app/lib/api-docs"; -import { DistinguishedNameRegex } from "@app/lib/regex"; +import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/lib/regex"; import { SecretNameSchema } from "@app/server/lib/schemas"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; @@ -26,10 +26,16 @@ const LdapPasswordRotationParametersSchema = z.object({ dn: z .string() .trim() - .regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") - .min(1, "Distinguished Name (DN) Required") + .min(1, "DN/UPN required") + .refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), { + message: "Invalid DN/UPN format" + }) .describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn), - passwordRequirements: PasswordRequirementsSchema.optional() + passwordRequirements: PasswordRequirementsSchema.optional(), + rotationMethod: z + .nativeEnum(LdapPasswordRotationMethod) + .optional() + .describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.rotationMethod) }); const LdapPasswordRotationSecretsMappingSchema = z.object({ @@ -50,10 +56,28 @@ export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotatio secretsMapping: LdapPasswordRotationSecretsMappingSchema }); -export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({ - parameters: LdapPasswordRotationParametersSchema, - secretsMapping: LdapPasswordRotationSecretsMappingSchema -}); +export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword) + .extend({ + parameters: LdapPasswordRotationParametersSchema, + secretsMapping: LdapPasswordRotationSecretsMappingSchema, + temporaryParameters: z + .object({ + password: z.string().min(1, "Password required").describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.password) + }) + .optional() + }) + .superRefine((val, ctx) => { + if ( + val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && + !val.temporaryParameters?.password + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password required", + path: ["temporaryParameters", "password"] + }); + } + }); export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({ parameters: LdapPasswordRotationParametersSchema.optional(), diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts index cb15b0734f..86437cac50 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts @@ -9,6 +9,11 @@ import { LdapPasswordRotationSchema } from "./ldap-password-rotation-schemas"; +export enum LdapPasswordRotationMethod { + ConnectionPrincipal = "connection-principal", + TargetPrincipal = "target-principal" +} + export type TLdapPasswordRotation = z.infer; export type TLdapPasswordRotationInput = z.infer; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index a25482c8cc..1be7dc8024 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -1,12 +1,13 @@ import { AxiosError } from "axios"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; -import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; +import { LDAP_PASSWORD_ROTATION_LIST_OPTION, TLdapPasswordRotation } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; @@ -15,7 +16,8 @@ import { TSecretRotationV2, TSecretRotationV2GeneratedCredentials, TSecretRotationV2ListItem, - TSecretRotationV2Raw + TSecretRotationV2Raw, + TUpdateSecretRotationV2DTO } from "./secret-rotation-v2-types"; const SECRET_ROTATION_LIST_OPTIONS: Record = { @@ -228,3 +230,30 @@ export const parseRotationErrorMessage = (err: unknown): string => { ? errorMessage : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; }; + +function haveUnequalProperties(obj1: T, obj2: T, properties: (keyof T)[]): boolean { + return properties.some((prop) => obj1[prop] !== obj2[prop]); +} + +export const throwOnImmutableParameterUpdate = ( + updatePayload: TUpdateSecretRotationV2DTO, + secretRotation: TSecretRotationV2Raw +) => { + if (!updatePayload.parameters) return; + + switch (updatePayload.type) { + case SecretRotation.LdapPassword: + if ( + haveUnequalProperties( + updatePayload.parameters as TLdapPasswordRotation["parameters"], + secretRotation.parameters as TLdapPasswordRotation["parameters"], + ["rotationMethod", "dn"] + ) + ) { + throw new BadRequestError({ message: "Cannot update rotation method or DN" }); + } + break; + default: + // do nothing + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 0a7a5c12c7..4e66a70230 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -25,7 +25,8 @@ import { getNextUtcRotationInterval, getSecretRotationRotateSecretJobOptions, listSecretRotationOptions, - parseRotationErrorMessage + parseRotationErrorMessage, + throwOnImmutableParameterUpdate } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; import { SECRET_ROTATION_CONNECTION_MAP, @@ -46,6 +47,7 @@ import { TSecretRotationV2, TSecretRotationV2GeneratedCredentials, TSecretRotationV2Raw, + TSecretRotationV2TemporaryParameters, TSecretRotationV2WithConnection, TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; @@ -114,7 +116,8 @@ const MAX_GENERATED_CREDENTIALS_LENGTH = 2; type TRotationFactoryImplementation = TRotationFactory< TSecretRotationV2WithConnection, - TSecretRotationV2GeneratedCredentials + TSecretRotationV2GeneratedCredentials, + TSecretRotationV2TemporaryParameters >; const SECRET_ROTATION_FACTORY_MAP: Record = { [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, @@ -403,6 +406,7 @@ export const secretRotationV2ServiceFactory = ({ environment, rotateAtUtc = { hours: 0, minutes: 0 }, secretsMapping, + temporaryParameters, ...payload }: TCreateSecretRotationV2DTO, actor: OrgServiceActor @@ -554,7 +558,7 @@ export const secretRotationV2ServiceFactory = ({ return createdRotation; }); - }); + }, temporaryParameters); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); @@ -593,10 +597,7 @@ export const secretRotationV2ServiceFactory = ({ } }; - const updateSecretRotation = async ( - { type, rotationId, ...payload }: TUpdateSecretRotationV2DTO, - actor: OrgServiceActor - ) => { + const updateSecretRotation = async (dto: TUpdateSecretRotationV2DTO, actor: OrgServiceActor) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) @@ -604,6 +605,8 @@ export const secretRotationV2ServiceFactory = ({ message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations." }); + const { type, rotationId, ...payload } = dto; + const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) @@ -611,6 +614,8 @@ export const secretRotationV2ServiceFactory = ({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` }); + throwOnImmutableParameterUpdate(dto, secretRotation); + const { folder, environment, projectId, folderId, connection } = secretRotation; const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; @@ -893,6 +898,7 @@ export const secretRotationV2ServiceFactory = ({ const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH; const inactiveCredentials = generatedCredentials[inactiveIndex]; + const activeCredentials = generatedCredentials[activeIndex]; const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]( { @@ -903,77 +909,81 @@ export const secretRotationV2ServiceFactory = ({ kmsService ); - const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => { - const updatedCredentials = [...generatedCredentials]; - updatedCredentials[inactiveIndex] = newCredentials; + const updatedRotation = await rotationFactory.rotateCredentials( + inactiveCredentials, + async (newCredentials) => { + const updatedCredentials = [...generatedCredentials]; + updatedCredentials[inactiveIndex] = newCredentials; - const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ - projectId, - generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials, - kmsService - }); - - return secretRotationV2DAL.transaction(async (tx) => { - const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); - - const { encryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId + const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ + projectId, + generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials, + kmsService }); - // update mapped secrets with new credential values - await fnSecretBulkUpdate({ - folderId, - orgId: connection.orgId, - tx, - inputSecrets: secretsPayload.map(({ key, value }) => ({ - filter: { - key, - folderId, - type: SecretType.Shared + return secretRotationV2DAL.transaction(async (tx) => { + const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + // update mapped secrets with new credential values + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: secretsPayload.map(({ key, value }) => ({ + filter: { + key, + folderId, + type: SecretType.Shared + }, + data: { + encryptedValue: encryptor({ + plainText: Buffer.from(value) + }).cipherTextBlob, + references: [] + } + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + folderCommitService, + actor: { + type: ActorType.PLATFORM }, - data: { - encryptedValue: encryptor({ - plainText: Buffer.from(value) - }).cipherTextBlob, - references: [] - } - })), - secretDAL: secretV2BridgeDAL, - secretVersionDAL: secretVersionV2BridgeDAL, - secretVersionTagDAL: secretVersionTagV2BridgeDAL, - folderCommitService, - actor: { - type: ActorType.PLATFORM - }, - secretTagDAL, - resourceMetadataDAL - }); + secretTagDAL, + resourceMetadataDAL + }); - const currentTime = new Date(); + const currentTime = new Date(); - return secretRotationV2DAL.updateById( - secretRotation.id, - { - encryptedGeneratedCredentials: encryptedUpdatedCredentials, - activeIndex: inactiveIndex, - isLastRotationManual: isManualRotation, - lastRotatedAt: currentTime, - lastRotationAttemptedAt: currentTime, - nextRotationAt: calculateNextRotationAt({ - ...(secretRotation as TSecretRotationV2), - rotationStatus: SecretRotationStatus.Success, + return secretRotationV2DAL.updateById( + secretRotation.id, + { + encryptedGeneratedCredentials: encryptedUpdatedCredentials, + activeIndex: inactiveIndex, + isLastRotationManual: isManualRotation, lastRotatedAt: currentTime, - isManualRotation - }), - rotationStatus: SecretRotationStatus.Success, - lastRotationJobId: jobId, - encryptedLastRotationMessage: null - }, - tx - ); - }); - }); + lastRotationAttemptedAt: currentTime, + nextRotationAt: calculateNextRotationAt({ + ...(secretRotation as TSecretRotationV2), + rotationStatus: SecretRotationStatus.Success, + lastRotatedAt: currentTime, + isManualRotation + }), + rotationStatus: SecretRotationStatus.Success, + lastRotationJobId: jobId, + encryptedLastRotationMessage: null + }, + tx + ); + }); + }, + activeCredentials + ); await auditLogService.createAuditLog({ ...(auditLogInfo ?? { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index ab715c4060..b72bfba31b 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -87,6 +87,8 @@ export type TSecretRotationV2ListItem = | TLdapPasswordRotationListItem | TAwsIamUserSecretRotationListItem; +export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined; + export type TSecretRotationV2Raw = NonNullable>>; export type TListSecretRotationsV2ByProjectId = { @@ -120,6 +122,7 @@ export type TCreateSecretRotationV2DTO = Pick< environment: string; isAutoRotationEnabled?: boolean; rotateAtUtc?: TRotateAtUtc; + temporaryParameters?: TSecretRotationV2TemporaryParameters; }; export type TUpdateSecretRotationV2DTO = Partial< @@ -186,8 +189,12 @@ export type TSecretRotationSendNotificationJobPayload = { // transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the // third party credential changes (when supported), preventing credentials getting out of sync -export type TRotationFactoryIssueCredentials = ( - callback: (newCredentials: T[number]) => Promise +export type TRotationFactoryIssueCredentials< + T extends TSecretRotationV2GeneratedCredentials, + P extends TSecretRotationV2TemporaryParameters = undefined +> = ( + callback: (newCredentials: T[number]) => Promise, + temporaryParameters?: P ) => Promise; export type TRotationFactoryRevokeCredentials = ( @@ -197,7 +204,8 @@ export type TRotationFactoryRevokeCredentials = ( credentialsToRevoke: T[number] | undefined, - callback: (newCredentials: T[number]) => Promise + callback: (newCredentials: T[number]) => Promise, + activeCredentials: T[number] ) => Promise; export type TRotationFactoryGetSecretsPayload = ( @@ -206,13 +214,14 @@ export type TRotationFactoryGetSecretsPayload = ( secretRotation: T, appConnectionDAL: Pick, kmsService: Pick ) => { - issueCredentials: TRotationFactoryIssueCredentials; + issueCredentials: TRotationFactoryIssueCredentials; revokeCredentials: TRotationFactoryRevokeCredentials; rotateCredentials: TRotationFactoryRotateCredentials; getSecretsPayload: TRotationFactoryGetSecretsPayload; diff --git a/backend/src/services/secret-sync/oci-vault/index.ts b/backend/src/ee/services/secret-sync/oci-vault/index.ts similarity index 100% rename from backend/src/services/secret-sync/oci-vault/index.ts rename to backend/src/ee/services/secret-sync/oci-vault/index.ts diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-constants.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts similarity index 89% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-constants.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts index 9e2aad0568..b864e354b7 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-constants.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-constants.ts @@ -6,5 +6,6 @@ export const OCI_VAULT_SYNC_LIST_OPTION: TSecretSyncListItem = { name: "OCI Vault", destination: SecretSync.OCIVault, connection: AppConnection.OCI, - canImportSecrets: true + canImportSecrets: true, + enterprise: true }; diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-fns.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts similarity index 98% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-fns.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts index e270f2e025..5b05b2301e 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-fns.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts @@ -1,7 +1,6 @@ import { secrets, vault } from "oci-sdk"; -import { delay } from "@app/lib/delay"; -import { getOCIProvider } from "@app/services/app-connection/oci"; +import { getOCIProvider } from "@app/ee/services/app-connections/oci"; import { TCreateOCIVaultVariable, TDeleteOCIVaultVariable, @@ -9,7 +8,8 @@ import { TOCIVaultSyncWithCredentials, TUnmarkOCIVaultVariableFromDeletion, TUpdateOCIVaultVariable -} from "@app/services/secret-sync/oci-vault/oci-vault-sync-types"; +} from "@app/ee/services/secret-sync/oci-vault/oci-vault-sync-types"; +import { delay } from "@app/lib/delay"; 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"; diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts similarity index 97% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts index 84a58bc8a0..a0bd293825 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts @@ -66,5 +66,6 @@ export const OCIVaultSyncListItemSchema = z.object({ name: z.literal("OCI Vault"), connection: z.literal(AppConnection.OCI), destination: z.literal(SecretSync.OCIVault), - canImportSecrets: z.literal(true) + canImportSecrets: z.literal(true), + enterprise: z.boolean() }); diff --git a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-types.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts similarity index 94% rename from backend/src/services/secret-sync/oci-vault/oci-vault-sync-types.ts rename to backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts index c040cd0c0b..8804b13226 100644 --- a/backend/src/services/secret-sync/oci-vault/oci-vault-sync-types.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-types.ts @@ -1,7 +1,7 @@ import { SimpleAuthenticationDetailsProvider } from "oci-sdk"; import { z } from "zod"; -import { TOCIConnection } from "@app/services/app-connection/oci"; +import { TOCIConnection } from "@app/ee/services/app-connections/oci"; import { CreateOCIVaultSyncSchema, OCIVaultSyncListItemSchema, OCIVaultSyncSchema } from "./oci-vault-sync-schemas"; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 3fad36facc..42207e621a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2063,7 +2063,7 @@ export const AppConnections = { LDAP: { provider: "The type of LDAP provider. Determines provider-specific behaviors.", url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').", - dn: "The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').", + dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').", password: "The password to bind with for authentication.", sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.", @@ -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." } } }; @@ -2308,7 +2315,10 @@ export const SecretRotations = { clientId: "The client ID of the Azure Application to rotate the client secret for." }, LDAP_PASSWORD: { - dn: "The Distinguished Name (DN) of the principal to rotate the password for." + dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to rotate the password for.", + rotationMethod: + 'Whether the rotation should be performed by the LDAP "connection-principal" or the "target-principal" (defaults to \'connection-principal\').', + password: 'The password of the provided principal if "parameters.rotationMethod" is set to "target-principal".' }, GENERAL: { PASSWORD_REQUIREMENTS: { @@ -2342,7 +2352,7 @@ export const SecretRotations = { clientSecret: "The name of the secret that the rotated client secret will be mapped to." }, LDAP_PASSWORD: { - dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", + dn: "The name of the secret that the Distinguished Name (DN) or User Principal Name (UPN) of the principal will be mapped to.", password: "The name of the secret that the rotated password will be mapped to." }, AWS_IAM_USER_SECRET: { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 352b86770c..8028ee19b8 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -69,6 +69,9 @@ const envSchema = z SMTP_PASSWORD: zpStr(z.string().optional()), SMTP_FROM_ADDRESS: zpStr(z.string().optional()), SMTP_FROM_NAME: zpStr(z.string().optional().default("Infisical")), + SMTP_CUSTOM_CA_CERT: zpStr( + z.string().optional().describe("Base64 encoded custom CA certificate PEM(s) for the SMTP server") + ), COOKIE_SECRET_SIGN_KEY: z .string() .min(32) @@ -302,6 +305,17 @@ export const initEnvConfig = (logger?: CustomLogger) => { }; export const formatSmtpConfig = () => { + const tlsOptions: { + rejectUnauthorized: boolean; + ca?: string | string[]; + } = { + rejectUnauthorized: envCfg.SMTP_TLS_REJECT_UNAUTHORIZED + }; + + if (envCfg.SMTP_CUSTOM_CA_CERT) { + tlsOptions.ca = Buffer.from(envCfg.SMTP_CUSTOM_CA_CERT, "base64").toString("utf-8"); + } + return { host: envCfg.SMTP_HOST, port: envCfg.SMTP_PORT, @@ -313,8 +327,6 @@ export const formatSmtpConfig = () => { from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>`, ignoreTLS: envCfg.SMTP_IGNORE_TLS, requireTLS: envCfg.SMTP_REQUIRE_TLS, - tls: { - rejectUnauthorized: envCfg.SMTP_TLS_REJECT_UNAUTHORIZED - } + tls: tlsOptions }; }; diff --git a/backend/src/lib/knex/scim.ts b/backend/src/lib/knex/scim.ts index 64f7fc2f61..d522e2f5f9 100644 --- a/backend/src/lib/knex/scim.ts +++ b/backend/src/lib/knex/scim.ts @@ -1,6 +1,8 @@ import { Knex } from "knex"; import { Compare, Filter, parse } from "scim2-parse-filter"; +import { TableName } from "@app/db/schemas"; + const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => { if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") { return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` }; @@ -27,8 +29,12 @@ const processDynamicQuery = ( const { scimFilterAst, query } = stack.pop()!; switch (scimFilterAst.op) { case "eq": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.where(attrPath, scimFilterAst.compValue); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.where(attrPath, sanitizedValue); break; } case "pr": { @@ -62,18 +68,30 @@ const processDynamicQuery = ( break; } case "ew": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}`); break; } case "co": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}%`); break; } case "ne": { + let sanitizedValue = scimFilterAst.compValue; const attrPath = getAttributeField(scimFilterAst.attrPath); - if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue); + if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") { + sanitizedValue = sanitizedValue.toLowerCase(); + } + if (attrPath) void query.whereNot(attrPath, "=", sanitizedValue); break; } case "and": { diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index afde8ef97d..219b4a9a7d 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -95,11 +95,20 @@ const extractReqId = () => { try { return requestContext.get("reqId") || UNKNOWN_REQUEST_ID; } catch (err) { + // eslint-disable-next-line no-console console.log("failed to get request context", err); return UNKNOWN_REQUEST_ID; } }; +const extractOrgId = () => { + try { + return requestContext.get("orgId"); + } catch { + return ""; + } +}; + export const initLogger = () => { const cfg = loggerConfig.parse(process.env); const targets: pino.TransportMultiOptions["targets"][number][] = [ @@ -135,22 +144,22 @@ export const initLogger = () => { const wrapLogger = (originalLogger: Logger): CustomLogger => { // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).info(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).info(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).error(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).error(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).warn(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).warn(obj, msg, ...args); }; // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => { - return originalLogger.child({ reqId: extractReqId() }).debug(obj, msg, ...args); + return originalLogger.child({ reqId: extractReqId(), orgId: extractOrgId() }).debug(obj, msg, ...args); }; return originalLogger; diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index 68ba7671d5..be94306694 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -1,3 +1,11 @@ +import RE2 from "re2"; + export const DistinguishedNameRegex = // DN format, ie; CN=user,OU=users,DC=example,DC=com - /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; + new RE2( + /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/ + ); + +export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$/); + +export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 9f93eaea0d..00651d2cc4 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -9,7 +9,7 @@ interface SlugSchemaInputs { field?: string; } -export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { +export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => { return z .string() .trim() diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 57a1313c6f..afea5c9f9c 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -123,6 +123,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { switch (authMode) { case AuthMode.JWT: { const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + requestContext.set("orgId", orgId); req.auth = { authMode: AuthMode.JWT, user, @@ -138,6 +139,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { case AuthMode.IDENTITY_ACCESS_TOKEN: { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); const serverCfg = await getServerCfg(); + requestContext.set("orgId", identity.orgId); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, @@ -157,6 +159,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { } case AuthMode.SERVICE_TOKEN: { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); + requestContext.set("orgId", serviceToken.orgId); req.auth = { orgId: serviceToken.orgId, authMode: AuthMode.SERVICE_TOKEN as const, @@ -181,6 +184,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { } case AuthMode.SCIM_TOKEN: { const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + requestContext.set("orgId", orgId); req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; break; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 865b1a6ccc..ba7666e7d8 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -669,7 +669,6 @@ export const registerRoutes = async ( const userService = userServiceFactory({ userDAL, - userAliasDAL, orgMembershipDAL, tokenService, permissionService, @@ -1060,7 +1059,8 @@ export const registerRoutes = async ( secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, resourceMetadataDAL, - appConnectionDAL + appConnectionDAL, + licenseService }); const secretQueueService = secretQueueFactory({ @@ -1695,7 +1695,8 @@ export const registerRoutes = async ( const appConnectionService = appConnectionServiceFactory({ appConnectionDAL, permissionService, - kmsService + kmsService, + licenseService }); const secretSyncService = secretSyncServiceFactory({ @@ -1706,7 +1707,8 @@ export const registerRoutes = async ( folderDAL, secretSyncQueue, projectBotService, - keyStore + keyStore, + licenseService }); const kmipService = kmipServiceFactory({ 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 0000000000..1100776d37 --- /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 b9ce3deb8d..0fea749c0f 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 @@ -1,9 +1,14 @@ import { z } from "zod"; +import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci"; 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 { @@ -38,7 +43,6 @@ import { } from "@app/services/app-connection/humanitec"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; -import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/services/app-connection/oci"; import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema @@ -78,7 +82,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options, - ...SanitizedOCIConnectionSchema.options + ...SanitizedOCIConnectionSchema.options, + ...SanitizedOnePassConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -100,7 +105,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 6f6fa19917..1c46b4ea98 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,5 +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"; @@ -13,7 +15,6 @@ import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; -import { registerOCIConnectionRouter } from "./oci-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; @@ -42,5 +43,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { method: "POST", schema: { body: z.object({ - inviteeEmails: z.array(z.string().trim().email()), + inviteeEmails: z + .string() + .trim() + .email() + .array() + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), organizationId: z.string().trim(), projects: z .object({ @@ -115,7 +120,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().trim().email(), + email: z + .string() + .trim() + .email() + .refine((val) => val === val.toLowerCase(), "Email must be lowercase"), organizationId: z.string().trim(), code: z.string().trim() }), diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index c489d685da..b3fceb2019 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -312,8 +312,17 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { data: req.body }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_ORG, + metadata: req.body + } + }); + return { - message: "Successfully changed organization name", + message: "Successfully updated organization", organization }; } diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 2e983cb830..651faede4e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -263,6 +263,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.DELETE_PROJECT, + metadata: workspace + } + }); + return { workspace }; } }); @@ -297,6 +308,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId, name: req.body.name }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return { message: "Successfully changed workspace name", workspace @@ -375,6 +397,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return { workspace }; @@ -411,6 +444,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId, autoCapitalization: req.body.autoCapitalization }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return { message: "Successfully changed workspace settings", workspace @@ -448,6 +492,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId, hasDeleteProtection: req.body.hasDeleteProtection }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return { message: "Successfully changed workspace settings", workspace @@ -486,6 +541,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { workspaceSlug: req.params.workspaceSlug }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: workspace.id, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return { message: "Successfully changed workspace version limit", workspace @@ -524,6 +589,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { auditLogsRetentionDays: req.body.auditLogsRetentionDays }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: workspace.id, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return { message: "Successfully updated project's audit logs retention period", workspace diff --git a/backend/src/server/routes/v1/secret-sync-routers/1password-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/1password-sync-router.ts new file mode 100644 index 0000000000..a6f5cc73fc --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/1password-sync-router.ts @@ -0,0 +1,17 @@ +import { + CreateOnePassSyncSchema, + OnePassSyncSchema, + UpdateOnePassSyncSchema +} from "@app/services/secret-sync/1password"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerOnePassSyncRouter = async (server: FastifyZodProvider) => + 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 b5bd62ad66..fbc636ffc2 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -1,5 +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 { 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"; @@ -10,7 +12,6 @@ import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; -import { registerOCIVaultSyncRouter } from "./oci-vault-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; @@ -33,5 +34,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index a97f11be49..a0c3592f7e 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -46,6 +46,54 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/duplicate-accounts", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + users: UsersSchema.extend({ + isMyAccount: z.boolean(), + organizations: z.object({ name: z.string(), slug: z.string() }).array() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) { + const users = await server.services.user.getAllMyAccounts(req.auth.user.email, req.permission.id); + return { users }; + } + return { users: [] }; + } + }); + + server.route({ + method: "POST", + url: "/remove-duplicate-accounts", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) { + await server.services.user.removeMyDuplicateAccounts(req.auth.user.email, req.permission.id); + } + return { message: "Removed all duplicate accounts" }; + } + }); + server.route({ method: "GET", url: "/private-key", diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index a1a1cfc969..76f1e9c5ea 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames), + emails: z + .string() + .email() + .array() + .default([]) + .describe(PROJECT_USERS.INVITE_MEMBER.emails) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), + usernames: z + .string() + .array() + .default([]) + .describe(PROJECT_USERS.INVITE_MEMBER.usernames) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase"), roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs) }), response: { @@ -92,8 +103,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) + emails: z + .string() + .email() + .array() + .default([]) + .describe(PROJECT_USERS.REMOVE_MEMBER.emails) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), + usernames: z + .string() + .array() + .default([]) + .describe(PROJECT_USERS.REMOVE_MEMBER.usernames) + .refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase") }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 3d92bfb1a7..00cd693291 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -206,19 +206,18 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); - if (req.body.template) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - orgId: req.permission.orgId, - event: { - type: EventType.APPLY_PROJECT_TEMPLATE, - metadata: { - template: req.body.template, - projectId: project.id - } + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: project.id, + event: { + type: EventType.CREATE_PROJECT, + metadata: { + ...req.body, + name: req.body.projectName } - }); - } + } + }); return { project }; } @@ -262,6 +261,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actor: req.permission.type }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: project.id, + event: { + type: EventType.DELETE_PROJECT, + metadata: project + } + }); + return project; } }); @@ -341,6 +350,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: project.id, + event: { + type: EventType.UPDATE_PROJECT, + metadata: req.body + } + }); + return project; } }); 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 0000000000..85b28ee5a4 --- /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 0000000000..d8a18576f9 --- /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 0000000000..da63dc32a2 --- /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 0000000000..8e1df95368 --- /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 0000000000..99d6bf94a7 --- /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 0000000000..333cc347e9 --- /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 6e09f12932..25c6394fad 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -17,7 +17,8 @@ export enum AppConnection { HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity", - OCI = "oci" + OCI = "oci", + OnePass = "1password" } export enum AWSRegion { @@ -66,3 +67,8 @@ export enum AWSRegion { // South America SA_EAST_1 = "sa-east-1" // Sao Paulo } + +export enum AppConnectionPlanType { + Enterprise = "enterprise", + Regular = "regular" +} diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index f6fd894a64..86e7280089 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,14 +1,25 @@ import { TAppConnections } from "@app/db/schemas/app-connections"; +import { + getOCIConnectionListItem, + OCIConnectionMethod, + validateOCIConnectionCredentials +} from "@app/ee/services/app-connections/oci"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { generateHash } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { APP_CONNECTION_NAME_MAP, APP_CONNECTION_PLAN_MAP } from "@app/services/app-connection/app-connection-maps"; import { transferSqlConnectionCredentialsToPlatform, validateSqlConnectionCredentials } from "@app/services/app-connection/shared/sql"; import { KmsDataKey } from "@app/services/kms/kms-types"; -import { AppConnection } from "./app-connection-enums"; +import { + getOnePassConnectionListItem, + OnePassConnectionMethod, + validateOnePassConnectionCredentials +} from "./1password"; +import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; import { TAppConnectionServiceFactoryDep } from "./app-connection-service"; import { TAppConnection, @@ -53,7 +64,6 @@ import { } from "./humanitec"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; -import { getOCIConnectionListItem, OCIConnectionMethod, validateOCIConnectionCredentials } from "./oci"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getTeamCityConnectionListItem, @@ -93,7 +103,8 @@ export const listAppConnectionOptions = () => { getHCVaultConnectionListItem(), getLdapConnectionListItem(), getTeamCityConnectionListItem(), - getOCIConnectionListItem() + getOCIConnectionListItem(), + getOnePassConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -163,7 +174,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); @@ -192,6 +204,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: @@ -255,5 +268,21 @@ 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 ( + licenseService: Pick, + appConnection: AppConnection, + orgId: string, + errorMessage: string +) => { + if (APP_CONNECTION_PLAN_MAP[appConnection] === AppConnectionPlanType.Enterprise) { + const plan = await licenseService.getPlan(orgId); + if (!plan.enterpriseAppConnections) + throw new BadRequestError({ + message: errorMessage + }); + } }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index c32336453c..ddd0b1087a 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -1,4 +1,4 @@ -import { AppConnection } from "./app-connection-enums"; +import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AWS]: "AWS", @@ -19,5 +19,29 @@ 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 = { + [AppConnection.AWS]: AppConnectionPlanType.Regular, + [AppConnection.GitHub]: AppConnectionPlanType.Regular, + [AppConnection.GCP]: AppConnectionPlanType.Regular, + [AppConnection.AzureKeyVault]: AppConnectionPlanType.Regular, + [AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular, + [AppConnection.AzureClientSecrets]: AppConnectionPlanType.Regular, + [AppConnection.Databricks]: AppConnectionPlanType.Regular, + [AppConnection.Humanitec]: AppConnectionPlanType.Regular, + [AppConnection.TerraformCloud]: AppConnectionPlanType.Regular, + [AppConnection.Vercel]: AppConnectionPlanType.Regular, + [AppConnection.Postgres]: AppConnectionPlanType.Regular, + [AppConnection.MsSql]: AppConnectionPlanType.Regular, + [AppConnection.Camunda]: AppConnectionPlanType.Regular, + [AppConnection.Windmill]: AppConnectionPlanType.Regular, + [AppConnection.Auth0]: AppConnectionPlanType.Regular, + [AppConnection.HCVault]: AppConnectionPlanType.Regular, + [AppConnection.LDAP]: AppConnectionPlanType.Regular, + [AppConnection.TeamCity]: AppConnectionPlanType.Regular, + [AppConnection.OCI]: AppConnectionPlanType.Enterprise, + [AppConnection.OnePass]: AppConnectionPlanType.Regular }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 85b63138a5..7d7508fc27 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -1,5 +1,8 @@ import { ForbiddenError, subject } from "@casl/ability"; +import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci"; +import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { generateHash } from "@app/lib/crypto/encryption"; @@ -9,6 +12,7 @@ import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; import { decryptAppConnection, encryptAppConnectionCredentials, + enterpriseAppCheck, getAppConnectionMethodName, listAppConnectionOptions, TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM, @@ -17,6 +21,8 @@ import { import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-connection-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { ValidateOnePassConnectionCredentialsSchema } from "./1password"; +import { onePassConnectionService } from "./1password/1password-connection-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; import { AppConnection } from "./app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "./app-connection-maps"; @@ -49,8 +55,6 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateLdapConnectionCredentialsSchema } from "./ldap"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; -import { ValidateOCIConnectionCredentialsSchema } from "./oci"; -import { ociConnectionService } from "./oci/oci-connection-service"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; @@ -65,6 +69,7 @@ export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; permissionService: Pick; kmsService: Pick; + licenseService: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -88,13 +93,15 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -191,6 +198,13 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); + await enterpriseAppCheck( + licenseService, + app, + actor.orgId, + "Failed to create app connection due to plan restriction. Upgrade plan to access enterprise app connections." + ); + const validatedCredentials = await validateAppConnectionCredentials({ app, credentials, @@ -253,6 +267,13 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + await enterpriseAppCheck( + licenseService, + appConnection.app as AppConnection, + actor.orgId, + "Failed to update app connection due to plan restriction. Upgrade plan to access enterprise app connections." + ); + const { permission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -399,6 +420,13 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + await enterpriseAppCheck( + licenseService, + app, + actor.orgId, + "Failed to connect app due to plan restriction. Upgrade plan to access enterprise app connections." + ); + const { permission: orgPermission } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -468,6 +496,7 @@ export const appConnectionServiceFactory = ({ hcvault: hcVaultConnectionService(connectAppConnectionById), windmill: windmillConnectionService(connectAppConnectionById), teamcity: teamcityConnectionService(connectAppConnectionById), - oci: ociConnectionService(connectAppConnectionById) + oci: ociConnectionService(connectAppConnectionById, licenseService), + onepass: onePassConnectionService(connectAppConnectionById) }; }; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 42ccfc84e5..459096ae5e 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -1,7 +1,19 @@ +import { + TOCIConnection, + TOCIConnectionConfig, + TOCIConnectionInput, + TValidateOCIConnectionCredentialsSchema +} from "@app/ee/services/app-connections/oci"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + TOnePassConnection, + TOnePassConnectionConfig, + TOnePassConnectionInput, + TValidateOnePassConnectionCredentialsSchema +} from "./1password"; import { AWSRegion } from "./app-connection-enums"; import { TAuth0Connection, @@ -76,12 +88,6 @@ import { TValidateLdapConnectionCredentialsSchema } from "./ldap"; import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; -import { - TOCIConnection, - TOCIConnectionConfig, - TOCIConnectionInput, - TValidateOCIConnectionCredentialsSchema -} from "./oci"; import { TPostgresConnection, TPostgresConnectionInput, @@ -132,6 +138,7 @@ export type TAppConnection = { id: string } & ( | TLdapConnection | TTeamCityConnection | TOCIConnection + | TOnePassConnection ); export type TAppConnectionRaw = NonNullable>>; @@ -158,6 +165,7 @@ export type TAppConnectionInput = { id: string } & ( | TLdapConnectionInput | TTeamCityConnectionInput | TOCIConnectionInput + | TOnePassConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; @@ -189,7 +197,8 @@ export type TAppConnectionConfig = | THCVaultConnectionConfig | TLdapConnectionConfig | TTeamCityConnectionConfig - | TOCIConnectionConfig; + | TOCIConnectionConfig + | TOnePassConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -210,7 +219,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateHCVaultConnectionCredentialsSchema | TValidateLdapConnectionCredentialsSchema | TValidateTeamCityConnectionCredentialsSchema - | TValidateOCIConnectionCredentialsSchema; + | TValidateOCIConnectionCredentialsSchema + | TValidateOnePassConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts index 91884b9143..c4c94b4fcb 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -1,8 +1,7 @@ -import RE2 from "re2"; import { z } from "zod"; import { AppConnections } from "@app/lib/api-docs"; -import { DistinguishedNameRegex } from "@app/lib/regex"; +import { DistinguishedNameRegex, LdapUrlRegex, UserPrincipalNameRegex } from "@app/lib/regex"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { BaseAppConnectionSchema, @@ -14,17 +13,14 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums"; export const LdapConnectionSimpleBindCredentialsSchema = z.object({ provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider), - url: z - .string() - .trim() - .min(1, "URL required") - .regex(new RE2(/^ldaps?:\/\//)) - .describe(AppConnections.CREDENTIALS.LDAP.url), + url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).describe(AppConnections.CREDENTIALS.LDAP.url), dn: z .string() .trim() - .regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") - .min(1, "Distinguished Name (DN) required") + .min(1, "DN/UPN required") + .refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), { + message: "Invalid DN/UPN format" + }) .describe(AppConnections.CREDENTIALS.LDAP.dn), password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password), sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized), diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index fdbd5ccd85..bee85b14c7 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({ providerAuthToken, clientPublicKey }: TLoginGenServerPublicKeyDTO) => { - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const serverCfg = await getServerCfg(); @@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({ }: TLoginClientProofDTO) => { const appCfg = getConfig(); - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; if (!userEnc) throw new Error("Failed to find user"); const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); @@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({ * OAuth2 login for google,github, and other oauth2 provider * */ const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { - let user = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserByUsername(email); + let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const serverCfg = await getServerCfg(); - if (serverCfg.enabledLoginMethods) { + if (serverCfg.enabledLoginMethods && user) { switch (authMethod) { case AuthMethod.GITHUB: { if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) { @@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({ } user = await userDAL.create({ - username: email, - email, + username: email.trim().toLowerCase(), + email: email.trim().toLowerCase(), isEmailVerified: true, firstName, lastName, @@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({ ? decodedProviderToken.orgId : undefined; - const userEnc = await userDAL.findUserEncKeyByUsername({ + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email }); - if (!userEnc) throw new BadRequestError({ message: "Invalid token" }); - if (!userEnc.serverEncryptedPrivateKey) + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + + if (!userEnc?.serverEncryptedPrivateKey) throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); const token = await generateUserTokens({ diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 14fb582585..5e2f8c7b33 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({ */ const sendPasswordResetEmail = async (email: string) => { const sendEmail = async () => { - const user = await userDAL.findUserByUsername(email); + const users = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0]; + if (!user) throw new BadRequestError({ message: "Failed to find user data" }); if (user && user.isAccepted) { const cfg = getConfig(); @@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({ * */ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); - const user = await userDAL.findUserByUsername(email); + const users = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0]; + if (!user) throw new BadRequestError({ message: "Failed to find user data" }); const userEnc = await userDAL.findUserEncKeyByUserId(user.id); @@ -189,16 +195,15 @@ export const authPaswordServiceFactory = ({ throw new BadRequestError({ message: `User encryption key not found for user with ID '${userId}'` }); } - if (!user.hashedPassword) { - throw new BadRequestError({ message: "Unable to reset password, no password is set" }); - } - if (!user.authMethods?.includes(AuthMethod.EMAIL)) { throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" }); } // we check the old password if the user is resetting their password while logged in if (type === ResetPasswordV2Type.LoggedInReset) { + if (!user.hashedPassword) { + throw new BadRequestError({ message: "Unable to change password, no password is set" }); + } if (!oldPassword) { throw new BadRequestError({ message: "Current password is required." }); } diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 4d8c98205f..7e11f25cb3 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({ }: TAuthSignupDep) => { // first step of signup. create user and send email const beginEmailSignupProcess = async (email: string) => { - const isEmailInvalid = await isDisposableEmail(email); + const sanitizedEmail = email.trim().toLowerCase(); + const isEmailInvalid = await isDisposableEmail(sanitizedEmail); if (isEmailInvalid) { throw new Error("Provided a disposable email"); } - let user = await userDAL.findUserByUsername(email); + // akhilmhdh: case sensitive email resolution + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + let user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (user && user.isAccepted) { // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues - throw new Error("Failed to send verification code for complete account"); + throw new BadRequestError({ message: "Failed to send verification code for complete account" }); } if (!user) { - user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false }); + user = await userDAL.create({ + authMethods: [AuthMethod.EMAIL], + username: sanitizedEmail, + email: sanitizedEmail, + isGhost: false + }); } if (!user) throw new Error("Failed to create user"); @@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", - recipients: [user.email as string], + recipients: [sanitizedEmail], substitutions: { code: token } @@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({ }; const verifyEmailSignup = async (email: string, code: string) => { - const user = await userDAL.findUserByUsername(email); + const sanitizedEmail = email.trim().toLowerCase(); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); } + const appCfg = getConfig(); await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_CONFIRMATION, @@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({ authorization, useDefaultOrg }: TCompleteAccountSignupDTO) => { + const sanitizedEmail = email.trim().toLowerCase(); const appCfg = getConfig(); const serverCfg = await getServerCfg(); - const user = await userDAL.findOne({ username: email }); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { - throw new Error("Failed to complete account for complete user"); + throw new BadRequestError({ message: "Failed to complete account for complete user" }); } let organizationId: string | null = null; @@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, { userId: user.id, status: OrgMembershipStatus.Accepted } ); const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; @@ -382,9 +398,9 @@ export const authSignupServiceFactory = ({ * User signup flow when they are invited to join the org * */ const completeAccountInvite = async ({ + email, ip, salt, - email, password, verifier, firstName, @@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({ encryptedPrivateKeyTag, authorization }: TCompleteAccountInviteDTO) => { - const user = await userDAL.findUserByUsername(email); + const sanitizedEmail = email.trim().toLowerCase(); + const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0]; if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } @@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({ validateSignUpAuthorization(authorization, user.id); const [orgMembership] = await orgDAL.findMembership({ - inviteEmail: email, + inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }); if (!orgMembership) @@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({ const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { ...systemGeneratedUserEncryptionKey }); - const encKeys = await generateUserSrpKeys(email, password, { + const encKeys = await generateUserSrpKeys(sanitizedEmail, password, { publicKey: systemGeneratedUserEncryptionKey.publicKey, privateKey: serverGeneratedPrivateKey }); @@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, { userId: us.id, status: OrgMembershipStatus.Accepted }, tx ); diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 292b5f1098..be3f8677ec 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -8,6 +8,7 @@ import { ProjectPermissionCertificateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; @@ -29,7 +30,6 @@ import { TGetCertPrivateKeyDTO, TRevokeCertDTO } from "./certificate-types"; -import { NotFoundError } from "@app/lib/errors"; type TCertificateServiceFactoryDep = { certificateDAL: Pick; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 54b0e1b0f2..32cabf444c 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -206,7 +206,7 @@ export const orgDALFactory = (db: TDbClient) => { .where(`${TableName.OrgMembership}.orgId`, orgId) .count("*") .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) - .where({ isGhost: false }) + .where({ isGhost: false, [`${TableName.OrgMembership}.isActive` as "isActive"]: true }) .first(); return parseInt((count as unknown as CountResult).count || "0", 10); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index c966d5ef9e..bfd24e639e 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -827,7 +827,11 @@ export const orgServiceFactory = ({ const users: Pick[] = []; for await (const inviteeEmail of inviteeEmails) { - let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; // if the user doesn't exist we create the user with the email if (!inviteeUser) { @@ -1239,10 +1243,13 @@ export const orgServiceFactory = ({ * magic link and issue a temporary signup token for user to complete setting up their account */ const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { - const user = await userDAL.findUserByUsername(email); + const usersByUsername = await userDAL.findUserByUsername(email); + const user = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; if (!user) { throw new NotFoundError({ message: "User not found" }); } + const [orgMembership] = await orgDAL.findMembership({ [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 4796bcdcaa..8f4298450f 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -433,6 +433,21 @@ export const projectDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Project)) .first(); return project; + } + + const countOfOrgProjects = async (orgId: string | null, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.Project) + .andWhere((bd) => { + if (orgId) { + void bd.where({ orgId }); + } + }) + .count(); + return Number(doc?.[0]?.count ?? 0); + } catch (error) { + throw new DatabaseError({ error, name: "Count of Org Projects" }); + } }; return { @@ -448,6 +463,7 @@ export const projectDALFactory = (db: TDbClient) => { checkProjectUpgradeStatus, getProjectFromSplitId, searchProjects, - findProjectByEnvId + findProjectByEnvId, + countOfOrgProjects }; }; 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 0000000000..01226a026c --- /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 0000000000..c832fbbdb4 --- /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 0000000000..2f77a1dadf --- /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 0000000000..af4db7369c --- /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 0000000000..db098b2992 --- /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 a0982c5b65..24f7d05f87 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 { @@ -26,3 +27,8 @@ export enum SecretSyncImportBehavior { PrioritizeSource = "prioritize-source", PrioritizeDestination = "prioritize-destination" } + +export enum SecretSyncPlanType { + Enterprise = "enterprise", + Regular = "regular" +} diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 1bb4da9db9..dbf3a3699f 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,6 +1,9 @@ import { AxiosError } from "axios"; import RE2 from "re2"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; +import { BadRequestError } from "@app/lib/errors"; import { AWS_PARAMETER_STORE_SYNC_LIST_OPTION, AwsParameterStoreSyncFns @@ -11,7 +14,7 @@ import { } from "@app/services/secret-sync/aws-secrets-manager"; import { DATABRICKS_SYNC_LIST_OPTION, databricksSyncFactory } from "@app/services/secret-sync/databricks"; import { GITHUB_SYNC_LIST_OPTION, GithubSyncFns } from "@app/services/secret-sync/github"; -import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { TSecretMap, @@ -21,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"; @@ -29,7 +33,7 @@ import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; -import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "./oci-vault"; +import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; @@ -50,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 = () => { @@ -171,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}` @@ -239,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}` @@ -297,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}` @@ -327,3 +339,18 @@ export const parseSyncErrorMessage = (err: unknown): string => { ? errorMessage : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; }; + +export const enterpriseSyncCheck = async ( + licenseService: Pick, + secretSync: SecretSync, + orgId: string, + errorMessage: string +) => { + if (SECRET_SYNC_PLAN_MAP[secretSync] === SecretSyncPlanType.Enterprise) { + const plan = await licenseService.getPlan(orgId); + if (!plan.enterpriseSecretSyncs) + throw new BadRequestError({ + message: errorMessage + }); + } +}; diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 21cb912b4c..832c15bf87 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -1,5 +1,5 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AWSParameterStore]: "AWS Parameter Store", @@ -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,5 +35,25 @@ 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 = { + [SecretSync.AWSParameterStore]: SecretSyncPlanType.Regular, + [SecretSync.AWSSecretsManager]: SecretSyncPlanType.Regular, + [SecretSync.GitHub]: SecretSyncPlanType.Regular, + [SecretSync.GCPSecretManager]: SecretSyncPlanType.Regular, + [SecretSync.AzureKeyVault]: SecretSyncPlanType.Regular, + [SecretSync.AzureAppConfiguration]: SecretSyncPlanType.Regular, + [SecretSync.Databricks]: SecretSyncPlanType.Regular, + [SecretSync.Humanitec]: SecretSyncPlanType.Regular, + [SecretSync.TerraformCloud]: SecretSyncPlanType.Regular, + [SecretSync.Camunda]: SecretSyncPlanType.Regular, + [SecretSync.Vercel]: SecretSyncPlanType.Regular, + [SecretSync.Windmill]: SecretSyncPlanType.Regular, + [SecretSync.HCVault]: SecretSyncPlanType.Regular, + [SecretSync.TeamCity]: SecretSyncPlanType.Regular, + [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, + [SecretSync.OnePass]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 9951a79aa8..66e83661f4 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -5,6 +5,7 @@ import { Job } from "bullmq"; import { ProjectMembershipRole, SecretType } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; @@ -32,7 +33,7 @@ import { SecretSyncInitialSyncBehavior } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; -import { parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns"; +import { enterpriseSyncCheck, parseSyncErrorMessage, SecretSyncFns } from "@app/services/secret-sync/secret-sync-fns"; import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { SecretSyncAction, @@ -95,6 +96,7 @@ type TSecretSyncQueueFactoryDep = { secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; folderCommitService: Pick; + licenseService: Pick; }; type SecretSyncActionJob = Job< @@ -136,7 +138,8 @@ export const secretSyncQueueFactory = ({ secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, resourceMetadataDAL, - folderCommitService + folderCommitService, + licenseService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -328,7 +331,20 @@ export const secretSyncQueueFactory = ({ secretSync: TSecretSyncWithCredentials, importBehavior: SecretSyncImportBehavior ): Promise => { - const { projectId, environment, folder } = secretSync; + const { + projectId, + environment, + folder, + destination, + connection: { orgId } + } = secretSync; + + await enterpriseSyncCheck( + licenseService, + destination, + orgId, + "Failed to import secrets due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); if (!environment || !folder) throw new Error( @@ -405,6 +421,13 @@ export const secretSyncQueueFactory = ({ if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + secretSync.connection.orgId, + "Failed to sync secrets due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + await secretSyncDAL.updateById(syncId, { syncStatus: SecretSyncStatus.Running }); @@ -664,6 +687,13 @@ export const secretSyncQueueFactory = ({ if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + secretSync.connection.orgId, + "Failed to remove secrets due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + await secretSyncDAL.updateById(syncId, { removeStatus: SecretSyncStatus.Running }); diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index db350f7850..e7751d3f97 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { @@ -16,7 +17,7 @@ import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-c import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; -import { listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; +import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncStatus, TCreateSecretSyncDTO, @@ -49,6 +50,7 @@ type TSecretSyncServiceFactoryDep = { TSecretSyncQueueFactory, "queueSecretSyncSyncSecretsById" | "queueSecretSyncImportSecretsById" | "queueSecretSyncRemoveSecretsById" >; + licenseService: Pick; }; export type TSecretSyncServiceFactory = ReturnType; @@ -61,7 +63,8 @@ export const secretSyncServiceFactory = ({ appConnectionService, projectBotService, secretSyncQueue, - keyStore + keyStore, + licenseService }: TSecretSyncServiceFactoryDep) => { const listSecretSyncsByProjectId = async ( { projectId, destination }: TListSecretSyncsByProjectId, @@ -191,6 +194,13 @@ export const secretSyncServiceFactory = ({ { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, actor: OrgServiceActor ) => { + await enterpriseSyncCheck( + licenseService, + params.destination, + actor.orgId, + "Failed to create secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission: projectPermission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -260,6 +270,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID ${syncId}` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to update secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -408,6 +425,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to trigger secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -463,6 +487,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to trigger secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -512,6 +543,13 @@ export const secretSyncServiceFactory = ({ message: `Could not find ${SECRET_SYNC_NAME_MAP[destination]} Sync with ID "${syncId}"` }); + await enterpriseSyncCheck( + licenseService, + secretSync.destination as SecretSync, + actor.orgId, + "Failed to trigger secret sync due to plan restriction. Upgrade plan to access enterprise secret syncs." + ); + const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 64d027e189..22f7848ad3 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -1,6 +1,12 @@ import { Job } from "bullmq"; import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { + TOCIVaultSync, + TOCIVaultSyncInput, + TOCIVaultSyncListItem, + TOCIVaultSyncWithCredentials +} from "@app/ee/services/secret-sync/oci-vault"; import { QueueJobs } from "@app/queue"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { @@ -36,6 +42,12 @@ import { TWindmillSyncWithCredentials } from "@app/services/secret-sync/windmill"; +import { + TOnePassSync, + TOnePassSyncInput, + TOnePassSyncListItem, + TOnePassSyncWithCredentials +} from "./1password/1password-sync-types"; import { TAwsParameterStoreSync, TAwsParameterStoreSyncInput, @@ -67,7 +79,6 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; -import { TOCIVaultSync, TOCIVaultSyncInput, TOCIVaultSyncListItem, TOCIVaultSyncWithCredentials } from "./oci-vault"; import { TTeamCitySync, TTeamCitySyncInput, @@ -97,7 +108,8 @@ export type TSecretSync = | TWindmillSync | THCVaultSync | TTeamCitySync - | TOCIVaultSync; + | TOCIVaultSync + | TOnePassSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -114,7 +126,8 @@ export type TSecretSyncWithCredentials = | TWindmillSyncWithCredentials | THCVaultSyncWithCredentials | TTeamCitySyncWithCredentials - | TOCIVaultSyncWithCredentials; + | TOCIVaultSyncWithCredentials + | TOnePassSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -131,7 +144,8 @@ export type TSecretSyncInput = | TWindmillSyncInput | THCVaultSyncInput | TTeamCitySyncInput - | TOCIVaultSyncInput; + | TOCIVaultSyncInput + | TOnePassSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -148,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 bad823bd34..a07110aa3c 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/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 22fc51d1a1..04dfa253bb 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -257,8 +257,8 @@ export const superAdminServiceFactory = ({ const adminSignUp = async ({ lastName, firstName, - salt, email, + salt, password, verifier, publicKey, @@ -272,7 +272,8 @@ export const superAdminServiceFactory = ({ userAgent }: TAdminSignUpDTO) => { const appCfg = getConfig(); - const existingUser = await userDAL.findOne({ email }); + const sanitizedEmail = email.trim().toLowerCase(); + const existingUser = await userDAL.findOne({ username: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); const privateKey = await getUserPrivateKey(password, { @@ -292,8 +293,8 @@ export const superAdminServiceFactory = ({ { firstName, lastName, - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -348,12 +349,13 @@ export const superAdminServiceFactory = ({ const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => { const appCfg = getConfig(); + const sanitizedEmail = email.trim().toLowerCase(); const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (serverCfg?.initialized) { throw new BadRequestError({ message: "Instance has already been set up" }); } - const existingUser = await userDAL.findOne({ email }); + const existingUser = await userDAL.findOne({ email: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" }); const userInfo = await userDAL.transaction(async (tx) => { @@ -361,8 +363,8 @@ export const superAdminServiceFactory = ({ { firstName: "Admin", lastName: "User", - username: email, - email, + username: sanitizedEmail, + email: sanitizedEmail, superAdmin: true, isGhost: false, isAccepted: true, @@ -372,7 +374,7 @@ export const superAdminServiceFactory = ({ tx ); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password); - const encKeys = await generateUserSrpKeys(email, password); + const encKeys = await generateUserSrpKeys(sanitizedEmail, password); const userEnc = await userDAL.createUserEncryption( { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index eba497f0f3..b5a29fc8cc 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -8,16 +8,18 @@ import { TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, - TUsers + TUsers, + UsersSchema } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TUserDALFactory = ReturnType; export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); - const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); + const findUserByUsername = async (username: string, tx?: Knex) => + (tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() }); const getUsersByFilter = async ({ limit, @@ -41,7 +43,7 @@ export const userDALFactory = (db: TDbClient) => { .whereILike("email", `%${searchTerm}%`) .orWhereILike("firstName", `%${searchTerm}%`) .orWhereILike("lastName", `%${searchTerm}%`) - .orWhereLike("username", `%${searchTerm}%`); + .orWhereRaw('lower("username") like ?', `%${searchTerm}%`); }); } @@ -65,12 +67,11 @@ export const userDALFactory = (db: TDbClient) => { try { return await db .replicaNode()(TableName.Users) + .whereRaw('lower("username") = :username', { username: username.toLowerCase() }) .where({ - username, isGhost: false }) - .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) - .first(); + .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`); } catch (error) { throw new DatabaseError({ error, name: "Find user enc by email" }); } @@ -168,6 +169,38 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findAllMyAccounts = async (email: string) => { + try { + const doc = await db(TableName.Users) + .where({ email }) + .leftJoin(TableName.OrgMembership, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .select(selectAllTableCols(TableName.Users)) + .select( + db.ref("name").withSchema(TableName.Organization).as("orgName"), + db.ref("slug").withSchema(TableName.Organization).as("orgSlug") + ); + const formattedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (el) => UsersSchema.parse(el), + childrenMapper: [ + { + key: "orgSlug", + label: "organizations" as const, + mapper: ({ orgSlug, orgName }) => ({ + slug: orgSlug, + name: orgName + }) + } + ] + }); + return formattedDoc; + } catch (error) { + throw new DatabaseError({ error, name: "Upsert user enc key" }); + } + }; + // USER ACTION FUNCTIONS // --------------------- const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => { @@ -200,6 +233,7 @@ export const userDALFactory = (db: TDbClient) => { createUserEncryption, findOneUserAction, createUserAction, - getUsersByFilter + getUsersByFilter, + findAllMyAccounts }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 5da5d493c8..aae32d91ff 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -5,11 +5,11 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; @@ -21,7 +21,7 @@ type TUserServiceFactoryDep = { userDAL: Pick< TUserDALFactory, | "find" - | "findOne" + | "findUserByUsername" | "findById" | "transaction" | "updateById" @@ -31,8 +31,8 @@ type TUserServiceFactoryDep = { | "createUserAction" | "findUserEncKeyByUserId" | "delete" + | "findAllMyAccounts" >; - userAliasDAL: Pick; groupProjectDAL: Pick; orgMembershipDAL: Pick; tokenService: Pick; @@ -45,7 +45,6 @@ export type TUserServiceFactory = ReturnType; export const userServiceFactory = ({ userDAL, - userAliasDAL, orgMembershipDAL, projectMembershipDAL, groupProjectDAL, @@ -54,8 +53,11 @@ export const userServiceFactory = ({ permissionService }: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (username: string) => { - const user = await userDAL.findOne({ username }); + // akhilmhdh: case sensitive email resolution + const users = await userDAL.findUserByUsername(username); + const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); if (user.isEmailVerified) @@ -77,7 +79,21 @@ export const userServiceFactory = ({ }; const verifyEmailVerificationCode = async (username: string, code: string) => { - const user = await userDAL.findOne({ username }); + // akhilmhdh: case sensitive email resolution + const usersByusername = await userDAL.findUserByUsername(username); + + logger.info( + usersByusername.map((user) => ({ + id: user.id, + email: user.email, + username: user.username, + isEmailVerified: user.isEmailVerified + })), + `Verify email users: [username=${username}]` + ); + + const user = + usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); @@ -90,84 +106,8 @@ export const userServiceFactory = ({ code }); - const { email } = user; - - await userDAL.transaction(async (tx) => { - await userDAL.updateById( - user.id, - { - isEmailVerified: true - }, - tx - ); - - // check if there are verified users with the same email. - const users = await userDAL.find( - { - email, - isEmailVerified: true - }, - { tx } - ); - - if (users.length > 1) { - // merge users - const mergeUser = users.find((u) => u.id !== user.id); - if (!mergeUser) throw new NotFoundError({ name: "Failed to find merge user" }); - - const mergeUserOrgMembershipSet = new Set( - (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) - ); - const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter( - (m) => !mergeUserOrgMembershipSet.has(m.orgId) - ); - - const userAliases = await userAliasDAL.find( - { - userId: user.id - }, - { tx } - ); - await userDAL.deleteById(user.id, tx); - - if (myOrgMemberships.length) { - await orgMembershipDAL.insertMany( - myOrgMemberships.map((orgMembership) => ({ - ...orgMembership, - userId: mergeUser.id - })), - tx - ); - } - - if (userAliases.length) { - await userAliasDAL.insertMany( - userAliases.map((userAlias) => ({ - ...userAlias, - userId: mergeUser.id - })), - tx - ); - } - } else { - await userDAL.delete( - { - email, - isAccepted: false, - isEmailVerified: false - }, - tx - ); - - // update current user's username to [email] - await userDAL.updateById( - user.id, - { - username: email - }, - tx - ); - } + await userDAL.updateById(user.id, { + isEmailVerified: true }); }; @@ -212,6 +152,23 @@ export const userServiceFactory = ({ return updatedUser; }; + const getAllMyAccounts = async (email: string, userId: string) => { + const users = await userDAL.findAllMyAccounts(email); + return users?.map((el) => ({ ...el, isMyAccount: el.id === userId })); + }; + + const removeMyDuplicateAccounts = async (email: string, userId: string) => { + const users = await userDAL.find({ email }); + const duplicatedAccounts = users?.filter((el) => el.id !== userId); + const myAccount = users?.find((el) => el.id === userId); + if (duplicatedAccounts.length && myAccount) { + await userDAL.transaction(async (tx) => { + await userDAL.delete({ $in: { id: duplicatedAccounts?.map((el) => el.id) } }, tx); + await userDAL.updateById(userId, { username: (myAccount.email || myAccount.username).toLowerCase() }, tx); + }); + } + }; + const getMe = async (userId: string) => { const user = await userDAL.findUserEncKeyByUserId(userId); if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" }); @@ -313,9 +270,11 @@ export const userServiceFactory = ({ }; const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { - const user = await userDAL.findOne({ - username - }); + // akhilmhdh: case sensitive email resolution + const usersByusername = await userDAL.findUserByUsername(username); + const user = + usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0]; + if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. if (user.id !== actorId) { @@ -346,7 +305,9 @@ export const userServiceFactory = ({ getUserAction, unlockUser, getUserPrivateKey, + getAllMyAccounts, getUserProjectFavorites, + removeMyDuplicateAccounts, updateUserProjectFavorites }; }; 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 0000000000..3797a75563 --- /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 0000000000..03562b50f9 --- /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 0000000000..24e7a2b161 --- /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 0000000000..bcab50f120 --- /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 0000000000..8cb10c351e --- /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 0000000000..4fa88de810 --- /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 0000000000..cbd52a6c6b --- /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 0000000000..b8c8a0d9d6 --- /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 0000000000..4949636bdb --- /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 0000000000..522b94499b --- /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 0000000000..9a904a6cfa --- /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 0000000000..75553aedd8 --- /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 0000000000..b7c7ad00dc --- /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 0000000000..03ce4de832 --- /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 0000000000..183cd07222 --- /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 0000000000..c7dcf5f1c0 --- /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 76cc4b74eb..58b21d4eb4 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 ffa85f6c59..610d1fd47d 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/documentation/platform/secret-rotation/ldap-password.mdx b/docs/documentation/platform/secret-rotation/ldap-password.mdx index 103fe46569..feb3a664d1 100644 --- a/docs/documentation/platform/secret-rotation/ldap-password.mdx +++ b/docs/documentation/platform/secret-rotation/ldap-password.mdx @@ -28,7 +28,7 @@ description: "Learn how to automatically rotate LDAP passwords." 3. Select the **LDAP Connection** to use and configure the rotation behavior. Then click **Next**. ![Rotation Configuration](/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png) - - **LDAP Connection** - the connection that will perform the rotation of the configured DN's password. + - **LDAP Connection** - the connection that will perform the rotation of the configured principal's password. LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol. @@ -40,13 +40,20 @@ description: "Learn how to automatically rotate LDAP passwords." - 4. Specify the Distinguished Name (DN) of the principal whose password you want to rotate and configure the password requirements. Then click **Next**. + 4. Configure the required Parameters for your rotation. Then click **Next**. ![Rotation Parameters](/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png) + - **Rotation Method** - The method to use when rotating the target principal's password. + - **Connection Principal** - Infisical will use the LDAP Connection's binding principal to rotate the target principal's password. + - **Target Principal** - Infisical will bind with the target Principal to rotate their own password. + - **DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal whose password you want to rotate. + - **Password** - The target principal's password (if **Rotation Method** is set to **Target Principal**). + - **Password Requirements** - The constraints to apply when generating new passwords. + 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. ![Rotation Secrets Mapping](/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png) - - **DN** - the name of the secret that the principal's Distinguished Name (DN) will be mapped to. + - **DN/UPN** - the name of the secret that the principal's Distinguished Name (DN) or User Principal Name (UPN) will be mapped to. - **Password** - the name of the secret that the rotated password will be mapped to. 6. Give your rotation a name and description (optional). Then click **Next**. @@ -85,6 +92,7 @@ description: "Learn how to automatically rotate LDAP passwords." "minutes": 0 }, "parameters": { + "rotationMethod": "connection-principal", "dn": "CN=John,CN=Users,DC=example,DC=com", "passwordRequirements": { "length": 48, @@ -154,6 +162,7 @@ description: "Learn how to automatically rotate LDAP passwords." "lastRotationMessage": null, "type": "ldap-password", "parameters": { + "rotationMethod": "connection-principal", "dn": "CN=John,CN=Users,DC=example,DC=com", "passwordRequirements": { "length": 48, diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index 0665a7b30b..4b54c053d7 100644 --- a/docs/documentation/platform/sso/auth0-oidc.mdx +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -14,7 +14,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." 1.1. From the Application's Page, navigate to the settings tab of the Auth0 application you want to integrate with Infisical. ![OIDC auth0 list of applications](../../../images/sso/auth0-oidc/application-settings.png) - + 1.2. In the Application URIs section, set the **Application Login URI** and **Allowed Web Origins** fields to `https://app.infisical.com` and the **Allowed Callback URL** field to `https://app.infisical.com/api/v1/sso/oidc/callback`. ![OIDC auth0 create application uris](../../../images/sso/auth0-oidc/application-uris.png) ![OIDC auth0 create application origin](../../../images/sso/auth0-oidc/application-origin.png) @@ -70,7 +70,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." prior to enforcing OIDC SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/auth0-saml.mdx b/docs/documentation/platform/sso/auth0-saml.mdx index 562360ecb0..22ef00c891 100644 --- a/docs/documentation/platform/sso/auth0-saml.mdx +++ b/docs/documentation/platform/sso/auth0-saml.mdx @@ -23,30 +23,30 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." 2.1. In your Auth0 account, head to Applications and create an application. - + ![Auth0 SAML app creation](../../../images/sso/auth0-saml/create-application.png) - + Select **Regular Web Application** and press **Create**. - + ![Auth0 SAML app creation](../../../images/sso/auth0-saml/create-application-2.png) - + 2.2. In the Application head to Settings > Application URIs and add the **Application Callback URL** from step 1 into the **Allowed Callback URLs** field. - + ![Auth0 SAML allowed callback URLs](../../../images/sso/auth0-saml/auth0-config.png) - + 2.3. In the Application head to Addons > SAML2 Web App and copy the **Issuer**, **Identity Provider Login URL**, and **Identity Provider Certificate** from the **Usage** tab. - + ![Auth0 SAML config](../../../images/sso/auth0-saml/auth0-config-2.png) - + 2.4. Back in Infisical, set **Issuer**, **Identity Provider Login URL**, and **Certificate** to the corresponding items from step 2.3. - + ![Auth0 SAML Infisical config](../../../images/sso/auth0-saml/infisical-config.png) - + 2.5. Back in Auth0, in the **Settings** tab, set the **Application Callback URL** to the **Application Callback URL** from step 1 and update the **Settings** field with the JSON under the picture below (replacing `` with the **Audience** from step 1). - + ![Auth0 SAML config](../../../images/sso/auth0-saml/auth0-config-3.png) - + ```json { "audience": "", @@ -76,7 +76,7 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. @@ -96,4 +96,4 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." 32`.
- `SITE_URL`: The absolute URL of your self-hosted instance of Infisical including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 137dc65647..0957dc4d1e 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -5,7 +5,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." Azure SAML SSO is a paid feature. - + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. @@ -26,7 +26,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." ![Azure SAML enterprise applications](../../../images/sso/azure/enterprise-applications.png) ![Azure SAML new application](../../../images/sso/azure/new-application.png) - + On the next screen, press the **+ Create your own application** button. Give the application a unique name like Infisical; choose the "Integrate any other application you don't find in the gallery (Non-gallery)" option and hit the **Create** button. @@ -89,9 +89,9 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." Back in Azure, navigate to the **Users and groups** tab and select **+ Add user/group** to assign access to the login with SSO application on a user or group-level. - + ![Azure SAML assignment](../../../images/sso/azure/assignment.png) - + Enabling SAML SSO allows members in your organization to log into Infisical via Azure. @@ -109,7 +109,7 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/general-oidc/overview.mdx b/docs/documentation/platform/sso/general-oidc/overview.mdx index 76ac982f86..07ddaaedd5 100644 --- a/docs/documentation/platform/sso/general-oidc/overview.mdx +++ b/docs/documentation/platform/sso/general-oidc/overview.mdx @@ -70,7 +70,7 @@ Prerequisites: We recommend ensuring that your account is provisioned using the identity provider prior to enforcing OIDC SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 99223c8150..84888b2f99 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -24,21 +24,21 @@ description: "Learn how to configure Google SAML for Infisical SSO." 2.1. In your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps and create a **custom SAML app**. - + ![Google SAML app creation](../../../images/sso/google-saml/create-custom-saml-app.png) - + 2.2. In the **App details** tab, give the application a unique name like Infisical. - + ![Google SAML app naming](../../../images/sso/google-saml/name-custom-saml-app.png) - + 2.3. In the **Google Identity Provider details** tab, copy the **SSO URL**, **Entity ID** and **Certificate**. - + ![Google SAML custom app details](../../../images/sso/google-saml/custom-saml-app-config.png) - + 2.4. Back in Infisical, set **SSO URL** and **Certificate** to the corresponding items from step 2.3. - + ![Google SAML Infisical config](../../../images/sso/google-saml/infisical-config.png) - + 2.5. Back in the Google Admin console, in the **Service provider details** tab, set the **ACS URL** and **Entity ID** to the corresponding items from step 1. Also, check the **Signed response** checkbox. @@ -84,7 +84,7 @@ description: "Learn how to configure Google SAML for Infisical SSO." prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index 0898c0715b..3cad22247b 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -5,7 +5,7 @@ description: "Learn how to configure JumpCloud SAML for Infisical SSO." JumpCloud SAML SSO is a paid feature. - + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. @@ -83,13 +83,12 @@ description: "Learn how to configure JumpCloud SAML for Infisical SSO." To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one JumpCloud user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - + - We recommend ensuring that your account is provisioned the application in JumpCloud - prior to enforcing SAML SSO to prevent any unintended issues. + We recommend ensuring that your account is provisioned in the application in JumpCloud prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 06d8dfa438..2c75fc6fe3 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -97,7 +97,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." prior to enforcing OIDC SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. diff --git a/docs/documentation/platform/sso/keycloak-saml.mdx b/docs/documentation/platform/sso/keycloak-saml.mdx index ba6aa0c3ac..daca360b43 100644 --- a/docs/documentation/platform/sso/keycloak-saml.mdx +++ b/docs/documentation/platform/sso/keycloak-saml.mdx @@ -5,7 +5,7 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." Keycloak SAML SSO is a paid feature. - + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. @@ -13,36 +13,36 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." In Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **SAML** under the Connect to an Identity Provider section. Select **Keycloak**, then click **Connect** again. - + ![SSO connect section](../../../images/sso/connect-saml.png) - + Next, copy the **Valid redirect URI** and **SP Entity ID** to use when configuring the Keycloak SAML application. - + ![Keycloak SAML initial configuration](../../../images/sso/keycloak/init-config.png) 2.1. In your realm, navigate to the **Clients** tab and click **Create client** to create a new client application. - + ![SAML keycloak list of clients](../../../images/sso/keycloak/clients-list.png) - + You don’t typically need to make a realm dedicated to Infisical. We recommend adding Infisical as a client to your primary realm. - + In the General Settings step, set **Client type** to **SAML**, the **Client ID** field to `https://app.infisical.com`, and the **Name** field to a friendly name like **Infisical**. - + ![SAML keycloak create client general settings](../../../images/sso/keycloak/create-client-general-settings.png) - + If you’re self-hosting Infisical, then you will want to replace https://app.infisical.com with your own domain. - + Next, in the Login Settings step, set both the **Home URL** field and **Valid redirect URIs** field to the **Valid redirect URI** from step 1 and press **Save**. - + ![SAML keycloak create client login settings](../../../images/sso/keycloak/create-client-login-settings.png) - + 2.2. Once you've created the client, under its **Settings** tab, make sure to set the following values: - + - Under **SAML Capabilities**: - Name ID format: email (or username). - Force name ID format: On. @@ -54,59 +54,59 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." - Signature algorithm: RSA_SHA256. ![SAML keycloak client SAML capabilities](../../../images/sso/keycloak/client-saml-capabilities.png) - + ![SAML keycloak client signature encryption](../../../images/sso/keycloak/client-signature-encryption.png) - + 2.3. Next, navigate to the **Client scopes** tab select the client's dedicated scope. - + ![SAML keycloak client scopes list](../../../images/sso/keycloak/client-scopes-list.png) - + Next click **Add predefined mapper**. - + ![SAML keycloak client mappers empty](../../../images/sso/keycloak/client-mappers-empty.png) - + Select the **X500 email**, **X500 givenName**, and **X500 surname** attributes and click **Add**. - + ![SAML keycloak client mappers predefined](../../../images/sso/keycloak/client-mappers-predefined.png) - - Now click on the **X500 email** mapper and set the **SAML Attribute Name** field to **email**. + + Now click on the **X500 email** mapper and set the **SAML Attribute Name** field to **email**. ![SAML keycloak client mappers email](../../../images/sso/keycloak/client-mappers-email.png) - + Repeat the same for **X500 givenName** and **X500 surname** mappers, setting the **SAML Attribute Name** field to **firstName** and **lastName** respectively. - + Next, back in the client scope's **Mappers**, click **Add mapper** and select **by configuration**. - + ![SAML keycloak client mappers by configuration](../../../images/sso/keycloak/client-mappers-by-configuration.png) - + Select **User Property**. - + ![SAML keycloak client mappers user property](../../../images/sso/keycloak/client-mappers-user-property.png) - Set the the **Name** field to **Username**, the **Property** field to **username**, and the **SAML Attribtue Name** to **username**. - + Set the the **Name** field to **Username**, the **Property** field to **username**, and the **SAML Attribute Name** to **username**. + ![SAML keycloak client mappers username](../../../images/sso/keycloak/client-mappers-username.png) - + Repeat the same for the `id` attribute, setting the **Name** field to **ID**, the **Property** field to **id**, and the **SAML Attribute Name** to **id**. - + ![SAML keycloak client mappers id](../../../images/sso/keycloak/client-mappers-id.png) - + Once you've completed the above steps, the list of mappers should look like this: - + ![SAML keycloak client mappers completed](../../../images/sso/keycloak/client-mappers-completed.png) Back in Keycloak, navigate to Configure > Realm settings > General tab > Endpoints > SAML 2.0 Identity Provider Metadata and copy the IDP URL. This should appear in various places and take the form: `https://keycloak-mysite.com/realms/myrealm/protocol/saml`. - + ![SAML keycloak realm SAML metadata](../../../images/sso/keycloak/realm-saml-metadata.png) - + Also, in the **Keys** tab, locate the RS256 key and copy the certificate to use when finishing configuring Keycloak SAML in Infisical. - + ![SAML keycloak realm settings keys](../../../images/sso/keycloak/realm-settings-keys.png) Back in Infisical, set **IDP URL** and **Certificate** to the items from step 3. Also, set the **Client ID** to the `https://app.infisical.com`. - + Once you've done that, press **Update** to complete the required configuration. ![SAML Okta paste values into Infisical](../../../images/sso/keycloak/idp-values.png) @@ -119,7 +119,7 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." Enforcing SAML SSO ensures that members in your organization can only access Infisical by logging into the organization via Keycloak. - + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Keycloak user with Infisical; Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. @@ -128,7 +128,7 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." prior to enforcing SAML SSO to prevent any unintended issues. - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. @@ -147,4 +147,4 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO." 32`.
- `SITE_URL`: The absolute URL of your self-hosted instance of Infisical including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 2af689e4c4..ecdf6ca397 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -93,13 +93,12 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. - We recommend ensuring that your account is provisioned the application in Okta - prior to enforcing SAML SSO to prevent any unintended issues. + We recommend ensuring that your account is provisioned for the application in Okta prior to enforcing SAML SSO to prevent any unintended issues. - - In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin. - + + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index e5d5e5c167..66243f7d80 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -39,18 +39,30 @@ If your required identity provider is not shown in the list above, please reach For enhanced security, Infisical enforces PKCE (Proof Key for Code Exchange) with the OAuth 2.0-based SSO providers and OIDC. This provides additional protection against authorization code interception attacks and strengthens your authentication flow security. +## SSO Break Glass + +In the event your SSO provider experiences downtime, and you need to access Infisical, Organization Admins can utilize the Admin Login Portal to bypass SSO enforcement. + +This portal is accessible at `/login/admin` (e.g., https://app.infisical.com/login/admin). + + + To bypass SSO for an organization, you must be an **Organization Admin** for that specific organization. This **Organization Admin** role is independent of **Server Admin** status. Being a **Server Admin** alone does not grant permission to use this bypass feature. + + ## FAQ - - By default, Infisical Cloud is configured to not trust emails from external - identity providers to prevent any malicious account takeover attempts via - email spoofing. Accordingly, Infisical creates a new user for anyone provisioned - through an external identity provider and requires an additional email - verification step upon their first login. + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. - If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, - you can configure this behavior in the Server Admin Console. - - + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the Server Admin Console. + + + You are likely being redirected because you do not have email authentication mode enabled, or you're not an **Organization Admin**. This portal requires **Organization Admin** status and direct credential login (email and password). **Server Admin** status alone is insufficient. + 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 0000000000..adfd1b2607 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 0000000000..cf828de3ca 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 0000000000..bd07c0a802 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 0000000000..f3720c2d40 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 0000000000..89015d499e 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 0000000000..cf29ea2e47 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 0000000000..7df91dfcf4 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 0000000000..c0730d5c3e 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 afe945b0a5..fabcb67169 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-rotations-v2/ldap-password/ldap-password-confirm.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png index 1725c43556..7077360955 100644 Binary files a/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png index dfe723b062..8dbfb8ddf0 100644 Binary files a/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.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 0000000000..af51914863 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 0000000000..69ce333e36 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 0000000000..ee08db72b9 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 0000000000..f0b3488e26 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 0000000000..5663e7da29 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 0000000000..a19b8189d3 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 0000000000..fbe8c90d6e 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 0000000000..0c3926a1b4 --- /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/app-connections/ldap.mdx b/docs/integrations/app-connections/ldap.mdx index 63c4bfed1f..db0b596ceb 100644 --- a/docs/integrations/app-connections/ldap.mdx +++ b/docs/integrations/app-connections/ldap.mdx @@ -10,7 +10,7 @@ Infisical supports the use of [Simple Binding](https://ldap.com/the-ldap-bind-op You will need the following information to establish an LDAP connection: - **LDAP URL** - The LDAP/LDAPS URL to connect to (e.g., ldap://domain-or-ip:389 or ldaps://domain-or-ip:636) -- **Binding DN** - The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com') +- **Binding DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com') - **Binding Password** - The password to bind with for authentication - **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate diff --git a/docs/integrations/app-connections/oci.mdx b/docs/integrations/app-connections/oci.mdx index ff51ce1d92..58fb3c1d31 100644 --- a/docs/integrations/app-connections/oci.mdx +++ b/docs/integrations/app-connections/oci.mdx @@ -3,6 +3,13 @@ title: "OCI Connection" description: "Learn how to configure an Oracle Cloud Infrastructure Connection for Infisical." --- + + OCI App Connection is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + Infisical supports the use of [API Signing Key Authentication](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm) to connect with OCI. ## Create OCI User diff --git a/docs/integrations/secret-syncs/1password.mdx b/docs/integrations/secret-syncs/1password.mdx new file mode 100644 index 0000000000..a33f54c8dd --- /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/integrations/secret-syncs/oci-vault.mdx b/docs/integrations/secret-syncs/oci-vault.mdx index 67a3426aa1..00b7120e76 100644 --- a/docs/integrations/secret-syncs/oci-vault.mdx +++ b/docs/integrations/secret-syncs/oci-vault.mdx @@ -3,6 +3,13 @@ title: "OCI Vault Sync" description: "Learn how to configure an Oracle Cloud Infrastructure Vault Sync for Infisical." --- + + OCI Vault Sync is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + **Prerequisites:** - Create an [OCI Connection](/integrations/app-connections/oci) with the required **Secret Sync** permissions - [Create](https://docs.oracle.com/en-us/iaas/Content/Identity/compartments/To_create_a_compartment.htm) or use an existing OCI Compartment (which the OCI Connection is authorized to access) diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx index e45de05bff..2dc4cd662a 100644 --- a/docs/internals/bug-bounty.mdx +++ b/docs/internals/bug-bounty.mdx @@ -10,8 +10,10 @@ We value reports that help identify vulnerabilities that affect the integrity of ### How to Report - Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. -- We will acknowledge receipt within 3 business days. +- We will acknowledge receipt within 3 business days for reports that are clearly written, technically sound, and plausibly within scope. - We'll provide an initial assessment or next steps within 5 business days. +- **Please note**: We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope. + ### What's in Scope? diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 0f55026888..96604f717b 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -185,12 +185,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 | +| 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 a81c9db67a..bf7e8533a9 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/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index b63c58d3af..8da732d6db 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -32,7 +32,7 @@ Used to configure platform-specific security and operational settings Specifies the network interface Infisical will bind to when accepting incoming connections. - By default, Infisical binds to `localhost`, which restricts access to connections from the same machine. + By default, Infisical binds to `localhost`, which restricts access to connections from the same machine. To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces. @@ -95,9 +95,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching - Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. - Use the command below to encode your certificate: - `echo "" | base64` + Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. + Use the following command to encode your certificate: `echo "" | base64` @@ -111,10 +110,9 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] Configure the SSL certificate for securing a Postgres replica connection by first encoding it in base64. - Use the command below to encode your certificate: - `echo "" | base64` + Use the following command to encode your certificate: `echo "" | base64` - If not provided it will use master SSL certificate. + If not provided it will use master SSL certificate. @@ -169,6 +167,16 @@ Without email configuration, Infisical's core functions like sign-up/login and s If this is `true`, Infisical will validate the server's SSL/TLS certificate and reject the connection if the certificate is invalid or not trusted. If set to `false`, the client will accept the server's certificate regardless of its validity, which can be useful in development or testing environments but is not recommended for production use. + + + If your SMTP server uses a certificate signed by a custom Certificate Authority, you should set this variable so that Infisical can trust the custom CA. + + This variable **must be a base64 encoded PEM certificate**. Use the following command to encode your certificate: `echo "" | base64` + + Infisical highly encourages the following variables be used alongside this one for maximum security: + - `SMTP_REQUIRE_TLS=true` + - `SMTP_TLS_REJECT_UNAUTHORIZED=true` + @@ -222,7 +230,7 @@ SMTP_FROM_NAME=Infisical This will be used to verify the email you are sending from. ![Create SES identity](../../images/self-hosting/configuration/email/ses-create-identity.png) - If you AWS SES is under sandbox mode, you will only be able to send emails to verified identies. + If you AWS SES is under sandbox mode, you will only be able to send emails to verified identies. @@ -388,9 +396,9 @@ SMTP_FROM_NAME=Infisical - + 1. Create an account and configure [SMTP2Go](https://www.smtp2go.com/) to send emails. -2. Turn on SMTP authentication +2. Turn on SMTP authentication ``` SMTP_HOST=mail.smtp2go.com SMTP_PORT=You can use one of the following ports: 2525, 80, 25, 8025, or 587 @@ -401,7 +409,7 @@ SMTP_FROM_NAME=Infisical ``` {" "} - + Optional (for TLS/SSL): TLS: Available on the same ports (2525, 80, 25, 8025, or 587) diff --git a/frontend/public/images/integrations/1Password.png b/frontend/public/images/integrations/1Password.png new file mode 100644 index 0000000000..8518b41e66 Binary files /dev/null and b/frontend/public/images/integrations/1Password.png differ diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx index ef2691d694..3d54bbb201 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -32,7 +32,7 @@ const formSchema = z.object({ environments: z .object({ name: z.string().trim().min(1), - slug: slugSchema({ min: 1, max: 32 }) + slug: slugSchema({ min: 1, max: 64 }) }) .array() .nullish() diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index 5b5728dac8..216253653a 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -21,7 +21,7 @@ import { import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: slugSchema({ min: 1, max: 32, field: "Name" }), + name: slugSchema({ min: 1, max: 64, field: "Name" }), description: z.string().max(500).optional() }); diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx index 238dabea38..9e1476118a 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx @@ -18,9 +18,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({ - - {activeCredentials?.dn} - + {activeCredentials?.dn} {activeCredentials?.password} @@ -28,9 +26,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({ } inactiveCredentials={ <> - - {inactiveCredentials?.dn} - + {inactiveCredentials?.dn} {inactiveCredentials?.password} diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx index e1c74b4209..d931d9d3c1 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2Form.tsx @@ -48,7 +48,8 @@ const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Fo "rotateAtUtc" ] }, - { name: "Parameters", key: "parameters", fields: ["parameters"] }, + // @ts-expect-error temporary parameters aren't present on all forms + { name: "Parameters", key: "parameters", fields: ["parameters", "temporaryParameters"] }, { name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] }, { name: "Details", key: "details", fields: ["name", "description"] }, { name: "Review", key: "review", fields: [] } @@ -75,7 +76,7 @@ export const SecretRotationV2Form = ({ const { rotationOption } = useSecretRotationV2Option(type); const formMethods = useForm({ - resolver: zodResolver(SecretRotationV2FormSchema), + resolver: zodResolver(SecretRotationV2FormSchema(Boolean(secretRotation))), defaultValues: secretRotation ? { ...secretRotation, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx index 9c9d8329f6..243c183699 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx @@ -2,40 +2,135 @@ import { Controller, useFormContext } from "react-hook-form"; import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared"; -import { FormControl, Input } from "@app/components/v2"; +import { FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; export const LdapPasswordRotationParametersFields = () => { - const { control } = useFormContext< + const { control, watch, setValue } = useFormContext< TSecretRotationV2Form & { type: SecretRotation.LdapPassword; } >(); + const [id, rotationMethod] = watch(["id", "parameters.rotationMethod"]); + const isUpdate = Boolean(id); + return ( <> ( + Determines how the rotation will be performed: +
    +
  • + Connection Principal - The Connection + principal will rotate the target principal's password. +
  • +
  • + Target Principal - The target principal + will rotate their own password. +
  • +
+ + } + tooltipClassName="max-w-sm" errorText={error?.message} - label="Distinguished Name (DN)" + isError={Boolean(error?.message)} + label="Rotation Method" + helperText={ + // eslint-disable-next-line no-nested-ternary + isUpdate + ? "Cannot be updated." + : value === LdapPasswordRotationMethod.ConnectionPrincipal + ? "The connection principal will rotate the target principal's password" + : "The target principal will rotate their own password" + } > - + onValueChange={(val) => { + setValue( + "temporaryParameters", + val === LdapPasswordRotationMethod.TargetPrincipal + ? { + password: "" + } + : undefined + ); + onChange(val); + }} + className="w-full border border-mineshaft-500 capitalize" + position="popper" + dropdownContainerClassName="max-w-none" + > + {Object.values(LdapPasswordRotationMethod).map((method) => { + return ( + + {method.replace("-", " ")} + + ); + })} +
)} /> +
+ ( + + + + )} + /> + {rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && !isUpdate && ( + ( + + + + )} + /> + )} +
Password Requirements
-
+
{ label="Password Length" isError={Boolean(error)} errorText={error?.message} - helperText="The length of the password to generate" + tooltipText="The length of the password to generate" > { label="Digit Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of digits" + tooltipText="Minimum number of digits" > { label="Lowercase Character Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of lowercase characters" + tooltipText="Minimum number of lowercase characters" > { label="Uppercase Character Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of uppercase characters" + tooltipText="Minimum number of uppercase characters" > { label="Symbol Count" isError={Boolean(error)} errorText={error?.message} - helperText="Minimum number of symbols" + tooltipText="Minimum number of symbols" > { label="Allowed Symbols" isError={Boolean(error)} errorText={error?.message} - helperText="Symbols to use in generated password" + tooltipText="Symbols to use in generated password" > { const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]); + const { passwordRequirements } = parameters; + return ( <> - {parameters.dn} + {parameters.dn} + {passwordRequirements && ( + + {passwordRequirements.length} + + {passwordRequirements.required.digits} + + + {passwordRequirements.required.lowercase} + + + {passwordRequirements.required.uppercase} + + + {passwordRequirements.required.symbols} + + + {passwordRequirements.allowedSymbols} + + + )} - {dn} + {dn} {password} diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx index 7e2da4a076..fdff4af5cd 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared/SecretRotationReviewSection.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; type Props = { - label: "Parameters" | "Secrets Mapping"; + label: "Parameters" | "Secrets Mapping" | "Password Requirements"; children: ReactNode; }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx index 01d2e0d74a..0c5c906625 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx @@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => { const items = [ { - name: "DN", + name: "DN/UPN", input: ( ( diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index b0484ae679..b8564801f7 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -6,16 +6,36 @@ import { AzureClientSecretRotationSchema } from "@app/components/secret-rotation import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; -const SecretRotationUnionSchema = z.discriminatedUnion("type", [ - Auth0ClientSecretRotationSchema, - AzureClientSecretRotationSchema, - PostgresCredentialsRotationSchema, - MsSqlCredentialsRotationSchema, - LdapPasswordRotationSchema, - AwsIamUserSecretRotationSchema -]); +export const SecretRotationV2FormSchema = (isUpdate: boolean) => + z + .intersection( + z.discriminatedUnion("type", [ + Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, + PostgresCredentialsRotationSchema, + MsSqlCredentialsRotationSchema, + LdapPasswordRotationSchema, + AwsIamUserSecretRotationSchema + ]), + z.object({ id: z.string().optional() }) + ) + .superRefine((val, ctx) => { + if (val.type !== SecretRotation.LdapPassword || isUpdate) return; -export const SecretRotationV2FormSchema = SecretRotationUnionSchema; + // this has to go on union or breaks discrimination + if ( + val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && + !val.temporaryParameters?.password + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Password required", + path: ["temporaryParameters", "password"] + }); + } + }); -export type TSecretRotationV2Form = z.infer; +export type TSecretRotationV2Form = z.infer>; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts index e18609f042..58e998ee79 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts @@ -2,8 +2,9 @@ import { z } from "zod"; import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; -import { DistinguishedNameRegex } from "@app/helpers/string"; +import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; export const LdapPasswordRotationSchema = z .object({ @@ -12,13 +13,24 @@ export const LdapPasswordRotationSchema = z dn: z .string() .trim() - .regex(DistinguishedNameRegex, "Invalid Distinguished Name format") - .min(1, "Distinguished Name (DN) required"), - passwordRequirements: PasswordRequirementsSchema.optional() + .min(1, "DN/UPN required") + .refine( + (value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), + { + message: "Invalid DN/UPN format" + } + ), + passwordRequirements: PasswordRequirementsSchema.optional(), + rotationMethod: z.nativeEnum(LdapPasswordRotationMethod).optional() }), secretsMapping: z.object({ - dn: z.string().trim().min(1, "Distinguished Name (DN) required"), + dn: z.string().trim().min(1, "DN/UPN required"), password: z.string().trim().min(1, "Password required") - }) + }), + temporaryParameters: z + .object({ + password: z.string().min(1, "Password required") + }) + .optional() }) .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts index a02852ec8e..1bab3b0bd2 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +export type TPasswordRequirements = z.infer; + export const PasswordRequirementsSchema = z .object({ length: z diff --git a/frontend/src/components/secret-syncs/SecretSyncSelect.tsx b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx index cbcba45130..62d99544fc 100644 --- a/frontend/src/components/secret-syncs/SecretSyncSelect.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncSelect.tsx @@ -2,16 +2,23 @@ import { faWrench } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Spinner, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { usePopUp } from "@app/hooks"; import { SecretSync, useSecretSyncOptions } from "@app/hooks/api/secretSyncs"; +import { UpgradePlanModal } from "../license/UpgradePlanModal"; + type Props = { onSelect: (destination: SecretSync) => void; }; export const SecretSyncSelect = ({ onSelect }: Props) => { + const { subscription } = useSubscription(); const { isPending, data: secretSyncOptions } = useSecretSyncOptions(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + if (isPending) { return (
@@ -23,14 +30,17 @@ export const SecretSyncSelect = ({ onSelect }: Props) => { return (
- {secretSyncOptions?.map(({ destination }) => { + {secretSyncOptions?.map(({ destination, enterprise }) => { const { image, name } = SECRET_SYNC_MAP[destination]; return ( ); })} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use every Secret Sync if you switch to Infisical's Enterprise plan." + /> { + 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 2cac1ae20f..48541b2721 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 7c2b139363..00d86e7004 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 0000000000..1c31fb6c31 --- /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 144ccb2a8f..a53eec5e73 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 0000000000..36b144776f --- /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 232b8cedfa..792226daec 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/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index dd6d3575e3..a42ee02a5d 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -51,6 +51,7 @@ type Props = Omit, "onChange" | "val isVisible?: boolean; isReadOnly?: boolean; isDisabled?: boolean; + canEditButNotView?: boolean; secretPath?: string; environment?: string; containerClassName?: string; @@ -70,6 +71,7 @@ export const InfisicalSecretInput = forwardRef( containerClassName, secretPath: propSecretPath, environment: propEnvironment, + canEditButNotView, ...props }, ref @@ -273,6 +275,7 @@ export const InfisicalSecretInput = forwardRef( { @@ -51,6 +52,7 @@ type Props = TextareaHTMLAttributes & { isReadOnly?: boolean; isDisabled?: boolean; containerClassName?: string; + canEditButNotView?: boolean; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; @@ -66,6 +68,7 @@ export const SecretInput = forwardRef( isDisabled, isReadOnly, onFocus, + canEditButNotView, ...props }, ref @@ -93,7 +96,15 @@ export const SecretInput = forwardRef( onFocus={(evt) => { onFocus?.(evt); setIsSecretFocused.on(); - evt.currentTarget.select(); + if (canEditButNotView && value === HIDDEN_SECRET_VALUE) { + evt.currentTarget.select(); + } + }} + onMouseDown={(e) => { + if (canEditButNotView && value === HIDDEN_SECRET_VALUE) { + e.preventDefault(); + e.currentTarget.select(); + } }} disabled={isDisabled} spellCheck={false} diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index d7b7334ea8..e8efffd6d6 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext"; export type { ProjectPermissionSet, TProjectPermission } from "./types"; export { ProjectPermissionActions, + ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 70976d4bac..352a069581 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,6 +24,15 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } +export enum ProjectPermissionApprovalActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" +} + export enum ProjectPermissionDynamicSecretActions { ReadRootCredential = "read-root-credential", CreateRootCredential = "create-root-credential", @@ -291,7 +300,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionIdentityActions, ( diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 91fcd90556..8b64ee58a8 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -10,6 +10,7 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, + ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 8caa13a5b4..dd0daf9682 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -23,6 +23,7 @@ import { HumanitecConnectionMethod, LdapConnectionMethod, MsSqlConnectionMethod, + OnePassConnectionMethod, PostgresConnectionMethod, TAppConnection, TeamCityConnectionMethod, @@ -34,7 +35,7 @@ import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-con export const APP_CONNECTION_MAP: Record< AppConnection, - { name: string; image: string; size?: number } + { name: string; image: string; size?: number; enterprise?: boolean } > = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, @@ -63,7 +64,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" } + [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, + [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -89,6 +91,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 80df92ac34..88a0f7517c 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/helpers/string.ts b/frontend/src/helpers/string.ts index ddd9fb7c97..ff12afced4 100644 --- a/frontend/src/helpers/string.ts +++ b/frontend/src/helpers/string.ts @@ -15,3 +15,5 @@ export const isValidPath = (val: string): boolean => { export const DistinguishedNameRegex = /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; + +export const UserPrincipalNameRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; diff --git a/frontend/src/helpers/userTablePreferences.ts b/frontend/src/helpers/userTablePreferences.ts new file mode 100644 index 0000000000..a23438746c --- /dev/null +++ b/frontend/src/helpers/userTablePreferences.ts @@ -0,0 +1,73 @@ +const TABLE_PREFERENCES_KEY = "userTablePreferences"; + +export enum PreferenceKey { + PerPage = "perPage" +} + +interface TableSpecificPreferences { + [preferenceKey: string]: any; +} + +interface UserTablePreferences { + [tableName: string]: TableSpecificPreferences; +} + +// Retrieves all table preferences from localStorage +const getAllTablePreferences = (): UserTablePreferences => { + try { + const preferencesString = localStorage.getItem(TABLE_PREFERENCES_KEY); + if (preferencesString) { + return JSON.parse(preferencesString) as UserTablePreferences; + } + } catch (error) { + console.error("Error reading user table preferences from localStorage:", error); + } + return {}; +}; + +// Saves all table preferences to localStorage +const saveAllTablePreferences = (preferences: UserTablePreferences): void => { + try { + localStorage.setItem(TABLE_PREFERENCES_KEY, JSON.stringify(preferences)); + } catch (error) { + console.error("Error saving user table preferences to localStorage:", error); + } +}; + +// Retrieves a specific preference for a given table +export const getUserTablePreference = ( + tableName: string, + preferenceKey: PreferenceKey, + defaultValue: T +): T => { + const preferences = getAllTablePreferences(); + if ( + preferences && + typeof preferences === "object" && + tableName in preferences && + preferenceKey in preferences[tableName] + ) { + const value = preferences[tableName][preferenceKey]; + + if (value !== undefined && value !== null) { + return value as T; + } + } + return defaultValue; +}; + +// Sets a specific preference for a given table and saves it to localStorage +export const setUserTablePreference = ( + tableName: string, + preferenceKey: PreferenceKey, + value: any +): void => { + const preferences = getAllTablePreferences(); + + if (!preferences[tableName]) { + preferences[tableName] = {}; + } + + preferences[tableName][preferenceKey] = value; + saveAllTablePreferences(preferences); +}; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 9fda2377af..c0da7af23d 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 1aa40c588a..6370f4a59c 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 0000000000..2c1906d369 --- /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 0000000000..f735626525 --- /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 0000000000..9386e9dddd --- /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 06a5056afb..d099936d6b 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -17,5 +17,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 0000000000..fcf307a15f --- /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 79cbb81b9a..771422ad21 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -88,6 +88,10 @@ export type TOCIConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OCI; }; +export type TOnePassConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.OnePass; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -106,7 +110,8 @@ export type TAppConnectionOption = | TAuth0ConnectionOption | THCVaultConnectionOption | TTeamCityConnectionOption - | TOCIConnectionOption; + | TOCIConnectionOption + | TOnePassConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -128,4 +133,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 2b29c2cd46..b53c9f751f 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"; @@ -20,6 +21,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"; @@ -59,7 +61,8 @@ export type TAppConnection = | THCVaultConnection | TLdapConnection | TTeamCityConnection - | TOCIConnection; + | TOCIConnection + | TOnePassConnection; export type TAvailableAppConnection = Pick; @@ -106,4 +109,5 @@ export type TAppConnectionMap = { [AppConnection.LDAP]: TLdapConnection; [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; + [AppConnection.OnePass]: TOnePassConnection; }; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index f726566cd7..555d66b881 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -123,7 +123,6 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_PROJECT_TEMPLATE]: "Create project template", [EventType.UPDATE_PROJECT_TEMPLATE]: "Update project template", [EventType.DELETE_PROJECT_TEMPLATE]: "Delete project template", - [EventType.APPLY_PROJECT_TEMPLATE]: "Apply project template", [EventType.GET_APP_CONNECTIONS]: "List App Connections", [EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS]: "List App Connections Details", [EventType.GET_APP_CONNECTION]: "Get App Connection", @@ -189,7 +188,13 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.ADD_IDENTITY_LDAP_AUTH]: "Attached LDAP Auth to identity", [EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity", [EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved LDAP Auth for identity", - [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity" + [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity", + + [EventType.UPDATE_ORG]: "Update Organization", + + [EventType.CREATE_PROJECT]: "Create Project", + [EventType.UPDATE_PROJECT]: "Update Project", + [EventType.DELETE_PROJECT]: "Delete Project" }; export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b74969d6df..19dfd9522f 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -131,7 +131,6 @@ export enum EventType { CREATE_PROJECT_TEMPLATE = "create-project-template", UPDATE_PROJECT_TEMPLATE = "update-project-template", DELETE_PROJECT_TEMPLATE = "delete-project-template", - APPLY_PROJECT_TEMPLATE = "apply-project-template", GET_APP_CONNECTIONS = "get-app-connections", GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details", GET_APP_CONNECTION = "get-app-connection", @@ -183,5 +182,11 @@ export enum EventType { MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS = "microsoft-teams-workflow-integration-check-installation-status", MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS = "microsoft-teams-workflow-integration-get-teams", MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET = "microsoft-teams-workflow-integration-get", - MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list" + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list", + + UPDATE_ORG = "update-org", + + CREATE_PROJECT = "create-project", + UPDATE_PROJECT = "update-project", + DELETE_PROJECT = "delete-project" } diff --git a/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts index b8d2ade2b0..42a1116cff 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts @@ -1,3 +1,4 @@ +import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { @@ -5,10 +6,17 @@ import { TSecretRotationV2GeneratedCredentialsResponseBase } from "@app/hooks/api/secretRotationsV2/types/shared"; +export enum LdapPasswordRotationMethod { + ConnectionPrincipal = "connection-principal", + TargetPrincipal = "target-principal" +} + export type TLdapPasswordRotation = TSecretRotationV2Base & { type: SecretRotation.LdapPassword; parameters: { dn: string; + rotationMethod?: LdapPasswordRotationMethod; + passwordRequirements?: TPasswordRequirements; }; secretsMapping: { dn: string; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 65a31e427d..7185563d51 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 0000000000..98556d2afd --- /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 e3de6029a7..f28a0820b2 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"; @@ -21,6 +22,7 @@ export type TSecretSyncOption = { name: string; destination: SecretSync; canImportSecrets: boolean; + enterprise?: boolean; }; export type TSecretSync = @@ -38,7 +40,8 @@ export type TSecretSync = | TWindmillSync | THCVaultSync | TTeamCitySync - | TOCIVaultSync; + | TOCIVaultSync + | TOnePassSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ec7b6a2dd5..a861c215a0 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -50,4 +50,6 @@ export type SubscriptionPlan = { enforceMfa: boolean; projectTemplates: boolean; kmip: boolean; + enterpriseSecretSyncs: boolean; + enterpriseAppConnections: boolean; }; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 0774275f96..715c3532d8 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRemoveMyDuplicateAccounts, useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode @@ -14,6 +15,7 @@ export { useDeleteOrgMembership, useGetMyAPIKeys, useGetMyAPIKeysV2, + useGetMyDuplicateAccount, useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 1b873b31cb..ee274ab318 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => { } }); }; + +export const useRemoveMyDuplicateAccounts = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 06cde4d34d..ea451db026 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -37,6 +37,33 @@ export const useGetUser = () => queryFn: fetchUserDetails }); +export const fetchUserDuplicateAccounts = async () => { + const { data } = await apiRequest.get<{ + users: Array< + User & { + isMyAccount: boolean; + organizations: { name: string; slug: string }[]; + devices: { + ip: string; + userAgent: string; + }[]; + } + >; + }>("/api/v1/user/duplicate-accounts"); + return data.users; +}; + +export const useGetMyDuplicateAccount = () => + useQuery({ + queryKey: userKeys.getMyDuplicateAccount, + staleTime: 60 * 1000, // 1 min in ms + queryFn: fetchUserDuplicateAccounts, + select: (users) => ({ + duplicateAccounts: users.filter((el) => !el.isMyAccount), + myAccount: users?.find((el) => el.isMyAccount) + }) + }); + export const useDeleteMe = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/users/query-keys.tsx b/frontend/src/hooks/api/users/query-keys.tsx index 34d969b495..aacbb73b92 100644 --- a/frontend/src/hooks/api/users/query-keys.tsx +++ b/frontend/src/hooks/api/users/query-keys.tsx @@ -1,5 +1,6 @@ export const userKeys = { getUser: ["user"] as const, + getMyDuplicateAccount: ["user-duplicate-account"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, diff --git a/frontend/src/lib/schemas/slugSchema.ts b/frontend/src/lib/schemas/slugSchema.ts index ed97cb7d0f..5a02c6a286 100644 --- a/frontend/src/lib/schemas/slugSchema.ts +++ b/frontend/src/lib/schemas/slugSchema.ts @@ -7,7 +7,7 @@ interface SlugSchemaInputs { field?: string; } -export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { +export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => { return z .string() .trim() diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index 529509b49d..91432f9bee 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; +import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries"; +import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation"; import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; @@ -40,6 +41,7 @@ export const PasswordStep = ({ const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const navigate = useNavigate(); + const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true); const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); @@ -109,6 +111,13 @@ export const PasswordStep = ({ return; } + const userDuplicateAccount = await fetchUserDuplicateAccounts(); + const hasDuplicate = userDuplicateAccount?.length > 1; + if (hasDuplicate) { + setRemoveDuplicateLater(false); + return; + } + await navigateUserToOrg(navigate, organizationId); }; @@ -306,6 +315,18 @@ export const PasswordStep = ({ ); } + if (!removeDuplicateLater) { + return ( + + navigateUserToOrg(navigate, organizationId).catch(() => + createNotification({ text: "Failed to navigate user", type: "error" }) + ) + } + /> + ); + } + if (hasExchangedPrivateKey) { return (
diff --git a/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx b/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx new file mode 100644 index 0000000000..fb4ac6543d --- /dev/null +++ b/frontend/src/pages/auth/SelectOrgPage/EmailDuplicationConfirmation.tsx @@ -0,0 +1,164 @@ +import { useCallback } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { format } from "date-fns"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal, Tooltip } from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { + useGetMyDuplicateAccount, + useLogoutUser, + useRemoveMyDuplicateAccounts +} from "@app/hooks/api"; + +type Props = { + onRemoveDuplicateLater: () => void; +}; + +export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) => { + const duplicateAccounts = useGetMyDuplicateAccount(); + const removeDuplicateEmails = useRemoveMyDuplicateAccounts(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const logout = useLogoutUser(true); + const { popUp, handlePopUpToggle } = usePopUp(["removeDuplicateConfirm"] as const); + const handleLogout = useCallback(async () => { + try { + console.log("Logging out..."); + await logout.mutateAsync(); + navigate({ to: "/login" }); + } catch (error) { + console.error(error); + } + }, [logout, navigate]); + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + +
+ +
+ Infisical logo +
+ +
+
+

+ Multiple Accounts Detected +

+

+ You're currently logged in as{" "} + {duplicateAccounts?.data?.myAccount?.username}. +

+
+

+ We've detected multiple accounts using variations of the same email address. +

+
+
+
+ Your other accounts +
+
+ {duplicateAccounts?.data?.duplicateAccounts?.map((el) => { + const lastSession = el.devices?.at(-1); + return ( +
+
+
{el.username}
+
+ Last logged in at {format(new Date(el.updatedAt), "Pp")} +
+
+ Organizations: {el?.organizations?.map((i) => i.slug)?.join(",")} +
+
+
+ +
IP: {lastSession?.ip || "-"}
+
User Agent: {lastSession?.userAgent || "-"}
+
+ } + > + + +
+
+ ); + })} +
+
+
+ + +
+ +
+ +
+
+ handlePopUpToggle("removeDuplicateConfirm", isOpen)} + deleteKey="remove" + buttonText="Confirm" + onDeleteApproved={() => + removeDuplicateEmails.mutateAsync(undefined, { + onSuccess: () => { + createNotification({ + type: "success", + text: "Removed duplicate accounts" + }); + onRemoveDuplicateLater(); + } + }) + } + /> +
+ ); +}; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx index 7dddd1a4b7..f66be2d4b8 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -1,33 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useNavigate } from "@tanstack/react-router"; -import axios from "axios"; -import { addSeconds, formatISO } from "date-fns"; -import { jwtDecode } from "jwt-decode"; +import { useState } from "react"; -import { Mfa } from "@app/components/auth/Mfa"; -import { createNotification } from "@app/components/notifications"; -import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; -import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Spinner } from "@app/components/v2"; -import { SessionStorageKeys } from "@app/const"; -import { OrgMembershipRole } from "@app/helpers/roles"; -import { useToggle } from "@app/hooks"; -import { - useGetOrganizations, - useGetUser, - useLogoutUser, - useSelectOrganization -} from "@app/hooks/api"; -import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types"; -import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery"; -import { Organization } from "@app/hooks/api/types"; -import { AuthMethod } from "@app/hooks/api/users/types"; +import { Spinner } from "@app/components/v2"; +import { useGetMyDuplicateAccount } from "@app/hooks/api"; -import { navigateUserToOrg } from "../LoginPage/Login.utils"; +import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation"; +import { SelectOrganizationSection } from "./SelectOrgSection"; const LoadingScreen = () => { return ( @@ -39,253 +16,18 @@ const LoadingScreen = () => { }; export const SelectOrganizationPage = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); + const duplicateAccounts = useGetMyDuplicateAccount(); + const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false); - const organizations = useGetOrganizations(); - const selectOrg = useSelectOrganization(); - const { data: user, isPending: userLoading } = useGetUser(); - const [shouldShowMfa, toggleShowMfa] = useToggle(false); - const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); - const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); - - const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); - - const queryParams = new URLSearchParams(window.location.search); - const orgId = queryParams.get("org_id"); - const callbackPort = queryParams.get("callback_port"); - const isAdminLogin = queryParams.get("is_admin_login") === "true"; - const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); - - const logout = useLogoutUser(true); - const handleLogout = useCallback(async () => { - try { - console.log("Logging out..."); - await logout.mutateAsync(); - navigate({ to: "/login" }); - } catch (error) { - console.error(error); - } - }, [logout, navigate]); - - const handleSelectOrganization = useCallback( - async (organization: Organization) => { - const canBypassOrgAuth = - organization.bypassOrgAuthEnabled && - organization.userRole === OrgMembershipRole.Admin && - isAdminLogin; - - if (organization.authEnforced && !canBypassOrgAuth) { - // org has an org-level auth method enabled (e.g. SAML) - // -> logout + redirect to SAML SSO - await logout.mutateAsync(); - let url = ""; - if (organization.orgAuthMethod === AuthMethod.OIDC) { - url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ - callbackPort ? `&callbackPort=${callbackPort}` : "" - }`; - } else { - url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; - - if (callbackPort) { - url += `?callback_port=${callbackPort}`; - } - } - - window.location.href = url; - return; - } - - const { token, isMfaEnabled, mfaMethod } = await selectOrg - .mutateAsync({ - organizationId: organization.id, - userAgent: callbackPort ? UserAgentType.CLI : undefined - }) - .finally(() => setIsInitialOrgCheckLoading(false)); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(token); - if (mfaMethod) { - setRequiredMfaMethod(mfaMethod); - } - toggleShowMfa.on(); - setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); - return; - } - - if (callbackPort) { - const privateKey = localStorage.getItem("PRIVATE_KEY"); - - let error: string | null = null; - - if (!privateKey) error = "Private key not found"; - if (!user?.email) error = "User email not found"; - if (!token) error = "No token found"; - - if (error) { - createNotification({ - text: error, - type: "error" - }); - return; - } - - const payload = { - JTWToken: token, - email: user?.email, - privateKey - } as IsCliLoginSuccessful["loginResponse"]; - - // send request to server endpoint - const instance = axios.create(); - await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - navigate({ to: "/cli-redirect" }); - // cli page - } else { - navigateUserToOrg(navigate, organization.id); - } - }, - [selectOrg] - ); - - const handleCliRedirect = useCallback(() => { - const authToken = getAuthToken(); - - if (authToken && !callbackPort) { - const decodedJwt = jwtDecode(authToken) as any; - - if (decodedJwt?.organizationId) { - navigateUserToOrg(navigate, decodedJwt.organizationId); - } - } - - if (!isLoggedIn()) { - navigate({ to: "/login" }); - } - }, []); - - useEffect(() => { - if (callbackPort) { - handleCliRedirect(); - } - }, [navigate]); - - useEffect(() => { - if (organizations.isPending || !organizations.data) return; - - // Case: User has no organizations. - // This can happen if the user was previously a member, but the organization was deleted or the user was removed. - if (organizations.data.length === 0) { - navigate({ to: "/organization/none" }); - } else if (organizations.data.length === 1) { - if (callbackPort) { - handleCliRedirect(); - setIsInitialOrgCheckLoading(false); - } else { - handleSelectOrganization(organizations.data[0]); - } - } else { - setIsInitialOrgCheckLoading(false); - } - }, [organizations.isPending, organizations.data]); - - useEffect(() => { - if (defaultSelectedOrg) { - handleSelectOrganization(defaultSelectedOrg); - } - }, [defaultSelectedOrg]); - - if ( - userLoading || - !user || - ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) - ) { + if (duplicateAccounts.isPending) { return ; } - return ( -
- - {t("common.head-title", { title: t("login.title") })} - - - - - - {shouldShowMfa ? ( - - ) : ( -
- -
- Infisical logo -
- -
-
-

- Choose your organization -

+ if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) { + return ( + setRemoveDuplicateLater(true)} /> + ); + } -
-

- You‘re currently logged in as {user.username} -

-

- Not you?{" "} - -

-
-
-
- {organizations.isPending ? ( - - ) : ( - organizations.data?.map((org) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
handleSelectOrganization(org)} - key={org.id} - className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" - > -

{org.name}

- - -
- )) - )} -
-
-
- )} - -
-
- ); + return ; }; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx new file mode 100644 index 0000000000..a736b52a76 --- /dev/null +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate } from "@tanstack/react-router"; +import axios from "axios"; +import { addSeconds, formatISO } from "date-fns"; +import { jwtDecode } from "jwt-decode"; + +import { Mfa } from "@app/components/auth/Mfa"; +import { createNotification } from "@app/components/notifications"; +import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { Button, Spinner } from "@app/components/v2"; +import { SessionStorageKeys } from "@app/const"; +import { OrgMembershipRole } from "@app/helpers/roles"; +import { useToggle } from "@app/hooks"; +import { + useGetOrganizations, + useGetUser, + useLogoutUser, + useSelectOrganization +} from "@app/hooks/api"; +import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types"; +import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery"; +import { Organization } from "@app/hooks/api/types"; +import { AuthMethod } from "@app/hooks/api/users/types"; + +import { navigateUserToOrg } from "../LoginPage/Login.utils"; + +const LoadingScreen = () => { + return ( +
+ +

Loading, please wait

+
+ ); +}; + +export const SelectOrganizationSection = () => { + const navigate = useNavigate(); + const { t } = useTranslation(); + + const organizations = useGetOrganizations(); + const selectOrg = useSelectOrganization(); + const { data: user, isPending: userLoading } = useGetUser(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); + const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); + + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); + + const queryParams = new URLSearchParams(window.location.search); + const orgId = queryParams.get("org_id"); + const callbackPort = queryParams.get("callback_port"); + const isAdminLogin = queryParams.get("is_admin_login") === "true"; + const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); + + const logout = useLogoutUser(true); + const handleLogout = useCallback(async () => { + try { + console.log("Logging out..."); + await logout.mutateAsync(); + navigate({ to: "/login" }); + } catch (error) { + console.error(error); + } + }, [logout, navigate]); + + const handleSelectOrganization = useCallback( + async (organization: Organization) => { + const isUserOrgAdmin = organization.userRole === OrgMembershipRole.Admin; + const canBypassOrgAuth = organization.bypassOrgAuthEnabled && isUserOrgAdmin && isAdminLogin; + + if (isAdminLogin) { + if (!organization.bypassOrgAuthEnabled) { + createNotification({ + text: "This organization does not have bypass org auth enabled", + type: "error" + }); + return; + } + if (!isUserOrgAdmin) { + createNotification({ + text: "Only organization admins can bypass org auth", + type: "error" + }); + return; + } + } + + if (organization.authEnforced && !canBypassOrgAuth) { + // org has an org-level auth method enabled (e.g. SAML) + // -> logout + redirect to SAML SSO + await logout.mutateAsync(); + let url = ""; + if (organization.orgAuthMethod === AuthMethod.OIDC) { + url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ + callbackPort ? `&callbackPort=${callbackPort}` : "" + }`; + } else { + url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; + + if (callbackPort) { + url += `?callback_port=${callbackPort}`; + } + } + + window.location.href = url; + return; + } + + const { token, isMfaEnabled, mfaMethod } = await selectOrg + .mutateAsync({ + organizationId: organization.id, + userAgent: callbackPort ? UserAgentType.CLI : undefined + }) + .finally(() => setIsInitialOrgCheckLoading(false)); + + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } + toggleShowMfa.on(); + setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); + return; + } + + if (callbackPort) { + const privateKey = localStorage.getItem("PRIVATE_KEY"); + + let error: string | null = null; + + if (!privateKey) error = "Private key not found"; + if (!user?.email) error = "User email not found"; + if (!token) error = "No token found"; + + if (error) { + createNotification({ + text: error, + type: "error" + }); + return; + } + + const payload = { + JTWToken: token, + email: user?.email, + privateKey + } as IsCliLoginSuccessful["loginResponse"]; + + // send request to server endpoint + const instance = axios.create(); + await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { + // if error happens to communicate we set the token with an expiry in sessino storage + // the cli-redirect page has logic to show this to user and ask them to paste it in terminal + sessionStorage.setItem( + SessionStorageKeys.CLI_TERMINAL_TOKEN, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 30)), + data: window.btoa(JSON.stringify(payload)) + }) + ); + }); + navigate({ to: "/cli-redirect" }); + // cli page + } else { + navigateUserToOrg(navigate, organization.id); + } + }, + [selectOrg] + ); + + const handleCliRedirect = useCallback(() => { + const authToken = getAuthToken(); + + if (authToken && !callbackPort) { + const decodedJwt = jwtDecode(authToken) as any; + + if (decodedJwt?.organizationId) { + navigateUserToOrg(navigate, decodedJwt.organizationId); + } + } + + if (!isLoggedIn()) { + navigate({ to: "/login" }); + } + }, []); + + useEffect(() => { + if (callbackPort) { + handleCliRedirect(); + } + }, [navigate]); + + useEffect(() => { + if (organizations.isPending || !organizations.data) return; + + // Case: User has no organizations. + // This can happen if the user was previously a member, but the organization was deleted or the user was removed. + if (organizations.data.length === 0) { + navigate({ to: "/organization/none" }); + } else if (organizations.data.length === 1) { + if (callbackPort) { + handleCliRedirect(); + setIsInitialOrgCheckLoading(false); + } else { + handleSelectOrganization(organizations.data[0]); + } + } else { + setIsInitialOrgCheckLoading(false); + } + }, [organizations.isPending, organizations.data]); + + useEffect(() => { + if (defaultSelectedOrg) { + handleSelectOrganization(defaultSelectedOrg); + } + }, [defaultSelectedOrg]); + + if ( + userLoading || + !user || + ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) + ) { + return ; + } + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + {shouldShowMfa ? ( + + ) : ( +
+ +
+ Infisical logo +
+ +
+
+

+ Choose your organization +

+
+

+ You‘re currently logged in as {user.username} +

+

+ Not you?{" "} + +

+
+
+
+ {organizations.isPending ? ( + + ) : ( + organizations.data?.map((org) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
handleSelectOrganization(org)} + key={org.id} + className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" + > +

{org.name}

+ + +
+ )) + )} +
+
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx b/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx index a9d29d24a6..bcc49b11a1 100644 --- a/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx +++ b/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx @@ -43,6 +43,11 @@ import { useSubscription, useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useGetKmipClientsByProjectId } from "@app/hooks/api/kmip"; @@ -71,7 +76,14 @@ export const KmipClientTable = () => { perPage, page, setPerPage - } = usePagination(KmipClientOrderBy.Name); + } = usePagination(KmipClientOrderBy.Name, { + initPerPage: getUserTablePreference("kmipClientTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("kmipClientTable", PreferenceKey.PerPage, newPerPage); + }; const { data, isPending, isFetching } = useGetKmipClientsByProjectId({ projectId, @@ -290,7 +302,7 @@ export const KmipClientTable = () => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} {!isPending && kmipClients.length === 0 && ( diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index c6902b4fa5..e1a2fe7892 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -53,6 +53,11 @@ import { useWorkspace } from "@app/context"; import { kmsKeyUsageOptions } from "@app/helpers/kms"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks"; import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks"; import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types"; @@ -100,7 +105,14 @@ export const CmekTable = () => { perPage, page, setPerPage - } = usePagination(CmekOrderBy.Name); + } = usePagination(CmekOrderBy.Name, { + initPerPage: getUserTablePreference("cmekClientTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("cmekClientTable", PreferenceKey.PerPage, newPerPage); + }; const { data, isPending, isFetching } = useGetCmeksByProjectId({ projectId, @@ -508,7 +520,7 @@ export const CmekTable = () => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} {!isPending && keys.length === 0 && ( diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx index e7b89c48d9..14de28fd63 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx @@ -34,6 +34,11 @@ import { Tr } from "@app/components/v2"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -103,7 +108,14 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { setOrderBy, setOrderDirection, toggleOrderDirection - } = usePagination(GroupsOrderBy.Name, { initPerPage: 20 }); + } = usePagination(GroupsOrderBy.Name, { + initPerPage: getUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, newPerPage); + }; const filteredGroups = useMemo(() => { const filtered = search @@ -376,7 +388,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredGroups?.length && ( diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index ddfcc7a7c9..d499a7b369 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -42,6 +42,11 @@ import { Tr } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -76,7 +81,15 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { perPage, page, setPerPage - } = usePagination(OrgIdentityOrderBy.Name); + } = usePagination(OrgIdentityOrderBy.Name, { + initPerPage: getUserTablePreference("identityTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage); + }; + const [filteredRoles, setFilteredRoles] = useState([]); const organizationId = currentOrg?.id || ""; @@ -379,7 +392,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} {!isPending && data && data?.identities.length === 0 && ( diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index a79da885ba..e0350741e7 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -42,6 +42,11 @@ import { useSubscription, useUser } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useFetchServerStatus, @@ -170,7 +175,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro setOrderBy, setOrderDirection, toggleOrderDirection - } = usePagination(OrgMembersOrderBy.Name, { initPerPage: 20 }); + } = usePagination(OrgMembersOrderBy.Name, { + initPerPage: getUserTablePreference("orgMembersTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("orgMembersTable", PreferenceKey.PerPage, newPerPage); + }; const filteredUsers = useMemo( () => @@ -513,7 +525,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isMembersLoading && !filteredUsers?.length && ( 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 0000000000..1c266aeeea --- /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 238dc4e04f..0e35ac4fde 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"; @@ -104,6 +105,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.OCI: return ; + case AppConnection.OnePass: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -178,6 +181,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/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx index 19df1f4ac3..70128025d3 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx @@ -5,7 +5,7 @@ import { FormControl, Input, TextArea } from "@app/components/v2"; import { slugSchema } from "@app/lib/schemas"; export const genericAppConnectionFieldsSchema = z.object({ - name: slugSchema({ min: 1, max: 32, field: "Name" }), + name: slugSchema({ min: 1, max: 64, field: "Name" }), description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() }); diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx index 7346f84af6..a13a5c1841 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx @@ -19,7 +19,7 @@ import { Tooltip } from "@app/components/v2"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; -import { DistinguishedNameRegex } from "@app/helpers/string"; +import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string"; import { LdapConnectionMethod, LdapConnectionProvider, @@ -55,8 +55,13 @@ const formSchema = z.discriminatedUnion("method", [ dn: z .string() .trim() - .regex(DistinguishedNameRegex, "Invalid Distinguished Name format") - .min(1, "Distinguished Name (DN) required"), + .min(1, "DN/UPN required") + .refine( + (value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), + { + message: "Invalid DN/UPN format" + } + ), password: z.string().trim().min(1, "Password required"), sslRejectUnauthorized: z.boolean(), sslCertificate: z @@ -223,7 +228,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx index 5e9f693354..ab6a44f430 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx @@ -1,8 +1,11 @@ import { faWrench } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { Spinner, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { usePopUp } from "@app/hooks"; import { useAppConnectionOptions } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; @@ -11,8 +14,11 @@ type Props = { }; export const AppConnectionsSelect = ({ onSelect }: Props) => { + const { subscription } = useSubscription(); const { isPending, data: appConnectionOptions } = useAppConnectionOptions(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + if (isPending) { return (
@@ -25,13 +31,16 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => { return (
{appConnectionOptions?.map((option) => { - const { image, name, size = 50 } = APP_CONNECTION_MAP[option.app]; + const { image, name, size = 50, enterprise = false } = APP_CONNECTION_MAP[option.app]; return ( ); })} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use every App Connection if you switch to Infisical's Enterprise plan." + /> { orderBy, setOrderDirection, setOrderBy - } = usePagination(AppConnectionsOrderBy.App, { initPerPage: 20 }); + } = usePagination(AppConnectionsOrderBy.App, { + initPerPage: getUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, newPerPage); + }; const filteredAppConnections = useMemo( () => @@ -282,7 +294,7 @@ export const AppConnectionsTable = () => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredAppConnections?.length && ( diff --git a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/CurrentPlanSection.tsx b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/CurrentPlanSection.tsx index 1b7e84f2c9..5c85f19f71 100644 --- a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/CurrentPlanSection.tsx +++ b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/CurrentPlanSection.tsx @@ -1,4 +1,9 @@ -import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons"; +import { + faCircleCheck, + faCircleXmark, + faFileInvoice, + faInfoCircle +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { @@ -10,6 +15,7 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; import { useOrganization } from "@app/context"; @@ -48,9 +54,26 @@ export const CurrentPlanSection = () => { data && data?.rows?.length > 0 && data.rows.map(({ name, allowed, used }) => { + let toolTipText = null; + if (name === "Organization identity limit") { + toolTipText = + "Identity count is calculated by the total number of user identities and machine identities."; + } + return ( - {name} + + {name} + {toolTipText && ( + + + + )} + {displayCell(allowed)} {used} diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index b6af10f2fe..6472c34c16 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -25,6 +25,11 @@ import { Tr } from "@app/components/v2"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -57,7 +62,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props offset, orderDirection, toggleOrderDirection - } = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 }); + } = usePagination(GroupMembersOrderBy.Name, { + initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage); + }; const { currentOrg } = useOrganization(); @@ -163,7 +175,7 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredGroupMemberships?.length && ( diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx index 680bb80358..ef70d1f965 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx @@ -21,6 +21,11 @@ import { THead, Tr } from "@app/components/v2"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetIdentityProjectMemberships } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -53,7 +58,14 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => offset, orderDirection, toggleOrderDirection - } = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 }); + } = usePagination(IdentityProjectsOrderBy.Name, { + initPerPage: getUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, newPerPage); + }; const filteredProjectMemberships = useMemo( () => @@ -132,7 +144,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredProjectMemberships?.length && ( diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx index 5fd4252e33..6be1a45547 100644 --- a/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx @@ -28,6 +28,11 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { getProjectHomePage } from "@app/helpers/project"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api"; import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; @@ -104,7 +109,15 @@ export const AllProjectView = ({ limit, toggleOrderDirection, orderDirection - } = usePagination("name", { initPerPage: 50 }); + } = usePagination("name", { + initPerPage: getUserTablePreference("allProjectsTable", PreferenceKey.PerPage, 50) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("allProjectsTable", PreferenceKey.PerPage, newPerPage); + }; + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ "requestAccessConfirmation" ] as const); @@ -274,7 +287,7 @@ export const AllProjectView = ({ count={searchedProjects?.totalCount || 0} page={page} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isProjectLoading && !searchedProjects?.totalCount && ( diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx index e723e1dfef..a04b4e1962 100644 --- a/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx @@ -19,6 +19,11 @@ import { OrgPermissionCan } from "@app/components/permissions"; import { Button, IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { getProjectHomePage } from "@app/helpers/project"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetUserWorkspaces } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -63,7 +68,15 @@ export const MyProjectView = ({ limit, toggleOrderDirection, orderDirection - } = usePagination(ProjectOrderBy.Name, { initPerPage: 24 }); + } = usePagination(ProjectOrderBy.Name, { + initPerPage: getUserTablePreference("myProjectsTable", PreferenceKey.PerPage, 24) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("myProjectsTable", PreferenceKey.PerPage, newPerPage); + }; + const { data: projectFavorites, isPending: isProjectFavoritesLoading } = useGetUserProjectFavorites(currentOrg?.id); @@ -415,7 +428,7 @@ export const MyProjectView = ({ count={filteredWorkspaces.length} page={page} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {isWorkspaceEmpty && ( diff --git a/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx b/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx index 23cdcf8002..f0ad036775 100644 --- a/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx +++ b/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx @@ -13,6 +13,11 @@ import { useOrganization, useServerConfig } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { withPermission } from "@app/hoc"; import { usePagination, usePopUp } from "@app/hooks"; import { @@ -28,8 +33,6 @@ import { SecretScanningFilter } from "./components/SecretScanningFilters"; import { SecretScanningFilterFormData, secretScanningFilterFormSchema } from "./components/types"; import { SecretScanningLogsTable } from "./components"; -const PER_PAGE_INIT = 25; - export const SecretScanningPage = withPermission( () => { const queryParams = useSearch({ @@ -49,9 +52,14 @@ export const SecretScanningPage = withPermission( const { offset, limit, orderBy, setPage, perPage, page, setPerPage } = usePagination( SecretScanningOrderBy.CreatedAt, - { initPerPage: PER_PAGE_INIT } + { initPerPage: getUserTablePreference("secretScanningTable", PreferenceKey.PerPage, 20) } ); + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretScanningTable", PreferenceKey.PerPage, newPerPage); + }; + const repositoryNames = watch("repositoryNames"); const resolvedStatus = watch("resolved"); @@ -180,7 +188,7 @@ export const SecretScanningPage = withPermission(
)}
-
+
{integrationEnabled && (
)} - {!isPending && - risksData?.totalCount !== undefined && - risksData.totalCount >= PER_PAGE_INIT && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} - /> - )} + {!isPending && risksData?.totalCount !== undefined && risksData.totalCount >= 10 && ( + setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )}
diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx index e0d88a082e..d65b76b16c 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -1,7 +1,7 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { useEffect } from "react"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; diff --git a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index 21c4409571..ac8685192b 100644 --- a/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SsoPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -129,7 +129,16 @@ export const OrgGeneralAuthSection = () => { level.

- In case of a lockout, admins can use the admin login portal at{" "} + In case of a lockout, admins can use the{" "} + + Admin Login Portal + {" "} + at{" "} { level.

- In case of a lockout, admins can use the admin login portal at{" "} + In case of a lockout, admins can use the{" "} + + Admin Login Portal + {" "} + at{" "} { offset, orderDirection, toggleOrderDirection - } = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 }); + } = usePagination(UserGroupsOrderBy.Name, { + initPerPage: getUserTablePreference("userGroupsTable", PreferenceKey.PerPage, 10) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("userGroupsTable", PreferenceKey.PerPage, newPerPage); + }; const filteredGroupMemberships = useMemo( () => @@ -119,7 +131,7 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredGroupMemberships?.length && ( diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx index ff9ed9bbcd..9e6042c03b 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectsTable.tsx @@ -22,6 +22,11 @@ import { Tr } from "@app/components/v2"; import { useOrganization } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -54,7 +59,14 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => { offset, orderDirection, toggleOrderDirection - } = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 }); + } = usePagination(UserProjectsOrderBy.Name, { + initPerPage: getUserTablePreference("userProjectsTable", PreferenceKey.PerPage, 10) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("userProjectsTable", PreferenceKey.PerPage, newPerPage); + }; const { data: projectMemberships = [], isPending } = useGetOrgMembershipProjectMemberships( orgId, @@ -136,7 +148,7 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredProjectMemberships?.length && ( diff --git a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx index 24f459a93f..8f06576e2d 100644 --- a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx @@ -27,6 +27,11 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useListWorkspaceGroups } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -62,7 +67,14 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => { orderDirection, orderBy, toggleOrderDirection - } = usePagination(GroupsOrderBy.Name, { initPerPage: 20 }); + } = usePagination(GroupsOrderBy.Name, { + initPerPage: getUserTablePreference("projectGroupsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("projectGroupsTable", PreferenceKey.PerPage, newPerPage); + }; const { data: groupMemberships = [], isPending } = useListWorkspaceGroups( currentWorkspace?.id || "" @@ -183,7 +195,7 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredGroupMemberships?.length && ( diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx index c9e5630655..d20beb7380 100644 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx @@ -42,6 +42,11 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { formatProjectRoleName } from "@app/helpers/roles"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { withProjectPermission } from "@app/hoc"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; @@ -72,7 +77,14 @@ export const IdentityTab = withProjectPermission( perPage, page, setPerPage - } = usePagination(ProjectIdentityOrderBy.Name); + } = usePagination(ProjectIdentityOrderBy.Name, { + initPerPage: getUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, newPerPage); + }; const workspaceId = currentWorkspace?.id ?? ""; @@ -403,7 +415,7 @@ export const IdentityTab = withProjectPermission( page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} {!isPending && data && data?.identityMemberships.length === 0 && ( diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx index c03f7f793e..c1251bef2f 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx @@ -51,6 +51,11 @@ import { useWorkspace } from "@app/context"; import { formatProjectRoleName } from "@app/helpers/roles"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; @@ -101,12 +106,12 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { setOrderDirection, toggleOrderDirection } = usePagination(MembersOrderBy.Name, { - initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10) + initPerPage: getUserTablePreference("projectMembersTable", PreferenceKey.PerPage, 20) }); const handlePerPageChange = (newPerPage: number) => { setPerPage(newPerPage); - localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString()); + setUserTablePreference("projectMembersTable", PreferenceKey.PerPage, newPerPage); }; const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers( diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 427b37ef98..2ee383b2c1 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -13,6 +13,7 @@ import { import { PermissionConditionOperators, ProjectPermissionCommitsActions, + ProjectPermissionApprovalActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, ProjectPermissionIdentityActions, @@ -53,6 +54,15 @@ const SecretPolicyActionSchema = z.object({ [ProjectPermissionSecretActions.Create]: z.boolean().optional() }); +const ApprovalPolicyActionSchema = z.object({ + [ProjectPermissionApprovalActions.Read]: z.boolean().optional(), + [ProjectPermissionApprovalActions.Edit]: z.boolean().optional(), + [ProjectPermissionApprovalActions.Delete]: z.boolean().optional(), + [ProjectPermissionApprovalActions.Create]: z.boolean().optional(), + [ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional(), + [ProjectPermissionApprovalActions.AllowAccessBypass]: z.boolean().optional() +}); + const CmekPolicyActionSchema = z.object({ read: z.boolean().optional(), edit: z.boolean().optional(), @@ -268,7 +278,7 @@ export const projectRoleFormSchema = z.object({ .array() .default([]), [ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]), - [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretApproval]: ApprovalPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), [ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]), @@ -409,7 +419,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.CertificateTemplates, - ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Tags, ProjectPermissionSub.SecretRotation, ProjectPermissionSub.Kms, @@ -571,6 +580,28 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.SecretApproval) { + const canCreate = action.includes(ProjectPermissionApprovalActions.Create); + const canDelete = action.includes(ProjectPermissionApprovalActions.Delete); + 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] = [{}]; + + // Map actions to the keys defined in ApprovalPolicyActionSchema + if (canCreate) formVal[subject]![0][ProjectPermissionApprovalActions.Create] = true; + if (canDelete) formVal[subject]![0][ProjectPermissionApprovalActions.Delete] = true; + if (canEdit) formVal[subject]![0][ProjectPermissionApprovalActions.Edit] = true; + 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; + } + if (subject === ProjectPermissionSub.SecretRollback) { const canRead = action.includes(ProjectPermissionActions.Read); const canCreate = action.includes(ProjectPermissionActions.Create); @@ -1208,10 +1239,12 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { [ProjectPermissionSub.SecretApproval]: { title: "Secret Approval Policies", actions: [ - { label: "Read", value: "read" }, - { label: "Create", value: "create" }, - { label: "Modify", value: "edit" }, - { label: "Remove", value: "delete" } + { label: "Read", value: ProjectPermissionApprovalActions.Read }, + { label: "Create", value: ProjectPermissionApprovalActions.Create }, + { label: "Modify", value: ProjectPermissionApprovalActions.Edit }, + { label: "Remove", value: ProjectPermissionApprovalActions.Delete }, + { label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass }, + { label: "Allow Access Bypass", value: ProjectPermissionApprovalActions.AllowAccessBypass } ] }, [ProjectPermissionSub.SecretRotation]: { @@ -1697,7 +1730,7 @@ export const RoleTemplates: Record = { }, { subject: ProjectPermissionSub.SecretApproval, - actions: Object.values(ProjectPermissionActions) + actions: Object.values(ProjectPermissionApprovalActions) }, { subject: ProjectPermissionSub.ServiceTokens, diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx index a8d2cccd5d..775e42c50c 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/IntegrationsTable.tsx @@ -32,6 +32,11 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; @@ -110,7 +115,14 @@ export const IntegrationsTable = ({ orderBy, setOrderDirection, setOrderBy - } = usePagination(IntegrationsOrderBy.App, { initPerPage: 20 }); + } = usePagination(IntegrationsOrderBy.App, { + initPerPage: getUserTablePreference("integrationsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("integrationsTable", PreferenceKey.PerPage, newPerPage); + }; useEffect(() => { if (integrations?.some((integration) => integration.isSynced === false)) @@ -437,7 +449,7 @@ export const IntegrationsTable = ({ page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isLoading && !filteredIntegrations?.length && ( 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 0000000000..ad90908c19 --- /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 8989808813..cd5ff2cf79 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/SecretSyncsTable.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx index 802e88bc17..fcd42a10fa 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncsTable.tsx @@ -38,6 +38,11 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { @@ -119,7 +124,14 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => { orderBy, setOrderDirection, setOrderBy - } = usePagination(SecretSyncsOrderBy.Name, { initPerPage: 20 }); + } = usePagination(SecretSyncsOrderBy.Name, { + initPerPage: getUserTablePreference("secretSyncTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretSyncTable", PreferenceKey.PerPage, newPerPage); + }; const filteredSecretSyncs = useMemo( () => @@ -465,7 +477,7 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!filteredSecretSyncs?.length && ( 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 4bf0eeed20..4943df4717 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/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 5f0d90f6cc..c94aef0d5a 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -64,6 +64,11 @@ import { useWorkspace } from "@app/context"; import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useCreateFolder, @@ -180,7 +185,14 @@ export const OverviewPage = () => { page, setPerPage, orderBy - } = usePagination(DashboardSecretsOrderBy.Name); + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, 100) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, newPerPage); + }; const resetSelectedEntries = useCallback(() => { setSelectedEntries({ @@ -1416,7 +1428,7 @@ export const OverviewPage = () => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )}

diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index a925d3b4f5..72eb085c0b 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -232,6 +232,7 @@ export const SecretEditRow = ({ environment={environment} isImport={isImportedSecret} defaultValue={secretValueHidden ? "" : undefined} + canEditButNotView={secretValueHidden && !isOverride} /> )} /> diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx index 5483dc5972..a70a6a9017 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 37239d987f..0b9f840087 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/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 8e03c99740..05576eb592 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -29,13 +29,13 @@ import { Tr } from "@app/components/v2"; import { - ProjectPermissionActions, ProjectPermissionSub, TProjectPermission, useProjectPermission, useSubscription, useWorkspace } from "@app/context"; +import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types"; import { usePopUp } from "@app/hooks"; import { useDeleteAccessApprovalPolicy, @@ -61,8 +61,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: projectSlug: currentWorkspace?.slug as string, options: { enabled: - permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && - !!currentWorkspace?.slug + permission.can( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ) && !!currentWorkspace?.slug } } ); @@ -71,8 +73,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: workspaceId: currentWorkspace?.id as string, options: { enabled: - permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && - !!currentWorkspace?.id + permission.can( + ProjectPermissionApprovalActions.Read, + ProjectPermissionSub.SecretApproval + ) && !!currentWorkspace?.id } } ); @@ -160,7 +164,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
{(isAllowed) => ( diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index a9304e7edc..a48c158f26 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -126,8 +126,6 @@ export const AccessPolicyForm = ({ const policyName = policyDetails[watch("policyType")]?.name || "Policy"; - const approversRequired = watch("approvals") || 1; - const handleCreatePolicy = async ({ environment, groupApprovers, @@ -303,73 +301,6 @@ export const AccessPolicyForm = ({ )} /> - ( - -

- Determines the level of enforcement for required approvers of a request: -

-

- Hard enforcement requires at least{" "} - {approversRequired} approver(s) to - approve the request.` -

-

- Soft enforcement At least{" "} - {approversRequired} approver(s) must - approve the request; however, the requester can bypass approval - requirements in emergencies. -

- - } - > - -
- )} - /> - - ( - - option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - />
+ ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + />

Approvers

@@ -465,6 +418,29 @@ export const AccessPolicyForm = ({ )} /> + ( + + + onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard) + } + > + Allow request creators to bypass policy in break-glass situations + + + )} + />

@@ -175,7 +199,7 @@ export const SecretApprovalRequestAction = ({ > Merge - +
) : (
Only approvers can merge
)} @@ -186,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 212f929f7f..b7a6a8149b 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 4eebc65f4d..17046450ed 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/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index c0445df92d..1082ce2e2b 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -29,6 +29,11 @@ import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useGetImportedSecretsSingleEnv, @@ -99,7 +104,14 @@ const Page = () => { page, setPerPage, orderBy - } = usePagination(DashboardSecretsOrderBy.Name); + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, 100) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, newPerPage); + }; const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); @@ -604,7 +616,7 @@ const Page = () => { page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + onChangePerPage={handlePerPageChange} /> )} { if (secret.secretValueHidden) { - return canEditSecretValue ? hiddenValue : ""; + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; } return secret.valueOverride || secret.value || ""; }; @@ -366,10 +366,11 @@ export const SecretItem = memo( isReadOnly={isReadOnly || isRotatedSecret} key="secret-value" isVisible={isVisible && !secretValueHidden} + canEditButNotView={secretValueHidden && !isOverriden} environment={environment} secretPath={secretPath} {...field} - defaultValue={secretValueHidden ? hiddenValue : undefined} + defaultValue={secretValueHidden ? HIDDEN_SECRET_VALUE : undefined} containerClassName="py-1.5 rounded-md transition-all" /> )} 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 0000000000..09d598f310 --- /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 b0c989ee20..f443c6106b 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 a53016d536..fbd66b9f52 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: diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index 150e110644..d0633eb120 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -25,9 +25,14 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useGetWsTags } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useGetWsTags } from "@app/hooks/api/tags"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -61,7 +66,14 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { offset, orderDirection, toggleOrderDirection - } = usePagination(TagsOrderBy.Slug, { initPerPage: 10 }); + } = usePagination(TagsOrderBy.Slug, { + initPerPage: getUserTablePreference("secretTagsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretTagsTable", PreferenceKey.PerPage, newPerPage); + }; const filteredTags = useMemo( () => @@ -151,7 +163,7 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isPending && !filteredTags?.length && ( diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index e22529f9ca..68159eca4c 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.9.2 +version: v0.9.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.9.2" +appVersion: "v0.9.3" diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index 78cfe4ad76..8988502997 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.9.2 + tag: v0.9.3 resources: limits: cpu: 500m diff --git a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go index 47c55d6985..3794c2f17f 100644 --- a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go +++ b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go @@ -30,9 +30,9 @@ import ( // InfisicalSecretReconciler reconciles a InfisicalSecret object type InfisicalPushSecretReconciler struct { client.Client - - BaseLogger logr.Logger - Scheme *runtime.Scheme + IsNamespaceScoped bool + BaseLogger logr.Logger + Scheme *runtime.Scheme } var infisicalPushSecretResourceVariablesMap map[string]util.ResourceVariables = make(map[string]util.ResourceVariables) @@ -51,7 +51,7 @@ func (r *InfisicalPushSecretReconciler) GetLogger(req ctrl.Request) logr.Logger //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list //+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create //+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create -// +kubebuilder:rbac:groups=secrets.infisical.com,resources=clustergenerators,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=secrets.infisical.com,resources=clustergenerators,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // For more details, check Reconcile and its Result here: @@ -249,19 +249,26 @@ func (r *InfisicalPushSecretReconciler) SetupWithManager(mgr ctrl.Manager) error }, } - return ctrl.NewControllerManagedBy(mgr). + controllerManager := ctrl.NewControllerManagedBy(mgr). For(&secretsv1alpha1.InfisicalPushSecret{}, builder.WithPredicates( specChangeOrDelete, )). Watches( &source.Kind{Type: &corev1.Secret{}}, handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForSecret), - ). - Watches( + ) + + if !r.IsNamespaceScoped { + r.BaseLogger.Info("Watching ClusterGenerators for non-namespace scoped operator") + controllerManager.Watches( &source.Kind{Type: &secretsv1alpha1.ClusterGenerator{}}, handler.EnqueueRequestsFromMapFunc(r.findPushSecretsForClusterGenerator), - ). - Complete(r) + ) + } else { + r.BaseLogger.Info("Not watching ClusterGenerators for namespace scoped operator") + } + + return controllerManager.Complete(r) } func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o client.Object) []reconcile.Request { @@ -277,6 +284,7 @@ func (r *InfisicalPushSecretReconciler) findPushSecretsForClusterGenerator(o cli } requests := []reconcile.Request{} + for _, pushSecret := range pushSecrets.Items { if pushSecret.Spec.Push.Generators != nil { for _, generator := range pushSecret.Spec.Push.Generators { diff --git a/k8-operator/main.go b/k8-operator/main.go index d2f1905953..234b059132 100644 --- a/k8-operator/main.go +++ b/k8-operator/main.go @@ -99,9 +99,10 @@ func main() { } if err = (&infisicalPushSecretController.InfisicalPushSecretReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - BaseLogger: ctrl.Log, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + BaseLogger: ctrl.Log, + IsNamespaceScoped: namespace != "", }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "InfisicalPushSecret") os.Exit(1)