diff --git a/backend/package.json b/backend/package.json index cc02186e9..aaef9f567 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,7 @@ "build": "tsup --sourcemap", "build:frontend": "npm run build --prefix ../frontend", "start": "node --enable-source-maps dist/main.mjs", - "type:check": "tsc --noEmit", + "type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit", "lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src", "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", "test:unit": "vitest run -c vitest.unit.config.ts", diff --git a/backend/src/ee/routes/v1/github-org-sync-router.ts b/backend/src/ee/routes/v1/github-org-sync-router.ts index 3f33a5d8f..0d7279928 100644 --- a/backend/src/ee/routes/v1/github-org-sync-router.ts +++ b/backend/src/ee/routes/v1/github-org-sync-router.ts @@ -126,4 +126,39 @@ export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) => return { githubOrgSyncConfig }; } }); + + server.route({ + url: "/sync-all-teams", + method: "POST", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + totalUsers: z.number(), + errors: z.array(z.string()), + createdTeams: z.array(z.string()), + updatedTeams: z.array(z.string()), + removedMemberships: z.number(), + syncDuration: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.githubOrgSync.syncAllTeams({ + orgPermission: req.permission + }); + + return { + totalUsers: result.totalUsers, + errors: result.errors, + createdTeams: result.createdTeams, + updatedTeams: result.updatedTeams, + removedMemberships: result.removedMemberships, + syncDuration: result.syncDuration + }; + } + }); }; diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 06186d54b..ece5edaf9 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -6,9 +6,9 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; -import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { OrgPermissionAuditLogsActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionAuditLogsActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TAuditLogQueueServiceFactory } from "./audit-log-queue"; import { EventType, TAuditLogServiceFactory } from "./audit-log-types"; @@ -41,7 +41,10 @@ export const auditLogServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAuditLogsActions.Read, + ProjectPermissionSub.AuditLogs + ); } else { // Organization-wide logs const { permission } = await permissionService.getOrgPermission( @@ -52,7 +55,10 @@ export const auditLogServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAuditLogsActions.Read, + OrgPermissionSubjects.AuditLogs + ); } // If project ID is not provided, then we need to return all the audit logs for the organization itself. diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index 37d1bd398..7c4ad15eb 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -1,14 +1,19 @@ +/* eslint-disable @typescript-eslint/return-await */ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import { Octokit } from "@octokit/core"; import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; +import RE2 from "re2"; import { OrgMembershipRole } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { retryWithBackoff } from "@app/lib/retry"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; @@ -16,20 +21,67 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal"; -import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types"; +import { + TCreateGithubOrgSyncDTO, + TDeleteGithubOrgSyncDTO, + TSyncAllTeamsDTO, + TSyncResult, + TUpdateGithubOrgSyncDTO, + TValidateGithubTokenDTO +} from "./github-org-sync-types"; const OctokitWithPlugin = Octokit.plugin(paginateGraphql); +// Type definitions for GitHub API errors +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +interface OrgMembershipWithUser { + id: string; + orgId: string; + role: string; + status: string; + isActive: boolean; + inviteEmail: string | null; + user: { + id: string; + email: string; + username: string | null; + firstName: string | null; + lastName: string | null; + } | null; +} + +interface GroupMembership { + id: string; + groupId: string; + groupName: string; + orgMembershipId: string; + firstName: string | null; + lastName: string | null; +} + type TGithubOrgSyncServiceFactoryDep = { githubOrgSyncDAL: TGithubOrgSyncDALFactory; permissionService: Pick; kmsService: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, - "findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete" + "findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete" >; groupDAL: Pick; licenseService: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + "find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId" + >; }; export type TGithubOrgSyncServiceFactory = ReturnType; @@ -40,7 +92,8 @@ export const githubOrgSyncServiceFactory = ({ kmsService, userGroupMembershipDAL, groupDAL, - licenseService + licenseService, + orgMembershipDAL }: TGithubOrgSyncServiceFactoryDep) => { const createGithubOrgSync = async ({ githubOrgName, @@ -304,8 +357,8 @@ export const githubOrgSyncServiceFactory = ({ const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName)); if (newTeams.length || updateTeams.length || removeFromTeams.length) { - await groupDAL.transaction(async (tx) => { - if (newTeams.length) { + if (newTeams.length) { + await groupDAL.transaction(async (tx) => { const newGroups = await groupDAL.insertMany( newTeams.map((newGroupName) => ({ name: newGroupName, @@ -322,9 +375,11 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (updateTeams.length) { + if (updateTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.insertMany( updateTeams.map((el) => ({ groupId: githubUserTeamOnInfisicalGroupByName[el][0].id, @@ -332,16 +387,433 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (removeFromTeams.length) { + if (removeFromTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.delete( { userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } }, tx ); - } + }); + } + } + }; + + const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to validate GitHub token due to plan restriction. Upgrade plan to use GitHub organization sync." }); } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config) { + throw new BadRequestError({ message: "GitHub organization sync is not configured" }); + } + + try { + const testOctokit = new OctokitRest({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + const { data: org } = await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + const octokitGraphQL = new OctokitWithPlugin({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await octokitGraphQL.graphql(`query($org: String!) { organization(login: $org) { id name } }`, { + org: config.githubOrgName + }); + + return { + valid: true, + organizationInfo: { + id: org.id, + login: org.login, + name: org.name || org.login, + publicRepos: org.public_repos, + privateRepos: org.owned_private_repos || 0 + } + }; + } catch (error) { + logger.error(error, `GitHub token validation failed for org ${config.githubOrgName}`); + + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions. Required: 1) 'read:org' scope for organization teams, 2) Token owner must be an organization member with team visibility access, 3) Organization settings must allow team visibility. Check GitHub token scopes and organization member permissions." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization '${config.githubOrgName}' not found or access token does not have access to it.` + }); + } + } + + throw new BadRequestError({ + message: `GitHub token validation failed: ${(error as Error).message}` + }); + } + }; + + const syncAllTeams = async ({ orgPermission }: TSyncAllTeamsDTO): Promise => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.GithubOrgSyncManual + ); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to sync all GitHub teams due to plan restriction. Upgrade plan to use GitHub organization sync." + }); + } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config || !config?.isActive) { + throw new BadRequestError({ message: "GitHub organization sync is not configured or not active" }); + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + if (!config.encryptedGithubOrgAccessToken) { + throw new BadRequestError({ + message: "GitHub organization access token is required. Please set a token first." + }); + } + + const orgAccessToken = decryptor({ cipherTextBlob: config.encryptedGithubOrgAccessToken }).toString(); + + try { + const testOctokit = new OctokitRest({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + await testOctokit.rest.users.getAuthenticated(); + } catch (error) { + throw new BadRequestError({ + message: "Stored GitHub access token is invalid or expired. Please set a new token." + }); + } + + const allMembers = await orgMembershipDAL.findOrgMembershipsWithUsersByOrgId(orgPermission.orgId); + const activeMembers = allMembers.filter( + (member) => member.status === "accepted" && member.isActive + ) as OrgMembershipWithUser[]; + + const startTime = Date.now(); + const syncErrors: string[] = []; + + const octokit = new OctokitWithPlugin({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(30000) + } + }); + + const data = await retryWithBackoff(async () => { + return octokit.graphql + .paginate<{ + organization: { + teams: { + totalCount: number; + edges: { + node: { + name: string; + description: string; + members: { + edges: { + node: { + login: string; + }; + }[]; + }; + }; + }[]; + }; + }; + }>( + ` + query orgTeams($cursor: String, $org: String!) { + organization(login: $org) { + teams(first: 100, after: $cursor) { + totalCount + edges { + node { + name + description + members(first: 100) { + edges { + node { + login + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + { + org: config.githubOrgName + } + ) + .catch((err) => { + logger.error(err, "GitHub GraphQL error for batched team sync"); + + const gitHubError = err as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired. Please provide a new token." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions for organization team sync. Required: 1) 'admin:org' scope, 2) Token owner must be organization owner or have team read permissions, 3) Organization settings must allow team visibility. Check token scopes and user role." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization ${config.githubOrgName} not found or access token does not have sufficient permissions to read it.` + }); + } + } + + if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) { + throw new BadRequestError({ + message: + "Organization has restricted OAuth app access. Please check that: 1) Your organization has approved the Infisical OAuth application, 2) The token owner has sufficient organization permissions." + }); + } + throw new BadRequestError({ message: `GitHub GraphQL query failed: ${(err as Error)?.message}` }); + }); + }); + + const { + organization: { teams } + } = data; + + const userTeamMap = new Map(); + const allGithubUsernamesInTeams = new Set(); + + teams?.edges?.forEach((teamEdge) => { + const teamName = teamEdge.node.name.toLowerCase(); + + teamEdge.node.members.edges.forEach((memberEdge) => { + const username = memberEdge.node.login.toLowerCase(); + allGithubUsernamesInTeams.add(username); + + if (!userTeamMap.has(username)) { + userTeamMap.set(username, []); + } + userTeamMap.get(username)!.push(teamName); + }); + }); + + const allGithubTeamNames = Array.from(new Set(teams?.edges?.map((edge) => edge.node.name.toLowerCase()) || [])); + + const existingTeamsOnInfisical = await groupDAL.find({ + orgId: orgPermission.orgId, + $in: { name: allGithubTeamNames } + }); + const existingTeamsMap = groupBy(existingTeamsOnInfisical, (i) => i.name); + + const teamsToCreate = allGithubTeamNames.filter((teamName) => !(teamName in existingTeamsMap)); + const createdTeams = new Set(); + const updatedTeams = new Set(); + const totalRemovedMemberships = 0; + + await groupDAL.transaction(async (tx) => { + if (teamsToCreate.length > 0) { + const newGroups = await groupDAL.insertMany( + teamsToCreate.map((teamName) => ({ + name: teamName, + role: OrgMembershipRole.Member, + slug: teamName, + orgId: orgPermission.orgId + })), + tx + ); + + newGroups.forEach((group) => { + if (!existingTeamsMap[group.name]) { + existingTeamsMap[group.name] = []; + } + existingTeamsMap[group.name].push(group); + createdTeams.add(group.name); + }); + } + + const allTeams = [...Object.values(existingTeamsMap).flat()]; + + for (const team of allTeams) { + const teamName = team.name.toLowerCase(); + + const currentMemberships = (await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg( + team.id, + orgPermission.orgId + )) as GroupMembership[]; + + const expectedUserIds = new Set(); + teams?.edges?.forEach((teamEdge) => { + if (teamEdge.node.name.toLowerCase() === teamName) { + teamEdge.node.members.edges.forEach((memberEdge) => { + const githubUsername = memberEdge.node.login.toLowerCase(); + + const matchingMember = activeMembers.find((member) => { + const email = member.user?.email || member.inviteEmail; + if (!email) return false; + + const emailPrefix = email.split("@")[0].toLowerCase(); + const emailDomain = email.split("@")[1].toLowerCase(); + + if (emailPrefix === githubUsername) { + return true; + } + const domainName = emailDomain.split(".")[0]; + if (githubUsername.endsWith(domainName) && githubUsername.length > domainName.length) { + const baseUsername = githubUsername.slice(0, -domainName.length); + if (emailPrefix === baseUsername) { + return true; + } + } + const emailSplitRegex = new RE2(/[._-]/); + const emailParts = emailPrefix.split(emailSplitRegex); + const longestEmailPart = emailParts.reduce((a, b) => (a.length > b.length ? a : b), ""); + if (longestEmailPart.length >= 4 && githubUsername.includes(longestEmailPart)) { + return true; + } + return false; + }); + + if (matchingMember?.user?.id) { + expectedUserIds.add(matchingMember.user.id); + logger.info( + `Matched GitHub user ${githubUsername} to email ${matchingMember.user?.email || matchingMember.inviteEmail}` + ); + } + }); + } + }); + + const currentUserIds = new Set(); + currentMemberships.forEach((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + if (activeMember?.user?.id) { + currentUserIds.add(activeMember.user.id); + } + }); + + const usersToAdd = Array.from(expectedUserIds).filter((userId) => !currentUserIds.has(userId)); + + const membershipsToRemove = currentMemberships.filter((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + return activeMember?.user?.id && !expectedUserIds.has(activeMember.user.id); + }); + + if (usersToAdd.length > 0) { + await userGroupMembershipDAL.insertMany( + usersToAdd.map((userId) => ({ + userId, + groupId: team.id + })), + tx + ); + updatedTeams.add(teamName); + } + + if (membershipsToRemove.length > 0) { + await userGroupMembershipDAL.delete( + { + $in: { + id: membershipsToRemove.map((m) => m.id) + } + }, + tx + ); + updatedTeams.add(teamName); + } + } + }); + + const syncDuration = Date.now() - startTime; + + logger.info( + { + orgId: orgPermission.orgId, + createdTeams: createdTeams.size, + syncDuration + }, + "GitHub team sync completed" + ); + + return { + totalUsers: activeMembers.length, + errors: syncErrors, + createdTeams: Array.from(createdTeams), + updatedTeams: Array.from(updatedTeams), + removedMemberships: totalRemovedMemberships, + syncDuration + }; }; return { @@ -349,6 +821,8 @@ export const githubOrgSyncServiceFactory = ({ updateGithubOrgSync, deleteGithubOrgSync, getGithubOrgSync, - syncUserGroups + syncUserGroups, + syncAllTeams, + validateGithubToken }; }; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts index e1df71e82..909414c2f 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts @@ -21,3 +21,21 @@ export interface TDeleteGithubOrgSyncDTO { export interface TGetGithubOrgSyncDTO { orgPermission: OrgServiceActor; } + +export interface TSyncAllTeamsDTO { + orgPermission: OrgServiceActor; +} + +export interface TSyncResult { + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; +} + +export interface TValidateGithubTokenDTO { + orgPermission: OrgServiceActor; + githubOrgAccessToken: string; +} diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 349130d8e..9329c3c7f 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, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -394,7 +395,7 @@ const buildMemberPermissionRules = () => { ); can([ProjectPermissionActions.Read], ProjectPermissionSub.Role); - can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs); + can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs); can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); // double check if all CRUD are needed for CA and Certificates @@ -502,7 +503,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionAuditLogsActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 2436dae2a..d4155d02c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -23,6 +23,10 @@ export enum OrgPermissionAppConnectionActions { Connect = "connect" } +export enum OrgPermissionAuditLogsActions { + Read = "read" +} + export enum OrgPermissionKmipActions { Proxy = "proxy", Setup = "setup" @@ -90,6 +94,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Scim = "scim", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", Ldap = "ldap", Groups = "groups", Billing = "billing", @@ -119,13 +124,14 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] - | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway] | [ @@ -188,6 +194,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.GithubOrgSync).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GithubOrgSyncManual).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -214,7 +224,9 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ }), z.object({ subject: z.literal(OrgPermissionSubjects.AuditLogs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAuditLogsActions).describe( + "Describe what action an entity can take." + ) }), z.object({ subject: z.literal(OrgPermissionSubjects.ProjectTemplates).describe("The entity this permission pertains to."), @@ -309,6 +321,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); @@ -340,10 +357,7 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); @@ -416,7 +430,7 @@ const buildMemberPermission = () => { can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index ab8fea5df..20b4344d3 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -164,6 +164,10 @@ export enum ProjectPermissionSecretEventActions { SubscribeImportMutations = "subscribe-on-import-mutations" } +export enum ProjectPermissionAuditLogsActions { + Read = "read" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -304,7 +308,7 @@ export type ProjectPermissionSet = | [ProjectPermissionGroupActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] - | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs] | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] @@ -645,7 +649,7 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.AuditLogs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionAuditLogsActions).describe( "Describe what action an entity can take." ) }), diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 3f7b8dc9f..777e73fe2 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -41,6 +41,7 @@ export const KeyStorePrefixes = { SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, + IdentityLockoutLock: (lockoutKey: string) => `identity-lockout-lock-${lockoutKey}` as const, CaOrderCertificateForSubscriberLock: (subscriberId: string) => `ca-order-certificate-for-subscriber-lock-${subscriberId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 80a742bfb..64ed15073 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2174,7 +2174,9 @@ export const CertificateAuthorities = { directoryUrl: `The directory URL for the ACME Certificate Authority.`, accountEmail: `The email address for the ACME Certificate Authority.`, provider: `The DNS provider for the ACME Certificate Authority.`, - hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.` + hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.`, + eabKid: `The External Account Binding (EAB) Key ID for the ACME Certificate Authority. Required if the ACME provider uses EAB.`, + eabHmacKey: `The External Account Binding (EAB) HMAC key for the ACME Certificate Authority. Required if the ACME provider uses EAB.` }, INTERNAL: { type: "The type of CA to create.", diff --git a/backend/src/lib/retry/index.ts b/backend/src/lib/retry/index.ts new file mode 100644 index 000000000..de7115399 --- /dev/null +++ b/backend/src/lib/retry/index.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-await-in-loop */ +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); + +export const retryWithBackoff = async (fn: () => Promise, maxRetries = 3, baseDelay = 1000): Promise => { + let lastError: Error; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode === 403) { + const rateLimitReset = gitHubError.response?.headers?.["x-ratelimit-reset"]; + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset, 10) * 1000; + const waitTime = Math.max(resetTime - Date.now(), baseDelay); + await delay(Math.min(waitTime, 60000)); + } else { + await delay(baseDelay * 2 ** attempt); + } + } else if (attempt < maxRetries) { + await delay(baseDelay * 2 ** attempt); + } + } + } + + throw lastError!; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a19922538..dedee0e61 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -680,7 +680,8 @@ export const registerRoutes = async ( kmsService, permissionService, groupDAL, - userGroupMembershipDAL + userGroupMembershipDAL, + orgMembershipDAL }); const ldapService = ldapConfigServiceFactory({ @@ -1747,7 +1748,8 @@ export const registerRoutes = async ( const migrationService = externalMigrationServiceFactory({ externalMigrationQueue, userDAL, - permissionService + permissionService, + gatewayService }); const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 48d536c14..a54bd5ccf 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -703,6 +703,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { // prevent older projects from accessing endpoint if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + // verify folder exists and user has project permission + await server.services.folder.getFolderByPath({ projectId, environment, secretPath }, req.permission); + const tags = req.query.tags?.split(",") ?? []; let remainingLimit = limit; diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index 4325692da..259a97ddb 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -66,7 +66,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider vaultAccessToken: z.string(), vaultNamespace: z.string().trim().optional(), vaultUrl: z.string(), - mappingType: z.nativeEnum(VaultMappingType) + mappingType: z.nativeEnum(VaultMappingType), + gatewayId: z.string().optional() }) }, onRequest: verifyAuth([AuthMode.JWT]), diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 55fc094b7..ce0d4f188 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -419,6 +419,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { 200: z.object({ secret: secretRawSchema.extend({ secretValueHidden: z.boolean(), + secretPath: z.string(), tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.optional() }) diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 691f6c4c9..869364853 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -600,7 +600,7 @@ export const appConnectionServiceFactory = ({ azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), - hcvault: hcVaultConnectionService(connectAppConnectionById), + hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService), windmill: windmillConnectionService(connectAppConnectionById), teamcity: teamcityConnectionService(connectAppConnectionById), oci: ociConnectionService(connectAppConnectionById, licenseService), diff --git a/backend/src/services/app-connection/auth0/auth0-connection-fns.ts b/backend/src/services/app-connection/auth0/auth0-connection-fns.ts index 5a9989b43..de4faf683 100644 --- a/backend/src/services/app-connection/auth0/auth0-connection-fns.ts +++ b/backend/src/services/app-connection/auth0/auth0-connection-fns.ts @@ -91,7 +91,7 @@ export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0 }; } catch (e: unknown) { throw new BadRequestError({ - message: (e as Error).message ?? `Unable to validate connection: verify credentials` + message: (e as Error).message ?? "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts index 80e65a821..d173a47b5 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts @@ -70,7 +70,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 41dbb4392..2614cfd12 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -186,7 +186,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts index 39a330360..a3a9f10bd 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -204,7 +204,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 1f88488c0..d6a260050 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -186,7 +186,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts index 90d9744b8..91a033c0e 100644 --- a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts +++ b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts @@ -82,7 +82,7 @@ export const validateCamundaConnectionCredentials = async (appConnection: TCamun }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts index a35cc4aec..8912ad936 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts @@ -89,7 +89,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts index fba852a0a..7349f89a6 100644 --- a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts @@ -114,7 +114,7 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa } throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 164aa31d9..05e6fdda3 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -447,7 +447,7 @@ export const validateGitHubConnectionCredentials = async ( } throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 48695a2a5..46a59bcec 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -1,18 +1,18 @@ -import { AxiosError } from "axios"; +import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import https from "https"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { - THCVaultConnection, - THCVaultConnectionConfig, - THCVaultMountResponse, - TValidateHCVaultConnectionCredentials -} from "./hc-vault-connection-types"; +import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -37,7 +37,78 @@ type TokenRespData = { }; }; -export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => { +export const requestWithHCVaultGateway = async ( + appConnection: { gatewayId?: string | null }, + gatewayService: Pick, + requestConfig: AxiosRequestConfig +): Promise> => { + const { gatewayId } = appConnection; + + // If gateway isn't set up, don't proxy request + if (!gatewayId) { + return request.request(requestConfig); + } + + const url = new URL(requestConfig.url as string); + + await blockLocalAndPrivateIpAddresses(url.toString()); + + const [targetHost] = await verifyHostInputValidity(url.hostname, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + return withGatewayProxy( + async (proxyPort) => { + const httpsAgent = new https.Agent({ + servername: targetHost + }); + + url.protocol = "https:"; + url.host = `localhost:${proxyPort}`; + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString(), + httpsAgent, + headers: { + ...requestConfig.headers, + Host: targetHost + } + }; + + try { + return await request.request(finalRequestConfig); + } catch (error) { + if (error instanceof AxiosError) { + logger.error( + { message: error.message, data: (error.response as undefined | { data: unknown })?.data }, + "Error during HashiCorp Vault gateway request:" + ); + } + throw error; + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); +}; + +export const getHCVaultAccessToken = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { // Return access token directly if not using AppRole method if (connection.method !== HCVaultConnectionMethod.AppRole) { return connection.credentials.accessToken; @@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti // Generate temporary token for AppRole method try { const { instanceUrl, roleId, secretId } = connection.credentials; - const tokenResp = await request.post( - `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, - { role_id: roleId, secret_id: secretId }, - { - headers: { - "Content-Type": "application/json", - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) - } - } - ); + + const tokenResp = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, + method: "POST", + headers: { + "Content-Type": "application/json", + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + }, + data: { role_id: roleId, secret_id: secretId } + }); if (tokenResp.status !== 200) { throw new BadRequestError({ @@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti } }; -export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => { - const instanceUrl = await getHCVaultInstanceUrl(config); +export const validateHCVaultConnectionCredentials = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); try { - const accessToken = await getHCVaultAccessToken(config); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); // Verify token - await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, { + await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/auth/token/lookup-self`, + method: "GET", headers: { "X-Vault-Token": accessToken } }); - return config.credentials; + return connection.credentials; } catch (error: unknown) { + logger.error(error, "Unable to verify HC Vault connection"); + if (error instanceof AxiosError) { throw new BadRequestError({ message: `Failed to validate credentials: ${error.message || "Unknown error"}` }); } + + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ message: "Unable to validate connection: verify credentials" }); } }; -export const listHCVaultMounts = async (appConnection: THCVaultConnection) => { - const instanceUrl = await getHCVaultInstanceUrl(appConnection); - const accessToken = await getHCVaultAccessToken(appConnection); +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); - const { data } = await request.get(`${instanceUrl}/v1/sys/mounts`, { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/mounts`, + method: "GET", headers: { "X-Vault-Token": accessToken, - ...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {}) + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) } }); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts index a6db4d0eb..57e8fbeaf 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts @@ -55,11 +55,18 @@ export const HCVaultConnectionSchema = z.intersection( export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [ BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AccessToken), - credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({}) + credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({ + namespace: true, + instanceUrl: true + }) }), BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AppRole), - credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({}) + credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({ + namespace: true, + instanceUrl: true, + roleId: true + }) }) ]); @@ -81,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion(" ]); export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault) + GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }) ); export const UpdateHCVaultConnectionSchema = z @@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z .optional() .describe(AppConnections.UPDATE(AppConnection.HCVault).credentials) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault)); + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })); export const HCVaultConnectionListItemSchema = z.object({ name: z.literal("HCVault"), diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index b5cee6fdd..589c7c1bd 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -1,3 +1,4 @@ +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; @@ -11,12 +12,15 @@ type TGetAppConnectionFunc = ( actor: OrgServiceActor ) => Promise; -export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +export const hcVaultConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + gatewayService: Pick +) => { const listMounts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor); try { - const mounts = await listHCVaultMounts(appConnection); + const mounts = await listHCVaultMounts(appConnection, gatewayService); return mounts; } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index e3afb754a..0e5ed2ad9 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -453,10 +453,14 @@ export const authLoginServiceFactory = ({ const selectedOrg = await orgDAL.findById(organizationId); - // Check if authEnforced is true, if that's the case, throw an error - if (selectedOrg.authEnforced) { + // Check if authEnforced is true and the current auth method is not an enforced method + if ( + selectedOrg.authEnforced && + !isAuthMethodSaml(decodedToken.authMethod) && + decodedToken.authMethod !== AuthMethod.OIDC + ) { throw new BadRequestError({ - message: "Authentication is required by your organization before you can log in." + message: "Login with the auth method required by your organization." }); } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index 86beadffe..830378ca8 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -64,6 +64,8 @@ type DBConfigurationColumn = { directoryUrl: string; accountEmail: string; hostedZoneId: string; + eabKid?: string; + eabHmacKey?: string; }; export const castDbEntryToAcmeCertificateAuthority = ( @@ -89,7 +91,9 @@ export const castDbEntryToAcmeCertificateAuthority = ( hostedZoneId: dbConfigurationCol.hostedZoneId }, directoryUrl: dbConfigurationCol.directoryUrl, - accountEmail: dbConfigurationCol.accountEmail + accountEmail: dbConfigurationCol.accountEmail, + eabKid: dbConfigurationCol.eabKid, + eabHmacKey: dbConfigurationCol.eabHmacKey }, status: ca.status as CaStatus }; @@ -128,7 +132,7 @@ export const AcmeCertificateAuthorityFns = ({ }); } - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { @@ -171,7 +175,9 @@ export const AcmeCertificateAuthorityFns = ({ directoryUrl, accountEmail, dnsProvider: dnsProviderConfig.provider, - hostedZoneId: dnsProviderConfig.hostedZoneId + hostedZoneId: dnsProviderConfig.hostedZoneId, + eabKid, + eabHmacKey } }, tx @@ -214,7 +220,7 @@ export const AcmeCertificateAuthorityFns = ({ }) => { const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { if (configuration) { - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { @@ -254,7 +260,9 @@ export const AcmeCertificateAuthorityFns = ({ directoryUrl, accountEmail, dnsProvider: dnsProviderConfig.provider, - hostedZoneId: dnsProviderConfig.hostedZoneId + hostedZoneId: dnsProviderConfig.hostedZoneId, + eabKid, + eabHmacKey } }, tx @@ -354,10 +362,19 @@ export const AcmeCertificateAuthorityFns = ({ await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); - const acmeClient = new acme.Client({ + const acmeClientOptions: acme.ClientOptions = { directoryUrl: acmeCa.configuration.directoryUrl, accountKey - }); + }; + + if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { + acmeClientOptions.externalAccountBinding = { + kid: acmeCa.configuration.eabKid, + hmacKey: acmeCa.configuration.eabHmacKey + }; + } + + const acmeClient = new acme.Client(acmeClientOptions); const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts index 56b3118cf..70b0cb0e1 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts @@ -18,7 +18,9 @@ export const AcmeCertificateAuthorityConfigurationSchema = z.object({ hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId) }), directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl), - accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail) + accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail), + eabKid: z.string().trim().max(64).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabKid), + eabHmacKey: z.string().trim().max(512).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabHmacKey) }); export const AcmeCertificateAuthorityCredentialsSchema = z.object({ diff --git a/backend/src/services/external-migration/external-migration-fns/vault.ts b/backend/src/services/external-migration/external-migration-fns/vault.ts index 1ebd56a80..f5f57aa1b 100644 --- a/backend/src/services/external-migration/external-migration-fns/vault.ts +++ b/backend/src/services/external-migration/external-migration-fns/vault.ts @@ -1,12 +1,21 @@ +import https from "node:https"; + import axios, { AxiosInstance } from "axios"; import { v4 as uuidv4 } from "uuid"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { InfisicalImportData, VaultMappingType } from "../external-migration-types"; +enum KvVersion { + V1 = "1", + V2 = "2" +} + type VaultData = { namespace: string; mount: string; @@ -14,7 +23,42 @@ type VaultData = { secretData: Record; }; -const vaultFactory = () => { +const vaultFactory = (gatewayService: Pick) => { + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost?: string; + targetPort?: number; + }, + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port, httpsAgent) => { + const res = await gatewayCallback("http://localhost", port, httpsAgent); + return res; + }, + { + protocol: GatewayProxyProtocol.Http, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + + return callbackResult; + }; + const getMounts = async (request: AxiosInstance) => { const response = await request .get<{ @@ -31,11 +75,24 @@ const vaultFactory = () => { const getPaths = async ( request: AxiosInstance, - { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string } + { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string }, + kvVersion: KvVersion ) => { try { - // For KV v2: /v1/{mount}/metadata/{path}?list=true - const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + if (kvVersion === KvVersion.V2) { + // For KV v2: /v1/{mount}/metadata/{path}?list=true + const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + const response = await request.get<{ + data: { + keys: string[]; + }; + }>(`/v1/${path}?list=true`); + + return response.data.data.keys; + } + + // kv version v1: /v1/{mount}?list=true + const path = secretPath ? `${mountPath}/${secretPath}` : mountPath; const response = await request.get<{ data: { keys: string[]; @@ -56,21 +113,42 @@ const vaultFactory = () => { const getSecrets = async ( request: AxiosInstance, - { mountPath, secretPath }: { mountPath: string; secretPath: string } + { mountPath, secretPath }: { mountPath: string; secretPath: string }, + kvVersion: KvVersion ) => { - // For KV v2: /v1/{mount}/data/{path} + if (kvVersion === KvVersion.V2) { + // For KV v2: /v1/{mount}/data/{path} + const response = await request + .get<{ + data: { + data: Record; // KV v2 has nested data structure + metadata: { + created_time: string; + deletion_time: string; + destroyed: boolean; + version: number; + }; + }; + }>(`/v1/${mountPath}/data/${secretPath}`) + .catch((err) => { + if (axios.isAxiosError(err)) { + logger.error(err.response?.data, "External migration: Failed to get Vault secret"); + } + throw err; + }); + + return response.data.data.data; + } + + // kv version v1 + const response = await request .get<{ - data: { - data: Record; // KV v2 has nested data structure - metadata: { - created_time: string; - deletion_time: string; - destroyed: boolean; - version: number; - }; - }; - }>(`/v1/${mountPath}/data/${secretPath}`) + data: Record; // KV v1 has flat data structure + lease_duration: number; + lease_id: string; + renewable: boolean; + }>(`/v1/${mountPath}/${secretPath}`) .catch((err) => { if (axios.isAxiosError(err)) { logger.error(err.response?.data, "External migration: Failed to get Vault secret"); @@ -78,7 +156,7 @@ const vaultFactory = () => { throw err; }); - return response.data.data.data; + return response.data.data; }; // helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1) @@ -89,9 +167,10 @@ const vaultFactory = () => { const recursivelyGetAllPaths = async ( request: AxiosInstance, mountPath: string, + kvVersion: KvVersion, currentPath: string = "" ): Promise => { - const paths = await getPaths(request, { mountPath, secretPath: currentPath }); + const paths = await getPaths(request, { mountPath, secretPath: currentPath }, kvVersion); if (paths === null || paths.length === 0) { return []; @@ -105,7 +184,7 @@ const vaultFactory = () => { if (path.endsWith("/")) { // it's a folder so we recurse into it - const subSecrets = await recursivelyGetAllPaths(request, mountPath, fullItemPath); + const subSecrets = await recursivelyGetAllPaths(request, mountPath, kvVersion, fullItemPath); allSecrets.push(...subSecrets); } else { // it's a secret so we add it to our results @@ -119,60 +198,93 @@ const vaultFactory = () => { async function collectVaultData({ baseUrl, namespace, - accessToken + accessToken, + gatewayId }: { baseUrl: string; namespace?: string; accessToken: string; + gatewayId?: string; }): Promise { - const request = axios.create({ - baseURL: baseUrl, - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + const getData = async (host: string, port?: number, httpsAgent?: https.Agent) => { + const allData: VaultData[] = []; + + const request = axios.create({ + baseURL: port ? `${host}:${port}` : host, + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + }, + httpsAgent + }); + + // Get all mounts in this namespace + const mounts = await getMounts(request); + + for (const mount of Object.keys(mounts)) { + if (!mount.endsWith("/")) { + delete mounts[mount]; + } } - }); - const allData: VaultData[] = []; + for await (const [mountPath, mountInfo] of Object.entries(mounts)) { + // skip non-KV mounts + if (!mountInfo.type.startsWith("kv")) { + // eslint-disable-next-line no-continue + continue; + } - // Get all mounts in this namespace - const mounts = await getMounts(request); + const kvVersion = mountInfo.options?.version === "2" ? KvVersion.V2 : KvVersion.V1; - for (const mount of Object.keys(mounts)) { - if (!mount.endsWith("/")) { - delete mounts[mount]; + // get all paths in this mount + const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`, kvVersion); + + const cleanMountPath = mountPath.replace(/\/$/, ""); + + for await (const secretPath of paths) { + // get the actual secret data + const secretData = await getSecrets( + request, + { + mountPath: cleanMountPath, + secretPath: secretPath.replace(`${cleanMountPath}/`, "") + }, + kvVersion + ); + + allData.push({ + namespace: namespace || "", + mount: mountPath.replace(/\/$/, ""), + path: secretPath.replace(`${cleanMountPath}/`, ""), + secretData + }); + } } + + return allData; + }; + + let data; + + if (gatewayId) { + const url = new URL(baseUrl); + + const { port, protocol, hostname } = url; + const cleanedProtocol = protocol.slice(0, -1); + + data = await $gatewayProxyWrapper( + { + gatewayId, + targetHost: `${cleanedProtocol}://${hostname}`, + targetPort: port ? Number(port) : 8200 // 8200, default port for Vault self-hosted/dedicated + }, + getData + ); + } else { + data = await getData(baseUrl); } - for await (const [mountPath, mountInfo] of Object.entries(mounts)) { - // skip non-KV mounts - if (!mountInfo.type.startsWith("kv")) { - // eslint-disable-next-line no-continue - continue; - } - - // get all paths in this mount - const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`); - - const cleanMountPath = mountPath.replace(/\/$/, ""); - - for await (const secretPath of paths) { - // get the actual secret data - const secretData = await getSecrets(request, { - mountPath: cleanMountPath, - secretPath: secretPath.replace(`${cleanMountPath}/`, "") - }); - - allData.push({ - namespace: namespace || "", - mount: mountPath.replace(/\/$/, ""), - path: secretPath.replace(`${cleanMountPath}/`, ""), - secretData - }); - } - } - - return allData; + return data; } return { @@ -296,17 +408,22 @@ export const transformToInfisicalFormatNamespaceToProjects = ( }; }; -export const importVaultDataFn = async ({ - vaultAccessToken, - vaultNamespace, - vaultUrl, - mappingType -}: { - vaultAccessToken: string; - vaultNamespace?: string; - vaultUrl: string; - mappingType: VaultMappingType; -}) => { +export const importVaultDataFn = async ( + { + vaultAccessToken, + vaultNamespace, + vaultUrl, + mappingType, + gatewayId + }: { + vaultAccessToken: string; + vaultNamespace?: string; + vaultUrl: string; + mappingType: VaultMappingType; + gatewayId?: string; + }, + { gatewayService }: { gatewayService: Pick } +) => { await blockLocalAndPrivateIpAddresses(vaultUrl); if (mappingType === VaultMappingType.Namespace && !vaultNamespace) { @@ -315,12 +432,13 @@ export const importVaultDataFn = async ({ }); } - const vaultApi = vaultFactory(); + const vaultApi = vaultFactory(gatewayService); const vaultData = await vaultApi.collectVaultData({ accessToken: vaultAccessToken, baseUrl: vaultUrl, - namespace: vaultNamespace + namespace: vaultNamespace, + gatewayId }); const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 3bbb88f5b..f1047d0ee 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,4 +1,5 @@ import { OrgMembershipRole } from "@app/db/schemas"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; @@ -12,6 +13,7 @@ type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; externalMigrationQueue: TExternalMigrationQueueFactory; userDAL: Pick; + gatewayService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -19,7 +21,8 @@ export type TExternalMigrationServiceFactory = ReturnType { const importEnvKeyData = async ({ decryptionKey, @@ -72,6 +75,7 @@ export const externalMigrationServiceFactory = ({ vaultNamespace, mappingType, vaultUrl, + gatewayId, actor, actorId, actorOrgId, @@ -91,12 +95,18 @@ export const externalMigrationServiceFactory = ({ const user = await userDAL.findById(actorId); - const vaultData = await importVaultDataFn({ - vaultAccessToken, - vaultNamespace, - vaultUrl, - mappingType - }); + const vaultData = await importVaultDataFn( + { + vaultAccessToken, + vaultNamespace, + vaultUrl, + mappingType, + gatewayId + }, + { + gatewayService + } + ); const stringifiedJson = JSON.stringify({ data: vaultData, diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 7c0d4c9ea..ac8ff44e2 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -31,6 +31,7 @@ export type TImportVaultDataDTO = { vaultNamespace?: string; mappingType: VaultMappingType; vaultUrl: string; + gatewayId?: string; } & Omit; export type TImportInfisicalDataCreate = { diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 3aeb5e83c..597747683 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -8,11 +8,18 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + NotFoundError, + PermissionBoundaryError, + RateLimitError, + UnauthorizedError +} from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -40,7 +47,10 @@ type TIdentityUaServiceFactoryDep = { identityOrgMembershipDAL: TIdentityOrgDALFactory; permissionService: Pick; licenseService: Pick; - keyStore: Pick; + keyStore: Pick< + TKeyStoreFactory, + "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" + >; }; export type TIdentityUaServiceFactory = ReturnType; @@ -74,17 +84,21 @@ export const identityUaServiceFactory = ({ const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); - if (!identityMembershipOrg) { - throw new UnauthorizedError({ - message: "Invalid credentials" + let lock: Awaited>; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Rate limit exceeded" }); } - const identityTx = await identityUaDAL.transaction(async (tx) => { - await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.IdentityLogin(identityUa.identityId, clientId)]); - - // Lockout Check + try { const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); let lockout: LockoutObject | undefined; @@ -98,6 +112,13 @@ export const identityUaServiceFactory = ({ }); } + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + if (!identityMembershipOrg) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + const clientSecretPrefix = clientSecret.slice(0, 4); const clientSecretInfo = await identityUaClientSecretDAL.find({ identityUAId: identityUa.id, @@ -159,7 +180,7 @@ export const identityUaServiceFactory = ({ } } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { // number of times client secret can be used for // a login operation reached await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { @@ -183,57 +204,61 @@ export const identityUaServiceFactory = ({ accessTokenMaxTTL: 1000000000 }; - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx); - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityUa.identityId, + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams + }, + tx + ); + + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = crypto.jwt().sign( { identityId: identityUa.identityId, - isAccessTokenRevoked: false, - identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - accessTokenPeriod: identityUa.accessTokenPeriod, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - ...accessTokenTTLParams - }, - tx + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return { newToken, validClientSecretInfo, accessTokenTTLParams }; - }); - - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityUa.identityId, - clientSecretId: identityTx.validClientSecretInfo.id, - identityAccessTokenId: identityTx.newToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityTx.newToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityTx.newToken.accessTokenTTL) - } - ); - - return { - accessToken, - identityUa, - validClientSecretInfo: identityTx.validClientSecretInfo, - identityAccessToken: identityTx.newToken, - identityMembershipOrg, - ...identityTx.accessTokenTTLParams - }; + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identityMembershipOrg, + ...accessTokenTTLParams + }; + } finally { + await lock.release(); + } }; const attachUniversalAuth = async ({ diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 7cef4a6cc..2d7992d34 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -153,10 +153,64 @@ export const orgMembershipDALFactory = (db: TDbClient) => { } }; + const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => { + try { + const members = await db + .replicaNode()(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId") + ) + .where({ isGhost: false }); + + return members.map((member) => ({ + id: member.id, + orgId: member.orgId, + role: member.role, + status: member.status, + isActive: member.isActive, + inviteEmail: member.inviteEmail, + user: { + id: member.userId, + email: member.email, + username: member.username, + firstName: member.firstName, + lastName: member.lastName + } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find org memberships with users by org id" }); + } + }; + return { ...orgMembershipOrm, findOrgMembershipById, findRecentInvitedMemberships, - updateLastInvitedAtByIds + updateLastInvitedAtByIds, + findOrgMembershipsWithUsersByOrgId }; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 90cc25710..c5eb0adde 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -30,6 +30,7 @@ import { TDeleteFolderDTO, TDeleteManyFoldersDTO, TGetFolderByIdDTO, + TGetFolderByPathDTO, TGetFolderDTO, TGetFoldersDeepByEnvsDTO, TUpdateFolderDTO, @@ -1398,6 +1399,31 @@ export const secretFolderServiceFactory = ({ }; }; + const getFolderByPath = async ( + { projectId, environment, secretPath }: TGetFolderByPathDTO, + actor: OrgServiceActor + ) => { + // folder check is allowed to be read by anyone + // permission is to check if user has access + await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new NotFoundError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + return folder; + }; + return { createFolder, updateFolder, @@ -1405,6 +1431,7 @@ export const secretFolderServiceFactory = ({ deleteFolder, getFolders, getFolderById, + getFolderByPath, getProjectFolderCount, getFoldersMultiEnv, getFoldersDeepByEnvs, diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index ae8e2c5dc..eed815da5 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = { idOrName: string; }>; }; + +export type TGetFolderByPathDTO = { + projectId: string; + environment: string; + secretPath: string; +}; diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index 724eec7be..9168c96a6 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -1,9 +1,13 @@ import { isAxiosError } from "axios"; -import { request } from "@app/lib/config/request"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { removeTrailingSlash } from "@app/lib/fn"; -import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; -import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault"; +import { + getHCVaultAccessToken, + getHCVaultInstanceUrl, + requestWithHCVaultGateway, + THCVaultConnection +} from "@app/services/app-connection/hc-vault"; import { THCVaultListVariables, THCVaultListVariablesResponse, @@ -14,19 +18,20 @@ 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 listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => { - await blockLocalAndPrivateIpAddresses(instanceUrl); - +const listHCVaultVariables = async ( + { instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables, + connection: THCVaultConnection, + gatewayService: Pick +) => { try { - const { data } = await request.get( - `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, - { - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}) - } + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) } - ); + }); return data.data.data; } catch (error: unknown) { @@ -39,33 +44,29 @@ const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken }; // Hashicorp Vault updates all variables in one batch. This is to respect their versioning -const updateHCVaultVariables = async ({ - path, - instanceUrl, - namespace, - accessToken, - mount, - data -}: TPostHCVaultVariable) => { - await blockLocalAndPrivateIpAddresses(instanceUrl); - - return request.post( - `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, - { - data +const updateHCVaultVariables = async ( + { path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable, + connection: THCVaultConnection, + gatewayService: Pick +) => { + return requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + method: "POST", + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}), + "Content-Type": "application/json" }, - { - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}), - "Content-Type": "application/json" - } - } - ); + data: { data } + }); }; export const HCVaultSyncFns = { - syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + syncSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, environment, @@ -74,16 +75,20 @@ export const HCVaultSyncFns = { } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ - instanceUrl, - accessToken, - namespace, - mount, - path - }); + const variables = await listHCVaultVariables( + { + instanceUrl, + accessToken, + namespace, + mount, + path + }, + connection, + gatewayService + ); let tainted = false; for (const entry of Object.entries(secretMap)) { @@ -110,24 +115,36 @@ export const HCVaultSyncFns = { if (!tainted) return; try { - await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + await updateHCVaultVariables( + { accessToken, instanceUrl, namespace, mount, path, data: variables }, + connection, + gatewayService + ); } catch (error) { throw new SecretSyncError({ error }); } }, - removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + removeSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path }); + const variables = await listHCVaultVariables( + { instanceUrl, namespace, accessToken, mount, path }, + connection, + gatewayService + ); for await (const [key] of Object.entries(variables)) { if (key in secretMap) { @@ -136,30 +153,41 @@ export const HCVaultSyncFns = { } try { - await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + await updateHCVaultVariables( + { accessToken, instanceUrl, namespace, mount, path, data: variables }, + connection, + gatewayService + ); } catch (error) { throw new SecretSyncError({ error }); } }, - getSecrets: async (secretSync: THCVaultSyncWithCredentials) => { + getSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + gatewayService: Pick + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ - instanceUrl, - namespace, - accessToken, - mount, - path - }); + const variables = await listHCVaultVariables( + { + instanceUrl, + namespace, + accessToken, + mount, + path + }, + connection, + gatewayService + ); return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }])); } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 3ff7cefbc..5b083baf6 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -244,7 +244,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: @@ -283,7 +283,7 @@ export const SecretSyncFns = { }, getSecrets: async ( secretSync: TSecretSyncWithCredentials, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { let secretMap: TSecretMap; switch (secretSync.destination) { @@ -341,7 +341,7 @@ export const SecretSyncFns = { secretMap = await WindmillSyncFns.getSecrets(secretSync); break; case SecretSync.HCVault: - secretMap = await HCVaultSyncFns.getSecrets(secretSync); + secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService); break; case SecretSync.TeamCity: secretMap = await TeamCitySyncFns.getSecrets(secretSync); @@ -451,7 +451,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 79bd97c95..35912fc8c 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -10,7 +10,7 @@ should approach the development and contribution process. Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. -- The [Infisical SDK](https://github.com/Infisical/sdk), the official Infisical client SDKs. +- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index ef139e8e4..8254e104f 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -8,12 +8,12 @@ description: "Learn how to migrate secrets from Vault to Infisical." Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. -Currently the Vault migration only supports migrating secrets from the KV v2 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). ### Prerequisites -- A Vault instance with the KV v2 secrets engine enabled. +- A Vault instance with the KV secret engine enabled. - An access token to your Vault instance. diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx index 519e12db8..71dd9dbf6 100644 --- a/docs/documentation/platform/github-org-sync.mdx +++ b/docs/documentation/platform/github-org-sync.mdx @@ -45,6 +45,64 @@ Once configured, the GitHub Organization Synchronization feature functions as fo When a user logs in via the GitHub OAuth flow and selects the configured organization, the system will then automatically synchronize the teams they are a part of in GitHub with corresponding groups in Infisical. +## Manual Team Sync + +You can manually synchronize GitHub teams for all organization members who have previously logged in with GitHub. This bulk sync operation updates team memberships without requiring users to log in again. + + + + To perform manual syncs, you'll need to create a GitHub Personal Access Token with the appropriate permissions. GitHub offers two types of tokens: + + + + 1. Go to [GitHub Settings → Personal Access Tokens → Tokens (classic)](https://github.com/settings/tokens) + 2. Click **Generate new token** → **Generate new token (classic)** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select the **read:org** scope - Required to read organization team information + 6. Click **Generate token** + 7. Copy the token immediately (you won't be able to see it again) + + ![Classic Token Creation](../../images/platform/external-syncs/github-classic-token.png) + + + 1. Go to [GitHub Settings → Personal Access Tokens → Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) + 2. Click **Generate new token** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select your organization under **Resource owner** + 6. Under **Organization permissions**, set **Members** to **Read** + 7. Click **Generate token** + 8. Copy the token immediately (you won't be able to see it again) + + ![Fine-grained Token Creation](../../images/platform/external-syncs/github-fine-grained-token.png) + + + + + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. Click the **Configure** button next to your GitHub Organization configuration. + 3. In the configuration modal, you'll find an optional **GitHub Access Token** field. + 4. Paste the token you generated in the previous step. + 5. Click **Update** to save the configuration. + + ![Token Configuration Modal](../../images/platform/external-syncs/github-token-config-modal.png) + + + + Once you have configured the GitHub access token: + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. You'll see a **Sync Now** section with a button to trigger the manual sync. + 3. Click **Sync Now** to synchronize GitHub teams for all organization members. + + ![Manual Sync Button](../../images/platform/external-syncs/github-manual-sync-button.png) + + The sync operation will process all organization members who have previously logged in with GitHub and update their team memberships accordingly. + + + ## Troubleshooting diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/acme-ca.mdx index 8b1fa10db..bf130da9e 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/acme-ca.mdx @@ -147,6 +147,8 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. + - **EAB Key Identifier (KID)**: (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them. + - **EAB HMAC Key**: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration. Finally, press **Create** to register the ACME CA with Infisical. @@ -277,6 +279,19 @@ Let's Encrypt is a free, automated, and open Certificate Authority that provides Always test your ACME integration using Let's Encrypt's staging environment first. This allows you to verify your DNS configuration and certificate issuance process without consuming your production rate limits. +## Example: DigiCert Integration + +DigiCert is a leading commercial Certificate Authority providing a wide range of trusted SSL/TLS certificates. Infisical can integrate with [DigiCert's ACME](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/third-party-acme-integration/request-and-manage-certificates-with-acme.html) service to automate the provisioning and management of these certificates. + +- **Directory URL**: `https://acme.digicert.com/v2/acme/directory` +- **External Account Binding (EAB)**: Required. You will need a Key Identifier (KID) and HMAC Key from your DigiCert account to register the ACME CA in Infisical. +- **Certificate Validity**: Typically 90 days, with automatic renewal through Infisical. +- **Trusted By**: All major browsers and operating systems. + + + When integrating with DigiCert ACME, ensure you have obtained the necessary External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your DigiCert account. + + ## FAQ diff --git a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png index 872d11cab..ebe9fc2ad 100644 Binary files a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png and b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png differ diff --git a/docs/images/platform/external-syncs/github-classic-token.png b/docs/images/platform/external-syncs/github-classic-token.png new file mode 100644 index 000000000..f59c4f362 Binary files /dev/null and b/docs/images/platform/external-syncs/github-classic-token.png differ diff --git a/docs/images/platform/external-syncs/github-fine-grained-token.png b/docs/images/platform/external-syncs/github-fine-grained-token.png new file mode 100644 index 000000000..4924fedd0 Binary files /dev/null and b/docs/images/platform/external-syncs/github-fine-grained-token.png differ diff --git a/docs/images/platform/external-syncs/github-manual-sync-button.png b/docs/images/platform/external-syncs/github-manual-sync-button.png new file mode 100644 index 000000000..291b9f033 Binary files /dev/null and b/docs/images/platform/external-syncs/github-manual-sync-button.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png new file mode 100644 index 000000000..5f81232cf Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png new file mode 100644 index 000000000..3ff168629 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png differ diff --git a/docs/images/platform/external-syncs/github-token-config-modal.png b/docs/images/platform/external-syncs/github-token-config-modal.png new file mode 100644 index 000000000..106738d3c Binary files /dev/null and b/docs/images/platform/external-syncs/github-token-config-modal.png differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png index ef572bfa7..bc32c23f6 100644 Binary files a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png and b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png differ diff --git a/docs/integrations/app-connections/hashicorp-vault.mdx b/docs/integrations/app-connections/hashicorp-vault.mdx index 6e2ff68bf..c49b53ab8 100644 --- a/docs/integrations/app-connections/hashicorp-vault.mdx +++ b/docs/integrations/app-connections/hashicorp-vault.mdx @@ -149,6 +149,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - **Name**: The name of the connection being created. Must be slug-friendly. - **Description**: An optional description to provide details about this connection. + - **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway. - **Instance URL**: The URL of your Hashicorp Vault instance. - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. - **Role ID**: The Role ID generated in the steps above. @@ -157,6 +158,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - **Name**: The name of the connection being created. Must be slug-friendly. - **Description**: An optional description to provide details about this connection. + - **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway. - **Instance URL**: The URL of your Hashicorp Vault instance. - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. - **Access Token**: The Access Token generated in the steps above. diff --git a/frontend/public/images/integrations/EnvKey.png b/frontend/public/images/integrations/EnvKey.png new file mode 100644 index 000000000..bf1ce03ed Binary files /dev/null and b/frontend/public/images/integrations/EnvKey.png differ diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index fa3ae2c70..1bbe9f6f1 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { reset(); onOpenChange(false); navigate({ - to: getProjectHomePage(project.type), + to: getProjectHomePage(project.type, project.environments), params: { projectId: project.id } }); } catch (err) { diff --git a/frontend/src/components/v2/EmptyState/EmptyState.tsx b/frontend/src/components/v2/EmptyState/EmptyState.tsx index 9816a3fe2..f6b926953 100644 --- a/frontend/src/components/v2/EmptyState/EmptyState.tsx +++ b/frontend/src/components/v2/EmptyState/EmptyState.tsx @@ -10,6 +10,7 @@ type Props = { children?: ReactNode; icon?: IconDefinition; iconSize?: SizeProp; + titleClassName?: string; }; export const EmptyState = ({ @@ -17,7 +18,8 @@ export const EmptyState = ({ className, children, icon = faCubesStacked, - iconSize = "2x" + iconSize = "2x", + titleClassName }: Props) => (
-
{title}
+
{title}
{children}
diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 47a5acd56..7ab8b0f3c 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -67,7 +67,7 @@ export const FilterableSelect = ({ }), menuPortal: (provided) => ({ ...provided, - zIndex: 9999 + zIndex: 99999 }) }} tabSelectsValue={tabSelectsValue} diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index cc180ffb6..b8ca27dc1 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -1,4 +1,4 @@ -import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; +import { DetailedHTMLProps, forwardRef, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; import { twMerge } from "tailwind-merge"; import { Skeleton } from "../Skeleton"; @@ -9,22 +9,20 @@ export type TableContainerProps = { className?: string; } & DetailedHTMLProps, HTMLDivElement>; -export const TableContainer = ({ - children, - className, - isRounded = true, - ...props -}: TableContainerProps): JSX.Element => ( -
- {children} -
+export const TableContainer = forwardRef( + ({ children, className, isRounded = true, ...props }, ref): JSX.Element => ( +
+ {children} +
+ ) ); // main parent table diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index fccd53935..f3bc0195d 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -2,6 +2,7 @@ export { useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; export { OrgPermissionActions, + OrgPermissionAuditLogsActions, OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 50e147aa0..4a569be06 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -52,6 +52,7 @@ export enum OrgPermissionSubjects { Gateway = "gateway", SecretShare = "secret-share", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", MachineIdentityAuthTemplate = "machine-identity-auth-template" } @@ -71,6 +72,10 @@ export enum OrgPermissionAppConnectionActions { Connect = "connect" } +export enum OrgPermissionAuditLogsActions { + Read = "read" +} + export enum OrgPermissionKmipActions { Proxy = "proxy", Setup = "setup" @@ -111,6 +116,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] @@ -118,7 +124,7 @@ export type OrgPermissionSet = | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] - | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index f564ac74e..a1669e18f 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, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index dad72cada..acfab612f 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -150,6 +150,10 @@ export enum ProjectPermissionSecretEventActions { SubscribeImportMutations = "subscribe-on-import-mutations" } +export enum ProjectPermissionAuditLogsActions { + Read = "read" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -365,7 +369,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] - | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs] | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 833956d77..bfb504664 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext"; export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, + OrgPermissionAuditLogsActions, OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, @@ -11,6 +12,7 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 3b04b263d..7c9185556 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -1,6 +1,6 @@ import { apiRequest } from "@app/config/request"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; -import { ProjectType } from "@app/hooks/api/workspace/types"; +import { ProjectType, WorkspaceEnv } from "@app/hooks/api/workspace/types"; const secretsToBeAdded = [ { @@ -72,12 +72,14 @@ export const getProjectBaseURL = (type: ProjectType) => { } }; -export const getProjectHomePage = (type: ProjectType) => { +// @ts-expect-error akhilmhdh: will remove this later +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const getProjectHomePage = (type: ProjectType, environments: WorkspaceEnv[]) => { switch (type) { case ProjectType.SecretManager: - return "/projects/secret-management/$projectId/overview"; + return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers"; + return "/projects/cert-management/$projectId/subscribers" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; default: diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 8dd874a75..336688ca4 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -16,6 +16,8 @@ export type TAcmeCertificateAuthority = { }; directoryUrl: string; accountEmail: string; + eabKid?: string; + eabHmacKey?: string; }; }; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index e748e3e5b..fde3a5ee3 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { apiRequest } from "@app/config/request"; import { @@ -273,6 +274,12 @@ export const useGetProjectSecretsDetails = ( ...options, // wait for all values to be available enabled: Boolean(projectId) && (options?.enabled ?? true), + retry: (count, error) => { + // don't retry 404s + if (error instanceof AxiosError && error.status === 404) return false; + + return count <= 5; + }, queryKey: dashboardKeys.getProjectSecretsDetails({ secretPath, search, diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 78c56394f..fbd5107cd 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit< DashboardProjectSecretsOverviewResponse, "secrets" | "secretRotations" > & { - secrets?: SecretV3RawSanitized[]; + secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[]; secretRotations?: (TSecretRotationV2 & { secrets: (SecretV3RawSanitized | null)[]; })[]; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx index 585426469..66b0246dc 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx @@ -1,6 +1,7 @@ export { useCreateGithubSyncOrgConfig, useDeleteGithubSyncOrgConfig, + useSyncAllGithubTeams, useUpdateGithubSyncOrgConfig } from "./mutations"; export { githubOrgSyncConfigQueryKeys } from "./queries"; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx index 0cb9b56e8..3c78550ed 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx @@ -40,3 +40,19 @@ export const useDeleteGithubSyncOrgConfig = () => { } }); }; + +export const useSyncAllGithubTeams = () => { + return useMutation({ + mutationFn: async (): Promise<{ + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; + }> => { + const response = await apiRequest.post("/api/v1/github-org-sync-config/sync-all-teams"); + return response.data; + } + }); +}; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index 529d5c463..182797954 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -46,18 +46,21 @@ export const useImportVault = () => { vaultAccessToken, vaultNamespace, vaultUrl, - mappingType + mappingType, + gatewayId }: { vaultAccessToken: string; vaultNamespace?: string; vaultUrl: string; mappingType: string; + gatewayId?: string; }) => { await apiRequest.post("/api/v3/external-migration/vault/", { vaultAccessToken, vaultNamespace, vaultUrl, - mappingType + mappingType, + gatewayId }); } }); diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7a0e3d2e2..3c7a6d30c 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -47,7 +47,8 @@ import { UpdateEnvironmentDTO, UpdatePitVersionLimitDTO, UpdateProjectDTO, - Workspace + Workspace, + WorkspaceEnv } from "./types"; export const fetchWorkspaceById = async (workspaceId: string) => { @@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => { export const useCreateWsEnvironment = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ workspaceId, name, slug }) => { - return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, { - name, - slug - }); + return useMutation({ + mutationFn: async ({ workspaceId, name, slug }) => { + const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>( + `/api/v1/workspace/${workspaceId}/environments`, + { + name, + slug + } + ); + return data.environment; }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/frontend/src/hooks/useResizableColWidth.tsx b/frontend/src/hooks/useResizableColWidth.tsx index f2ad80625..6af269217 100644 --- a/frontend/src/hooks/useResizableColWidth.tsx +++ b/frontend/src/hooks/useResizableColWidth.tsx @@ -1,12 +1,13 @@ -import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { MouseEvent, RefObject, useCallback, useEffect, useRef, useState } from "react"; type Params = { minWidth: number; maxWidth: number; initialWidth: number; + ref: RefObject; }; -export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Params) => { +export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth, ref }: Params) => { const [colWidth, setColWidth] = useState(initialWidth); const [isResizing, setIsResizing] = useState(false); const startX = useRef(0); @@ -63,6 +64,28 @@ export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Param }; }, [isResizing, handleMouseMove, handleMouseUp]); + useEffect(() => { + const element = ref?.current; + if (!element) return; + + const handleResize = () => { + if (colWidth > maxWidth) { + setColWidth(Math.max(maxWidth, minWidth)); + } else if (ref.current?.clientWidth && colWidth > ref.current.clientWidth * 0.9) { + // this else is a fallback to ensure col is always visible + setColWidth(initialWidth); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); + + // eslint-disable-next-line consistent-return + return () => { + resizeObserver.disconnect(); + }; + }, [ref, maxWidth, colWidth]); + return { colWidth, handleMouseDown, diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index e136df3f2..d77b41f5f 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -130,7 +130,11 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr const getEnvSecretKeyCount = useCallback( (env: string) => { - return secrets?.filter((secret) => secret.env === env).length ?? 0; + return ( + secrets?.filter((secret) => + secret.sourceEnv ? secret.sourceEnv === env : secret.env === env + ).length ?? 0 + ); }, [secrets] ); diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 2b4fcff80..995083ee7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => { }, { onSuccess: () => { - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage( + currentWorkspace.type, + currentWorkspace.environments + ); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index b66717a5c..ebc28a227 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -101,7 +101,7 @@ export const ProjectSelect = () => {
{ // to reproduce change this back to router.push and switch between two projects with different env count // look into this on dashboard revamp const url = linkOptions({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index ad31d9017..0584e8e9c 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -11,7 +11,7 @@ import { faVault } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; @@ -31,6 +31,7 @@ export const SecretManagerLayout = () => { const { t } = useTranslation(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; + const location = useLocation(); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId @@ -73,11 +74,21 @@ export const SecretManagerLayout = () => { {({ isActive }) => ( - +
diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index 8b0a88bd0..68757f549 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -42,6 +42,17 @@ import { import { UsePopUpState } from "@app/hooks/usePopUp"; import { slugSchema } from "@app/lib/schemas"; +const REQUIRED_EAB_DIRECTORIES = [ + "https://acme.digicert.com/v2/acme/directory", + "https://acme.zerossl.com/v2/DV90", + "https://acme.ssl.com/sslcom-dv-rsa", + "https://acme.ssl.com/sslcom-dv-ecc", + "https://dv.acme-v02.api.pki.goog/directory", + "https://acme.sectigo.com/v2/OV", + "https://acme.sectigo.com/v2/EV", + "https://acme.cisco.com/ACMEv2/directory" +]; + const baseSchema = z.object({ type: z.nativeEnum(CaType), name: slugSchema({ @@ -51,18 +62,39 @@ const baseSchema = z.object({ status: z.nativeEnum(CaStatus) }); -const acmeConfigurationSchema = z.object({ - dnsAppConnection: z.object({ - id: z.string(), - name: z.string() - }), - dnsProviderConfig: z.object({ - provider: z.nativeEnum(AcmeDnsProvider), - hostedZoneId: z.string() - }), - directoryUrl: z.string(), - accountEmail: z.string() -}); +const acmeConfigurationSchema = z + .object({ + dnsAppConnection: z.object({ + id: z.string(), + name: z.string() + }), + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string() + }), + directoryUrl: z.string(), + accountEmail: z.string(), + eabKid: z.string().optional(), + eabHmacKey: z.string().optional() + }) + .superRefine((data, ctx) => { + if (REQUIRED_EAB_DIRECTORIES.includes(data.directoryUrl)) { + if (!data.eabKid || data.eabKid.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EAB Key Identifier (KID) is required for this directory URL", + path: ["eabKid"] + }); + } + if (!data.eabHmacKey || data.eabHmacKey.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EAB HMAC Key is required for this directory URL", + path: ["eabHmacKey"] + }); + } + } + }); const azureAdCsConfigurationSchema = z.object({ azureAdcsConnection: z.object({ @@ -122,6 +154,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration ? configuration.dnsProviderConfig.provider : undefined; + const directoryUrl = + caType === CaType.ACME && configuration && "directoryUrl" in configuration + ? configuration.directoryUrl + : undefined; useEffect(() => { const initialType = (popUp?.ca?.data as { type: CaType })?.type; @@ -155,7 +191,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { hostedZoneId: "" }, directoryUrl: "", - accountEmail: "" + accountEmail: "", + eabKid: "", + eabHmacKey: "" } }); } @@ -178,13 +216,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { }); const availableConnections: TAvailableAppConnection[] = useMemo(() => { - if (caType === CaType.ACME) { - return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; - } if (caType === CaType.AZURE_AD_CS) { return availableAzureConnections || []; } - return []; + return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; }, [ caType, availableRoute53Connections, @@ -192,7 +227,8 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { availableAzureConnections ]); - const isPending = isRoute53Pending || isCloudflarePending || isAzurePending; + const isPending = + isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS); const dnsAppConnection = caType === CaType.ACME && configuration && "dnsAppConnection" in configuration @@ -227,7 +263,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId }, directoryUrl: ca.configuration.directoryUrl, - accountEmail: ca.configuration.accountEmail + accountEmail: ca.configuration.accountEmail, + eabKid: ca.configuration.eabKid, + eabHmacKey: ca.configuration.eabHmacKey } }); } else if (ca.type === CaType.AZURE_AD_CS && availableConnections?.length) { @@ -268,7 +306,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { dnsProviderConfig: formConfiguration.dnsProviderConfig, directoryUrl: formConfiguration.directoryUrl, accountEmail: formConfiguration.accountEmail, - dnsAppConnectionId: formConfiguration.dnsAppConnection.id + dnsAppConnectionId: formConfiguration.dnsAppConnection.id, + eabKid: formConfiguration.eabKid, + eabHmacKey: formConfiguration.eabHmacKey }; } else if (type === CaType.AZURE_AD_CS && "azureAdcsConnection" in formConfiguration) { configPayload = { @@ -499,6 +539,44 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> + ( + + + + )} + /> + ( + + + + )} + /> )} {caType === CaType.AZURE_AD_CS && ( diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx index af3954b45..78d937fc6 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx @@ -1,7 +1,9 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, @@ -9,9 +11,16 @@ import { ModalClose, SecretInput, Select, - SelectItem + SelectItem, + Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; @@ -66,7 +75,8 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => { resolver: zodResolver(formSchema), defaultValues: appConnection ?? { app: AppConnection.HCVault, - method: HCVaultConnectionMethod.AppRole + method: HCVaultConnectionMethod.AppRole, + gatewayId: null } }); @@ -79,6 +89,9 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => { const selectedMethod = watch("method"); + const { subscription } = useSubscription(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + return (
@@ -115,6 +128,54 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> + {subscription.gateway && ( + + {(isAllowed) => ( + ( + + + + + + )} + /> + )} + + )} { - const { subscription } = useSubscription(); +const LogsSectionComponent = ({ + presets, + refetchInterval, + showFilters = true, + pageView = false, + project +}: Props) => { + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + const [logFilter, setLogFilter] = useState({ + eventType: presets?.eventType || [], + actor: presets?.actorId, + eventMetadata: presets?.eventMetadata + }); + const [timezone, setTimezone] = useState(Timezone.Local); - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); - const [logFilter, setLogFilter] = useState({ - eventType: presets?.eventType || [], - actor: presets?.actorId, - eventMetadata: presets?.eventMetadata - }); - const [timezone, setTimezone] = useState(Timezone.Local); + const [dateFilter, setDateFilter] = useState( + presets?.endDate || presets?.startDate + ? { + type: AuditLogDateFilterType.Absolute, + startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")), + endDate: presets?.endDate || new Date() + } + : { + startDate: new Date(Number(new Date()) - ms("1h")), + endDate: new Date(), + type: AuditLogDateFilterType.Relative, + relativeModeValue: "1h" + } + ); - const [dateFilter, setDateFilter] = useState( - presets?.endDate || presets?.startDate - ? { - type: AuditLogDateFilterType.Absolute, - startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")), - endDate: presets?.endDate || new Date() - } - : { - startDate: new Date(Number(new Date()) - ms("1h")), - endDate: new Date(), - type: AuditLogDateFilterType.Relative, - relativeModeValue: "1h" - } - ); - - useEffect(() => { - if (subscription && !subscription.auditLogs) { - handlePopUpOpen("upgradePlan"); - } - }, [subscription]); - - if (pageView) - return ( -
-
-
-
-

Audit History

- -
- - Docs - -
-
-
-
-
- {showFilters && ( - - )} - {showFilters && ( - - )} -
-
-
- - { - handlePopUpToggle("upgradePlan", isOpen); - }} - text="You can use audit logs if you switch to a paid Infisical plan." - /> -
-
- ); + useEffect(() => { + if (subscription && !subscription.auditLogs) { + handlePopUpOpen("upgradePlan"); + } + }, [subscription]); + if (pageView) return ( -
-
- {showFilters && ( - - )} - {showFilters && ( - - )} +
+
+
+
+

Audit History

+ +
+ + Docs + +
+
+
+
+
+ {showFilters && ( + + )} + {showFilters && ( + + )} +
+
+
+ + { + handlePopUpToggle("upgradePlan", isOpen); + }} + text="You can use audit logs if you switch to a paid Infisical plan." + />
- - { - handlePopUpToggle("upgradePlan", isOpen); - }} - text="You can use audit logs if you switch to a paid Infisical plan." - />
); - }, - { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs } -); + + return ( +
+
+ {showFilters && ( + + )} + {showFilters && ( + + )} +
+ + { + handlePopUpToggle("upgradePlan", isOpen); + }} + text="You can use audit logs if you switch to a paid Infisical plan." + /> +
+ ); +}; + +export const LogsSection = (props: Props) => { + const { project } = props; + + if (project) { + const ProjectLogsSectionWithPermission = withProjectPermission(LogsSectionComponent, { + action: ProjectPermissionAuditLogsActions.Read, + subject: ProjectPermissionSub.AuditLogs + }); + return ; + } + + const OrgLogsSectionWithPermission = withPermission(LogsSectionComponent, { + action: OrgPermissionAuditLogsActions.Read, + subject: OrgPermissionSubjects.AuditLogs + }); + return ; +}; diff --git a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx index a9f613584..7b066a099 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx @@ -48,7 +48,7 @@ import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api"; -import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; +import { ProjectType, Workspace, WorkspaceEnv } from "@app/hooks/api/workspace/types"; import { ProjectListToggle, ProjectListView @@ -152,13 +152,17 @@ export const AllProjectView = ({ type: projectTypeFilter }); - const handleAccessProject = async (type: ProjectType, projectId: string) => { + const handleAccessProject = async ( + type: ProjectType, + projectId: string, + environments: WorkspaceEnv[] + ) => { try { await orgAdminAccessProject.mutateAsync({ projectId }); await navigate({ - to: getProjectHomePage(type), + to: getProjectHomePage(type, environments), params: { projectId } @@ -315,7 +319,7 @@ export const AllProjectView = ({ onKeyDown={(evt) => { if (evt.key === "Enter" && workspace.isMember) { navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } @@ -325,7 +329,7 @@ export const AllProjectView = ({ onClick={() => { if (workspace.isMember) { navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } @@ -371,7 +375,7 @@ export const AllProjectView = ({ onClick={(e) => { e.stopPropagation(); e.preventDefault(); - handleAccessProject(workspace.type, workspace.id); + handleAccessProject(workspace.type, workspace.id, workspace.environments); }} disabled={ orgAdminAccessProject.variables?.projectId === workspace.id && diff --git a/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx index 8606aeb89..d63099273 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx @@ -193,7 +193,7 @@ export const MyProjectView = ({
{ navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } @@ -247,7 +247,7 @@ export const MyProjectView = ({
{ navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index 275b73fd0..03b86f2ab 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -5,6 +5,7 @@ import { OrgPermissionSubjects } from "@app/context"; import { OrgGatewayPermissionActions, OrgPermissionAppConnectionActions, + OrgPermissionAuditLogsActions, OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, @@ -23,6 +24,12 @@ const generalPermissionSchema = z }) .optional(); +const auditLogsPermissionSchema = z + .object({ + [OrgPermissionAuditLogsActions.Read]: z.boolean().optional() + }) + .optional(); + const billingPermissionSchema = z .object({ [OrgPermissionBillingActions.Read]: z.boolean().optional(), @@ -121,7 +128,7 @@ export const formSchema = z.object({ }) .optional(), - "audit-logs": generalPermissionSchema, + "audit-logs": auditLogsPermissionSchema, member: generalPermissionSchema, groups: groupPermissionSchema, role: generalPermissionSchema, diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAuditLogsRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAuditLogsRow.tsx new file mode 100644 index 000000000..b86ae91d4 --- /dev/null +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAuditLogsRow.tsx @@ -0,0 +1,145 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { OrgPermissionAuditLogsActions } from "@app/context/OrgPermissionContext/types"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "../OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [ + { + action: OrgPermissionAuditLogsActions.Read, + label: "Read" + } +] as const; + +export const OrgPermissionAuditLogsRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.audit-logs" + }); + + const selectedPermissionCategory = useMemo(() => { + const actions = Object.keys(rule || {}) as Array; + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + switch (val) { + case Permission.NoAccess: + default: + setValue( + "permissions.audit-logs", + { + [OrgPermissionAuditLogsActions.Read]: false + }, + { shouldDirty: true } + ); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Audit Logs + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.audit-logs.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx index e0702b201..0254e8240 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -71,6 +71,7 @@ type Props = { | "gateway" | "secret-share" | "billing" + | "audit-logs" | "machine-identity-auth-template" >; setValue: UseFormSetValue; diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index 6fd848f56..3b606cbb1 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -16,6 +16,7 @@ import { TFormSchema } from "../OrgRoleModifySection.utils"; import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow"; +import { OrgPermissionAuditLogsRow } from "./OrgPermissionAuditLogsRow"; import { OrgPermissionBillingRow } from "./OrgPermissionBillingRow"; import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow"; import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow"; @@ -39,10 +40,6 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "Incident Contacts", formName: "incident-contact" }, - { - title: "Audit Logs", - formName: "audit-logs" - }, { title: "Organization Profile", formName: "settings" @@ -166,6 +163,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => { /> ); })} + { {PLATFORM_LIST.map((platform, idx) => (
{ @@ -81,7 +81,11 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => { } }} > - + {`${platform.title}
{platform.title}
))} diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx index 02ab5df1b..137e765d2 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx @@ -2,12 +2,19 @@ import { Controller, useForm } from "react-hook-form"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input, Tooltip } from "@app/components/v2"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Input, Select, SelectItem, Tooltip } from "@app/components/v2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { useImportVault } from "@app/hooks/api/migration/mutations"; type Props = { @@ -62,36 +69,32 @@ const MAPPING_TYPE_MENU_ITEMS = [ export const VaultPlatformModal = ({ onClose }: Props) => { const formSchema = z.object({ vaultUrl: z.string().min(1), + gatewayId: z.string().optional(), vaultNamespace: z.string().trim().optional(), vaultAccessToken: z.string().min(1), mappingType: z.nativeEnum(VaultMappingType).default(VaultMappingType.KeyVault) }); type TFormData = z.infer; + const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list()); const { mutateAsync: importVault } = useImportVault(); const { control, handleSubmit, reset, - formState: { isLoading, isDirty, isSubmitting, isValid, errors } + formState: { isLoading, isDirty, isSubmitting, isValid } } = useForm({ resolver: zodResolver(formSchema) }); - console.log({ - isSubmitting, - isLoading, - isValid, - errors - }); - const onSubmit = async (data: TFormData) => { await importVault({ vaultAccessToken: data.vaultAccessToken, vaultNamespace: data.vaultNamespace, vaultUrl: data.vaultUrl, - mappingType: data.mappingType + mappingType: data.mappingType, + ...(data.gatewayId && { gatewayId: data.gatewayId }) }); createNotification({ title: "Import started", @@ -110,11 +113,70 @@ export const VaultPlatformModal = ({ onClose }: Props) => { The Vault migration currently supports importing static secrets from Vault Dedicated/Self-Hosted.
- Currently only KV Secret Engine V2 is supported for Vault migrations. + Currently only KV Secret Engine is supported for Vault migrations.

+
+ + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
+ ({ resolver: zodResolver(schema), - values: data ? { githubOrgName: data.githubOrgName } : undefined + values: data ? { githubOrgName: data.githubOrgName, githubOrgAccessToken: "" } : undefined }); const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => { @@ -123,21 +123,21 @@ export const GithubOrgSyncConfigModal = ({ )} /> - {/* ( - + )} - /> */} + />
+ )} + +
+

+ Manually sync GitHub teams for all organization members. This will update team + memberships for users who have previously logged in with GitHub. +

+
+ )} { diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index bcffb428f..7d155ba43 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -136,7 +136,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { text: "User privilege assumption has started" }); - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 19d6c4acc..e1da4edab 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -67,7 +67,7 @@ const Page = () => { type: "success", text: "Identity privilege assumption has started" }); - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 7bac32f75..7fadf2cb9 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -72,7 +72,7 @@ export const Page = () => { text: "User privilege assumption has started" }); - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 9ebc903a5..856275374 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -12,6 +12,7 @@ import { } from "@app/context"; import { PermissionConditionOperators, + ProjectPermissionAuditLogsActions, ProjectPermissionCommitsActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, @@ -41,6 +42,10 @@ const GeneralPolicyActionSchema = z.object({ create: z.boolean().optional() }); +const AuditLogsPolicyActionSchema = z.object({ + [ProjectPermissionAuditLogsActions.Read]: z.boolean().optional() +}); + const CertificatePolicyActionSchema = z.object({ [ProjectPermissionCertificateActions.Create]: z.boolean().optional(), [ProjectPermissionCertificateActions.Delete]: z.boolean().optional(), @@ -316,7 +321,7 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]), - [ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.AuditLogs]: AuditLogsPolicyActionSchema.array().default([]), [ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Certificates]: CertificatePolicyActionSchema.array().default([]), @@ -1324,12 +1329,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { }, [ProjectPermissionSub.AuditLogs]: { title: "Audit Logs", - actions: [ - { label: "Read", value: "read" }, - { label: "Create", value: "create" }, - { label: "Modify", value: "edit" }, - { label: "Remove", value: "delete" } - ] + actions: [{ label: "Read", value: ProjectPermissionAuditLogsActions.Read }] }, [ProjectPermissionSub.IpAllowList]: { title: "IP Allowlist", @@ -1721,7 +1721,7 @@ const projectManagerTemplate = ( permissions: [ { subject: ProjectPermissionSub.AuditLogs, - actions: Object.values(ProjectPermissionActions) + actions: Object.values(ProjectPermissionAuditLogsActions) }, { subject: ProjectPermissionSub.Groups, diff --git a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx index b4ecd1e0e..c27b1bb08 100644 --- a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx @@ -1,7 +1,7 @@ import { Link } from "@tanstack/react-router"; import { EmptyState } from "@app/components/v2"; -import { useSubscription } from "@app/context"; +import { useSubscription, useWorkspace } from "@app/context"; import { EventType } from "@app/hooks/api/auditLogs/enums"; import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection"; @@ -15,6 +15,7 @@ type Props = { export const IntegrationAuditLogsSection = ({ integration }: Props) => { const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30; @@ -30,6 +31,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => { { const { permission } = useProjectPermission(); const { mutateAsync: createCommit } = useCreateCommit(); - const tableRef = useRef(null); + const tableRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const { isBatchMode, pendingChanges } = useBatchMode(); @@ -249,7 +252,8 @@ const Page = () => { const { data, isPending: isDetailsLoading, - isFetching: isDetailsFetching + isFetching: isDetailsFetching, + isFetched } = useGetProjectSecretsDetails({ environment, projectId: workspaceId, @@ -270,6 +274,18 @@ const Page = () => { tags: filter.tags }); + useEffect(() => { + // if switching tabs in a folder path that doesn't exist in a separate env we navigate to the root + if (!data && isFetched) { + navigate({ + search: (prev) => ({ + ...prev, + secretPath: "/" + }) + }); + } + }, [data, isFetched]); + const { imports, folders, @@ -491,7 +507,8 @@ const Page = () => { minWidth: 100, maxWidth: tableRef.current ? tableRef.current.clientWidth - 148 // ensure value column can't collapse completely - : 800 + : 800, + ref: tableRef }); useEffect(() => { @@ -710,12 +727,18 @@ const Page = () => { const mergedSecrets = getMergedSecretsWithPending(); const mergedFolders = getMergedFoldersWithPending(); + + if (!(currentWorkspace?.version === ProjectVersion.V3)) + return ( +
+ +
+ ); + return (
env.slug === environment)?.name ?? environment - } + title="Secrets Management" description={

Inject your secrets using @@ -759,6 +782,8 @@ const Page = () => { } /> + + {!isRollbackMode ? ( <> { workspaceId={workspaceId} secretPath={secretPath} onNavigateToFolder={handleResetFilter} + canNavigate={isFetched} /> )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/CompareEnvironments.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/CompareEnvironments.tsx new file mode 100644 index 000000000..c3570bf5a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/CompareEnvironments.tsx @@ -0,0 +1,595 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { MultiValue } from "react-select"; +import { + faArrowDown, + faArrowUp, + faCheckCircle, + faFilter, + faFingerprint, + faFolder, + faKey, + faRotate, + faSearch, + faWarning +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + FilterableSelect, + FormLabel, + IconButton, + Input, + Lottie, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { useDebounce, usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetImportedSecretsAllEnvs } from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard"; +import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; +import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; +import { + useDynamicSecretOverview, + useFolderOverview, + useSecretOverview, + useSecretRotationOverview +} from "@app/hooks/utils"; +import { SecretTableResourceCount } from "@app/pages/secret-manager/OverviewPage/components/SecretTableResourceCount"; + +import { DynamicSecretRow } from "./components/DynamicSecretRow"; +import { FolderRow } from "./components/FolderRow"; +import { SecretRotationRow } from "./components/SecretRotationRow"; +import { SecretNoAccessRow, SecretRow } from "./components/SecretRow"; + +type Props = { + secretPath: string; +}; + +enum RowType { + Folder = "folder", + DynamicSecret = "dynamic", + Secret = "secret", + SecretRotation = "rotation" +} + +type Filter = { + [key in RowType]: boolean; +}; + +const DEFAULT_FILTER_STATE = { + [RowType.Folder]: false, + [RowType.DynamicSecret]: false, + [RowType.Secret]: false, + [RowType.SecretRotation]: false +}; + +const COL_WIDTH_OFFSET = 220; + +export const CompareEnvironments = ({ secretPath }: Props) => { + const { currentWorkspace } = useWorkspace(); + const compareEnvironmentsKey = `compare-environments-${currentWorkspace.id}`; + + const [selectedEnvironments, setSelectedEnvironments] = useState(() => { + try { + const storedEnvironments = JSON.parse(localStorage.getItem(compareEnvironmentsKey) ?? "[]"); + + if (Array.isArray(storedEnvironments) && storedEnvironments.length > 0) { + const potentialEnvs: string[] = []; + storedEnvironments.forEach((env) => { + if (typeof env === "string") { + potentialEnvs.push(env); + } + }); + + return currentWorkspace.environments.filter((env) => potentialEnvs.includes(env.id)); + } + } catch { + // do nothing and proceed + } + return currentWorkspace.environments.slice(0, 2); + }); + + const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); + + const { + offset, + limit, + orderDirection, + setOrderDirection, + setPage, + perPage, + page, + setPerPage, + orderBy + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretCompareTable", PreferenceKey.PerPage, 50) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretCompareTable", PreferenceKey.PerPage, newPerPage); + }; + + const workspaceId = currentWorkspace.id; + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearchFilter] = useDebounce(searchFilter); + const [debouncedSelectedEnvironments] = useDebounce(selectedEnvironments); + + useEffect(() => { + localStorage.setItem( + compareEnvironmentsKey, + JSON.stringify(selectedEnvironments.map((env) => env.id)) + ); + }, [debouncedSelectedEnvironments]); + + const { + secretImports, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + getEnvImportedSecretKeyCount + } = useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (currentWorkspace.environments || []).map(({ slug }) => slug) + }); + + const compareEnvironments = selectedEnvironments.length + ? selectedEnvironments + : currentWorkspace.environments; + + const isFilteredByResources = Object.values(filter).some(Boolean); + const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: compareEnvironments.map((env) => env.slug), + secretPath, + orderDirection, + orderBy, + includeFolders: isFilteredByResources ? filter.folder : true, + includeDynamicSecrets: isFilteredByResources ? filter.dynamic : true, + includeSecrets: isFilteredByResources ? filter.secret : true, + includeImports: true, + includeSecretRotations: isFilteredByResources ? filter.rotation : true, + search: debouncedSearchFilter, + limit, + offset + }, + { enabled: Boolean(compareEnvironments.length) } + ); + + const { + secrets, + folders, + dynamicSecrets, + secretRotations, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount, + totalSecretRotationCount, + totalCount = 0, + totalUniqueFoldersInPage, + totalUniqueSecretsInPage, + totalUniqueSecretImportsInPage, + totalUniqueDynamicSecretsInPage, + totalUniqueSecretRotationsInPage + } = overview ?? {}; + + const secretImportsShaped = secretImports + ?.flatMap(({ data }) => data) + .filter(Boolean) + .flatMap((item) => item?.secrets || []); + + const handleIsImportedSecretPresentInEnv = (envSlug: string, secretName: string) => { + if (secrets?.some((s) => s.key === secretName && s.env === envSlug)) { + return false; + } + if (secretImportsShaped.some((s) => s.key === secretName && s.sourceEnv === envSlug)) { + return true; + } + return isImportedSecretPresentInEnv(envSlug, secretName); + }; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { folderNamesAndDescriptions, isFolderPresentInEnv } = useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { secretRotationNames, isSecretRotationPresentInEnv, getSecretRotationByName } = + useSecretRotationOverview(secretRotations); + + const { secKeys, getEnvSecretKeyCount } = useSecretOverview( + secrets?.concat(secretImportsShaped) || [] + ); + + const getSecretByKey = useCallback( + (env: string, key: string) => { + const sec = secrets?.find((s) => s.env === env && s.key === key); + return sec; + }, + [secrets] + ); + + const [tableWidth, setTableWidth] = useState(0); + const tableRef = useRef(null); + + const { handleMouseDown, isResizing, colWidth } = useResizableColWidth({ + initialWidth: 320, + minWidth: 160, + maxWidth: tableRef.current + ? tableRef.current.clientWidth - COL_WIDTH_OFFSET // ensure value column can't collapse completely + : 800, + ref: tableRef + }); + + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); + + const isTableEmpty = totalCount === 0; + + const isTableFiltered = isFilteredByResources; + + useEffect(() => { + const element = tableRef.current; + if (!element) return; + + const handleResize = () => { + setTableWidth(element.clientWidth - 1); + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); + + // eslint-disable-next-line consistent-return + return () => { + resizeObserver.disconnect(); + }; + }, [tableRef]); + + return ( + // scott: this is reverse to fix z-indexing bug of dropdown with sticky table cols; couldn't resolve with flex-col +

+ {!isOverviewLoading && totalCount > 0 && ( + + } + className="rounded-b-lg border border-solid border-mineshaft-500 bg-mineshaft-700" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )} +
+ + {/* eslint-disable-next-line no-nested-ternary */} + {isOverviewLoading ? ( +
+ +
+ ) : isTableEmpty ? ( + + ) : ( + + + + + {compareEnvironments?.map(({ name, slug }, index) => { + const envSecKeyCount = getEnvSecretKeyCount(slug); + const importedSecKeyCount = getEnvImportedSecretKeyCount(slug); + const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount; + + return ( + + ); + })} + + + + {folderNamesAndDescriptions.map(({ name: folderName }, index) => ( + + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secretRotationNames.map((secretRotationName, index) => ( + + ))} + {secKeys.map((key, index) => ( + + ))} + totalCount ? totalCount % perPage : perPage) - + (totalUniqueFoldersInPage || 0) - + (totalUniqueDynamicSecretsInPage || 0) - + (totalUniqueSecretsInPage || 0) - + (totalUniqueSecretImportsInPage || 0) - + (totalUniqueSecretRotationsInPage || 0), + 0 + )} + /> + +
+
+
+
+
+
+
+ Name + + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } + > + + +
+
+
+
+ {name} + {missingKeyCount > 0 && ( + + {missingKeyCount} secret{missingKeyCount > 1 ? "s" : ""} missing + compared to other environments on this page + + } + > + + + {missingKeyCount} + + + )} +
+
+ )} +
+
+
+ setSearchFilter(e.target.value)} + className="h-full flex-1" + placeholder="Search by resource name..." + leftIcon={} + containerClassName="h-10" + /> + {isTableFiltered && ( + + )} + {compareEnvironments.length > 0 && ( + + + + + + Filter by Resource + { + e.preventDefault(); + handleToggleRowType(RowType.Folder); + }} + icon={filter[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.DynamicSecret); + }} + icon={filter[RowType.DynamicSecret] && } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.SecretRotation); + }} + icon={filter[RowType.SecretRotation] && } + iconPos="right" + > +
+ + Secret Rotations +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.Secret); + }} + icon={filter[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
+
+
+ )} +
+
+ + { + const selected = value as MultiValue; + + setSelectedEnvironments((selected as WorkspaceEnv[]) ?? []); + }} + placeholder="Leave blank to compare all environments" + options={currentWorkspace.environments} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + isMulti + /> +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/DynamicSecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/DynamicSecretRow.tsx new file mode 100644 index 000000000..16de6533b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/DynamicSecretRow.tsx @@ -0,0 +1,41 @@ +import { faFingerprint } from "@fortawesome/free-solid-svg-icons"; + +import { Tr } from "@app/components/v2"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + dynamicSecretName: string; + environments: { name: string; slug: string }[]; + isDynamicSecretInEnv: (name: string, env: string) => boolean; + colWidth: number; +}; + +export const DynamicSecretRow = ({ + dynamicSecretName, + environments = [], + isDynamicSecretInEnv, + colWidth +}: Props) => { + return ( + + + {environments.map(({ slug }, i) => { + const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug); + + return ( + + ); + })} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/index.tsx new file mode 100644 index 000000000..5c7bf445a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/index.tsx @@ -0,0 +1 @@ +export { DynamicSecretRow } from "./DynamicSecretRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/FolderRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/FolderRow.tsx new file mode 100644 index 000000000..a3d672e13 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/FolderRow.tsx @@ -0,0 +1,41 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; + +import { Tr } from "@app/components/v2"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + folderName: string; + environments: { name: string; slug: string }[]; + isFolderPresentInEnv: (name: string, env: string) => boolean; + colWidth: number; +}; + +export const FolderRow = ({ + folderName, + environments = [], + isFolderPresentInEnv, + colWidth +}: Props) => { + return ( + + + {environments.map(({ slug }, i) => { + const isPresent = isFolderPresentInEnv(folderName, slug); + + return ( + + ); + })} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/index.tsx new file mode 100644 index 000000000..171b2e596 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/index.tsx @@ -0,0 +1 @@ +export { FolderRow } from "./FolderRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/SecretRotationRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/SecretRotationRow.tsx new file mode 100644 index 000000000..d5c938e9b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/SecretRotationRow.tsx @@ -0,0 +1,179 @@ +import { faEye, faEyeSlash, faInfoCircle, faRotate } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { useToggle } from "@app/hooks"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + secretRotationName: string; + environments: { name: string; slug: string }[]; + isSecretRotationInEnv: (name: string, env: string) => boolean; + getSecretRotationByName: (slug: string, name: string) => TSecretRotationV2 | undefined; + colWidth: number; + tableWidth: number; +}; + +export const SecretRotationRow = ({ + secretRotationName, + environments = [], + isSecretRotationInEnv, + colWidth, + getSecretRotationByName, + tableWidth +}: Props) => { + const [isExpanded, setIsExpanded] = useToggle(false); + const [isSecretVisible, setIsSecretVisible] = useToggle(); + + const totalCols = environments.length + 1; // secret key row + + return ( + <> + + + {environments.map(({ slug }, i) => { + const isPresent = isSecretRotationInEnv(secretRotationName, slug); + + return ( + + ); + })} + + {isExpanded && + environments.map(({ name: envName, slug }) => { + const secretRotation = getSecretRotationByName(slug, secretRotationName); + + if (!secretRotation) return null; + + const { type, secrets, description } = secretRotation; + + const { name: rotationType, image } = SECRET_ROTATION_MAP[type]; + + return ( + + +
+
+
+
+ {envName} + + {`${rotationType} + {rotationType} + + {description && ( + + + + )} +
+
+ + setIsSecretVisible.toggle()} + > + + + +
+ + + + {secrets.map((secret, index) => { + return ( + + + + + + + ); + })} + +
+
+ + {secret?.key ?? "********"} + +
+
+ {/* eslint-disable-next-line no-nested-ternary */} + {!secret ? ( +
********
+ ) : secret.secretValueHidden ? ( + + ) : ( + {}} + /> + )} +
+
+
+ + + ); + })} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/index.tsx new file mode 100644 index 000000000..a0b2fcfa7 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/index.tsx @@ -0,0 +1 @@ +export { SecretRotationRow } from "./SecretRotationRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/EnvironmentSecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/EnvironmentSecretRow.tsx new file mode 100644 index 000000000..87db191f6 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/EnvironmentSecretRow.tsx @@ -0,0 +1,49 @@ +import { faEyeSlash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; + +type Props = { + defaultValue?: string | null; + isOverride?: boolean; + isVisible?: boolean; + isImportedSecret: boolean; + environment: string; + secretValueHidden: boolean; + secretPath: string; +}; + +export const EnvironmentSecretRow = ({ + defaultValue, + isOverride, + isImportedSecret, + secretValueHidden, + environment, + secretPath, + isVisible +}: Props) => { + return ( +
+ {secretValueHidden && !isOverride && ( + + + + )} +
+ {}} + isReadOnly + value={defaultValue as string} + key="secret-input" + isVisible={isVisible && !secretValueHidden} + secretPath={secretPath} + environment={environment} + isImport={isImportedSecret} + defaultValue={secretValueHidden ? "" : undefined} + canEditButNotView={secretValueHidden && !isOverride} + /> +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretNoAccessRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretNoAccessRow.tsx new file mode 100644 index 000000000..075726d1b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretNoAccessRow.tsx @@ -0,0 +1,44 @@ +import { faLock } from "@fortawesome/free-solid-svg-icons"; + +import { Tr } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + environments: { name: string; slug: string }[]; + count: number; + colWidth: number; +}; + +export const SecretNoAccessRow = ({ environments = [], count, colWidth }: Props) => { + return ( + <> + {Array.from(Array(count)).map((_, j) => ( + + } + iconClassName="text-bunker-400" + icon={faLock} + colWidth={colWidth} + tooltipContent="You do not have permission to view this secret" + /> + {environments.map(({ slug }, i) => { + return ( + + ); + })} + + ))} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretRow.tsx new file mode 100644 index 000000000..095b50356 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretRow.tsx @@ -0,0 +1,231 @@ +import { subject } from "@casl/ability"; +import { + faCodeBranch, + faEye, + faEyeSlash, + faFileImport, + faKey, + faRotate +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { useToggle } from "@app/hooks"; +import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; + +import { EnvironmentStatus, EnvironmentStatusCell, ResourceNameCell } from "../shared"; +import { EnvironmentSecretRow } from "./EnvironmentSecretRow"; + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; + isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; + getImportedSecretByKey: ( + env: string, + secretName: string + ) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined; + colWidth: number; + tableWidth: number; +}; + +export const SecretRow = ({ + secretKey, + environments = [], + secretPath, + getSecretByKey, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + colWidth, + tableWidth +}: Props) => { + const [isFormExpanded, setIsFormExpanded] = useToggle(); + const totalCols = environments.length + 1; // secret key row + const [isSecretVisible, setIsSecretVisible] = useToggle(); + + const { permission } = useProjectPermission(); + + const getDefaultValue = ( + secret: SecretV3RawSanitized | undefined, + importedSecret: { secret?: SecretV3RawSanitized } | undefined + ) => { + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment: secret?.env || "", + secretPath: secret?.path || "", + secretName: secret?.key || "", + secretTags: ["*"] + }) + ); + + if (secret?.secretValueHidden && !secret?.valueOverride) { + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; + } + return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || ""; + }; + + return ( + <> + setIsFormExpanded.toggle()} + className="group border-mineshaft-500" + > + + {environments.map(({ slug }, i) => { + const secret = getSecretByKey(slug, secretKey); + + const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey); + + const isSecretPresent = Boolean(secret); + const isSecretEmpty = secret?.value === ""; + + let status: EnvironmentStatus; + + if (isSecretEmpty) { + status = "empty"; + } else if (isSecretPresent) { + status = "present"; + } else if (isSecretImported) { + status = "imported"; + } else { + status = "missing"; + } + + return ( + + ); + })} + + {isFormExpanded && ( + + +
+ + + + + + +
+ + setIsSecretVisible.toggle()} + > + + + +
+ + + + {environments.map(({ name, slug }) => { + const secret = getSecretByKey(slug, secretKey); + + const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey); + const importedSecret = getImportedSecretByKey(slug, secretKey); + + return ( + + + + + ); + })} + +
+ Environment + + Value +
+
+ {name} + {isImportedSecret && ( + + + + )} + {secret?.isRotatedSecret && ( + + + + )} + {secret?.valueOverride && ( + + + + )} +
+
+ +
+
+
+ + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/index.tsx new file mode 100644 index 000000000..a1e4a3d51 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/index.tsx @@ -0,0 +1,2 @@ +export { SecretNoAccessRow } from "./SecretNoAccessRow"; +export { SecretRow } from "./SecretRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/EnvironmentStatusCell.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/EnvironmentStatusCell.tsx new file mode 100644 index 000000000..fa5d2f8bb --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/EnvironmentStatusCell.tsx @@ -0,0 +1,75 @@ +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faCircle } from "@fortawesome/free-regular-svg-icons"; +import { faBan, faCheck, faFileImport, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Td, Tooltip } from "@app/components/v2"; + +export type EnvironmentStatus = "present" | "missing" | "empty" | "imported" | "no-access"; + +type Props = { + isLast: boolean; + status: EnvironmentStatus; +}; + +export const EnvironmentStatusCell = ({ isLast, status }: Props) => { + let tooltipContent: string; + let icon: IconDefinition; + let iconClassName: string; + + switch (status) { + case "present": + tooltipContent = "Present in environment"; + icon = faCheck; + iconClassName = "h-3 w-3"; + break; + case "missing": + tooltipContent = "Missing from environment"; + icon = faXmark; + iconClassName = "h-3.5 w-3.5"; + break; + case "empty": + tooltipContent = "Empty value in environment"; + icon = faCircle; + iconClassName = "h-3 w-3"; + break; + case "imported": + tooltipContent = "Imported into environment"; + icon = faFileImport; + iconClassName = "h-3 w-3"; + break; + case "no-access": + tooltipContent = "You do not have permission to view this secret"; + icon = faBan; + iconClassName = "h-3 w-3"; + break; + default: + throw new Error(`Unhandled environment status: ${status as string}`); + } + + return ( + +
+
+ + + +
+
+ + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/ResourceNameCell.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/ResourceNameCell.tsx new file mode 100644 index 000000000..079b563d5 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/ResourceNameCell.tsx @@ -0,0 +1,47 @@ +import { ReactElement } from "react"; +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faAngleDown } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Td, Tooltip } from "@app/components/v2"; + +type Props = { + isRowExpanded?: boolean; + label: ReactElement | string; + icon: IconDefinition; + iconClassName?: string; + colWidth: number; + tooltipContent?: string; +}; + +export const ResourceNameCell = ({ + isRowExpanded, + label, + icon, + iconClassName, + colWidth, + tooltipContent +}: Props) => { + return ( + + +
+
+ +
+ {typeof label === "string" ? {label} : label} +
+
+ + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/index.ts b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/index.ts new file mode 100644 index 000000000..c8bd53cbe --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/index.ts @@ -0,0 +1,2 @@ +export * from "./EnvironmentStatusCell"; +export * from "./ResourceNameCell"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/index.tsx new file mode 100644 index 000000000..f5c997972 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/index.tsx @@ -0,0 +1 @@ +export * from "./CompareEnvironments"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx new file mode 100644 index 000000000..66fd3a878 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import { faArrowRightArrowLeft, faEllipsisH, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + Modal, + ModalContent, + Tab, + TabList, + Tabs, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { workspaceKeys } from "@app/hooks/api"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; +import { AddEnvironmentModal } from "@app/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal"; + +import { CompareEnvironments } from "../CompareEnvironments"; + +const COMPARE_ENVIRONMENT_TAB = "__COMPARE_ENVIRONMENT_TAB__"; +const ADD_ENVIRONMENT_TAB = "__ADD_ENVIRONMENT_TAB__"; +const VIEW_MORE_ENVIRONMENT_TAB = "__VIEW_MORE_ENVIRONMENT_TAB__"; + +type Props = { + secretPath: string; +}; + +const TABS_TO_SHOW = 5; + +export const EnvironmentTabs = ({ secretPath }: Props) => { + const { currentWorkspace } = useWorkspace(); + const currentEnv = useParams({ + from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id, + select: (el) => el.envSlug + }); + + const { subscription } = useSubscription(); + + const isMoreEnvironmentsAllowed = + subscription?.environmentLimit && currentWorkspace?.environments + ? currentWorkspace.environments.length < subscription.environmentLimit + : true; + + const [isNavigating, setIsNavigating] = useState(false); + + const navigate = useNavigate(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "compareEnvironments", + "createEnvironment", + "upgradePlan" + ] as const); + + const selectedIndex = currentWorkspace.environments.findIndex((env) => env.slug === currentEnv); + + let tabEnvironments: WorkspaceEnv[]; + let dropdownEnvironments: WorkspaceEnv[]; + + if (selectedIndex < TABS_TO_SHOW) { + tabEnvironments = currentWorkspace.environments.slice(0, TABS_TO_SHOW); + dropdownEnvironments = currentWorkspace.environments.slice(TABS_TO_SHOW); + } else { + tabEnvironments = [ + ...currentWorkspace.environments.slice(0, TABS_TO_SHOW - 1), + currentWorkspace.environments[selectedIndex] + ]; + dropdownEnvironments = currentWorkspace.environments + .slice(TABS_TO_SHOW - 1) + .filter((env) => env.slug !== currentEnv); + } + + const queryClient = useQueryClient(); + + const handleSelect = async (envSlug: string) => { + if (isNavigating) return; + + setIsNavigating(true); + await navigate({ + to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path, + params: { + envSlug, + projectId: currentWorkspace.id + }, + search: (prev) => prev + }); + setIsNavigating(false); + }; + + const handleAddEnvironment = () => { + if (isMoreEnvironmentsAllowed) { + handlePopUpOpen("createEnvironment"); + } else { + handlePopUpOpen("upgradePlan"); + } + }; + + return ( + <> + { + if (value === COMPARE_ENVIRONMENT_TAB) { + handlePopUpOpen("compareEnvironments"); + return; + } + + if (value === ADD_ENVIRONMENT_TAB) { + handleAddEnvironment(); + return; + } + + handleSelect(value); + }} + defaultValue="environment-tabs" + > + + {tabEnvironments.map((environment) => ( + +

{environment.name}

+
+ ))} + {dropdownEnvironments.length ? ( + + + + +
+ +
+
+
+
+ + Environments +
+ {dropdownEnvironments.map((environment) => ( + { + e.stopPropagation(); + handleSelect(environment.slug); + }} + > + {environment.name} + + ))} +
+
+ + {(isAllowed) => ( + + + + )} + + + + ) : ( + + +
+ +
+
+
+ )} + {currentWorkspace.environments.length > 1 && ( + +
+ + Compare Environments +
+
+ )} + + + handlePopUpToggle("compareEnvironments", isOpen)} + > + + + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + /> + handlePopUpToggle("createEnvironment", isOpen)} + onComplete={async (env) => { + await queryClient.refetchQueries({ + queryKey: workspaceKeys.getWorkspaceById(currentWorkspace.id) + }); + handleSelect(env.slug); + }} + /> + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/index.tsx new file mode 100644 index 000000000..76be6609b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/index.tsx @@ -0,0 +1 @@ +export * from "./EnvironmentTabs"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx new file mode 100644 index 000000000..a2169fa5a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx @@ -0,0 +1,52 @@ +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +type Props = { + secretPath: string; +}; + +export const FolderBreadCrumbs = ({ secretPath = "/" }: Props) => { + const navigate = useNavigate({ + from: "/projects/secret-management/$projectId/secrets/$envSlug" + }); + + const onFolderCrumbClick = (index: number) => { + const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`; + if (secretPath === newSecPath) return; + navigate({ + search: (prev) => ({ ...prev, secretPath: newSecPath }) + }); + }; + + return ( +
+
onFolderCrumbClick(0)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + +
+ {(secretPath || "") + .split("/") + .filter(Boolean) + .map((path, index, arr) => ( +
onFolderCrumbClick(index + 1)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + {path} +
+ ))} +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/index.tsx new file mode 100644 index 000000000..8224cdb25 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/index.tsx @@ -0,0 +1 @@ +export { FolderBreadCrumbs } from "./FolderBreadCrumbs"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx index 9b8c9ccaa..fd65bc639 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx @@ -36,6 +36,7 @@ type Props = { workspaceId: string; secretPath?: string; onNavigateToFolder: (path: string) => void; + canNavigate: boolean; }; export const FolderListView = ({ @@ -43,7 +44,8 @@ export const FolderListView = ({ environment, workspaceId, secretPath = "/", - onNavigateToFolder + onNavigateToFolder, + canNavigate }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "updateFolder", @@ -190,7 +192,7 @@ export const FolderListView = ({ }; const handleFolderClick = (name: string, isPending?: boolean) => { - if (isPending) { + if (isPending || !canNavigate) { return; } const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx index ddb618708..58c00db6f 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncAuditLogsSection.tsx @@ -2,7 +2,7 @@ import { faFingerprint } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link } from "@tanstack/react-router"; -import { useSubscription } from "@app/context"; +import { useSubscription, useWorkspace } from "@app/context"; import { EventType } from "@app/hooks/api/auditLogs/enums"; import { TSecretSync } from "@app/hooks/api/secretSyncs"; import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection"; @@ -19,6 +19,7 @@ type Props = { export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => { const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30; @@ -36,6 +37,7 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => { ; - handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: (environment: WorkspaceEnv) => void; }; const schema = z.object({ @@ -24,10 +24,14 @@ const schema = z.object({ export type FormData = z.infer; -export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { +type ContentProps = { + onComplete: (environment: WorkspaceEnv) => void; +}; + +const Content = ({ onComplete }: ContentProps) => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isPending } = useCreateWsEnvironment(); - const { control, handleSubmit, reset } = useForm({ + const { control, handleSubmit } = useForm({ resolver: zodResolver(schema) }); @@ -35,7 +39,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle try { if (!currentWorkspace?.id) return; - await mutateAsync({ + const env = await mutateAsync({ workspaceId: currentWorkspace.id, name: environmentName, slug: environmentSlug @@ -46,7 +50,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle type: "success" }); - handlePopUpClose("createEnv"); + onComplete(env); } catch (err) { console.error(err); createNotification({ @@ -57,64 +61,62 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }; return ( - { - handlePopUpToggle("createEnv", isOpen); - reset(); - }} - > - - - ( - - - - )} - /> - ( - - - - )} - /> -
- + + ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + +
+ + ); +}; - -
- +export const AddEnvironmentModal = ({ onComplete, ...props }: Props) => { + return ( + + + { + if (onComplete) onComplete(env); + props.onOpenChange(false); + }} + /> ); diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 323fe2640..34acf0c7b 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -103,9 +103,8 @@ export const EnvironmentSection = () => { )} handlePopUpToggle("createEnv", isOpen)} />