Merge branch 'main' into ENG-3506-LDAP

This commit is contained in:
x032205
2025-09-02 20:05:48 -04:00
118 changed files with 3921 additions and 635 deletions
+1 -1
View File
@@ -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",
@@ -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
};
}
});
};
@@ -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.
@@ -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<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete"
"findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete"
>;
groupDAL: Pick<TGroupDALFactory, "insertMany" | "transaction" | "find">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
orgMembershipDAL: Pick<
TOrgMembershipDALFactory,
"find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId"
>;
};
export type TGithubOrgSyncServiceFactory = ReturnType<typeof githubOrgSyncServiceFactory>;
@@ -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<TSyncResult> => {
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<string, string[]>();
const allGithubUsernamesInTeams = new Set<string>();
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<string>();
const updatedTeams = new Set<string>();
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<string>();
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<string>();
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
};
};
@@ -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;
}
@@ -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);
@@ -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);
@@ -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."
)
}),
+1
View File
@@ -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,
+3 -1
View File
@@ -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.",
+43
View File
@@ -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<void>((resolve) => {
setTimeout(() => resolve(), ms);
});
export const retryWithBackoff = async <T>(fn: () => Promise<T>, maxRetries = 3, baseDelay = 1000): Promise<T> => {
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!;
};
+4 -2
View File
@@ -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({
@@ -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;
@@ -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]),
@@ -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()
})
@@ -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),
@@ -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"
});
}
};
@@ -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"
});
}
}
@@ -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"
});
}
}
@@ -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"
});
}
}
@@ -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"
});
}
}
@@ -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"
});
}
};
@@ -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"
});
}
};
@@ -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"
});
}
@@ -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"
});
}
@@ -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 <T>(
appConnection: { gatewayId?: string | null },
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
requestConfig: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
// 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<TokenRespData>(
`${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<TokenRespData>(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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const { data } = await request.get<THCVaultMountResponse>(`${instanceUrl}/v1/sys/mounts`, {
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(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 } : {})
}
});
@@ -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"),
@@ -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<THCVaultConnection>;
export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
export const hcVaultConnectionService = (
getAppConnection: TGetAppConnectionFunc,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
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");
@@ -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."
});
}
@@ -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);
@@ -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({
@@ -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<string, string>;
};
const vaultFactory = () => {
const vaultFactory = (gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">) => {
const $gatewayProxyWrapper = async <T>(
inputs: {
gatewayId: string;
targetHost?: string;
targetPort?: number;
},
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => {
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<string, string>; // 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<string, string>; // KV v2 has nested data structure
metadata: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
};
}>(`/v1/${mountPath}/data/${secretPath}`)
data: Record<string, string>; // 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<string[]> => {
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<VaultData[]> {
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
) => {
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);
@@ -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<TUserDALFactory, "findById">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
@@ -19,7 +21,8 @@ export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrati
export const externalMigrationServiceFactory = ({
permissionService,
externalMigrationQueue,
userDAL
userDAL,
gatewayService
}: TExternalMigrationServiceFactoryDep) => {
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,
@@ -31,6 +31,7 @@ export type TImportVaultDataDTO = {
vaultNamespace?: string;
mappingType: VaultMappingType;
vaultUrl: string;
gatewayId?: string;
} & Omit<TOrgPermission, "orgId">;
export type TImportInfisicalDataCreate = {
@@ -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<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems">;
keyStore: Pick<
TKeyStoreFactory,
"setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock"
>;
};
export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>;
@@ -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<ReturnType<typeof keyStore.acquireLock>>;
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 ({
@@ -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<TUserEncryptionKeys>(
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
};
};
@@ -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,
@@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = {
idOrName: string;
}>;
};
export type TGetFolderByPathDTO = {
projectId: string;
environment: string;
secretPath: string;
};
@@ -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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
try {
const { data } = await request.get<THCVaultListVariablesResponse>(
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
{
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
}
const { data } = await requestWithHCVaultGateway<THCVaultListVariablesResponse>(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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
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 }]));
}
@@ -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<TSecretMap> => {
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:
@@ -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.
<CardGroup cols={2}>
@@ -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.
@@ -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.
<Steps>
<Step title="Generate a GitHub Access Token">
To perform manual syncs, you'll need to create a GitHub Personal Access Token with the appropriate permissions. GitHub offers two types of tokens:
<Tabs>
<Tab title="Classic Token">
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)
</Tab>
<Tab title="Fine-grained Token">
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)
</Tab>
</Tabs>
</Step>
<Step title="Configure the Token in Infisical">
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)
</Step>
<Step title="Perform Manual Sync">
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.
</Step>
</Steps>
## Troubleshooting
<Accordion title="Please check if your organization has approved the Infisical OAuth application.">
@@ -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.
</Step>
@@ -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.
</Note>
## 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.
<Note>
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.
</Note>
## FAQ
<AccordionGroup>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 587 KiB

@@ -149,6 +149,7 @@ Infisical supports two methods for connecting to Hashicorp Vault.
<Tab title="App Role">
- **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.
<Tab title="Access Token">
- **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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -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) {
@@ -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) => (
<div
className={twMerge(
@@ -27,7 +29,7 @@ export const EmptyState = ({
>
<FontAwesomeIcon icon={icon} size={iconSize} />
<div className="flex flex-col items-center py-4">
<div className="text-sm text-bunker-300">{title}</div>
<div className={twMerge("text-sm text-bunker-300", titleClassName)}>{title}</div>
<div>{children}</div>
</div>
</div>
@@ -67,7 +67,7 @@ export const FilterableSelect = <T,>({
}),
menuPortal: (provided) => ({
...provided,
zIndex: 9999
zIndex: 99999
})
}}
tabSelectsValue={tabSelectsValue}
+15 -17
View File
@@ -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<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export const TableContainer = ({
children,
className,
isRounded = true,
...props
}: TableContainerProps): JSX.Element => (
<div
className={twMerge(
"relative w-full overflow-x-auto border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter",
isRounded && "rounded-lg",
className
)}
{...props}
>
{children}
</div>
export const TableContainer = forwardRef<HTMLDivElement, TableContainerProps>(
({ children, className, isRounded = true, ...props }, ref): JSX.Element => (
<div
ref={ref}
className={twMerge(
"relative w-full overflow-x-auto border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter",
isRounded && "rounded-lg",
className
)}
{...props}
>
{children}
</div>
)
);
// main parent table
@@ -2,6 +2,7 @@ export { useOrgPermission } from "./OrgPermissionContext";
export type { TOrgPermission } from "./types";
export {
OrgPermissionActions,
OrgPermissionAuditLogsActions,
OrgPermissionBillingActions,
OrgPermissionGroupActions,
OrgPermissionIdentityActions,
@@ -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]
@@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext";
export type { ProjectPermissionSet, TProjectPermission } from "./types";
export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
@@ -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]
+2
View File
@@ -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,
+6 -4
View File
@@ -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:
+2
View File
@@ -16,6 +16,8 @@ export type TAcmeCertificateAuthority = {
};
directoryUrl: string;
accountEmail: string;
eabKid?: string;
eabHmacKey?: string;
};
};
@@ -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,
+1 -1
View File
@@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
"secrets" | "secretRotations"
> & {
secrets?: SecretV3RawSanitized[];
secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3RawSanitized | null)[];
})[];
@@ -1,6 +1,7 @@
export {
useCreateGithubSyncOrgConfig,
useDeleteGithubSyncOrgConfig,
useSyncAllGithubTeams,
useUpdateGithubSyncOrgConfig
} from "./mutations";
export { githubOrgSyncConfigQueryKeys } from "./queries";
@@ -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;
}
});
};
@@ -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
});
}
});
+12 -7
View File
@@ -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<object, object, CreateEnvironmentDTO>({
mutationFn: ({ workspaceId, name, slug }) => {
return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, {
name,
slug
});
return useMutation<WorkspaceEnv, WorkspaceEnv, CreateEnvironmentDTO>({
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({
+25 -2
View File
@@ -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<HTMLTableElement>;
};
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,
@@ -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]
);
@@ -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);
}
}
@@ -101,7 +101,7 @@ export const ProjectSelect = () => {
<div className="-mr-2 flex w-full items-center gap-1">
<DropdownMenu modal={false}>
<Link
to={getProjectHomePage(currentWorkspace.type)}
to={getProjectHomePage(currentWorkspace.type, currentWorkspace.environments)}
params={{
projectId: currentWorkspace.id
}}
@@ -158,7 +158,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
}
@@ -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 = () => {
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentWorkspace.id
projectId: currentWorkspace.id,
...(currentWorkspace.environments.length
? { envSlug: currentWorkspace.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<MenuItem
isSelected={
isActive ||
location.pathname.startsWith(
`/projects/secret-management/${currentWorkspace.id}/overview`
)
}
>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faVault} />
@@ -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) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.eabKid"
render={({ field, fieldState: { error } }) => (
<FormControl
label="EAB Key Identifier (KID)"
isError={Boolean(error)}
errorText={error?.message}
isOptional={!REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
isRequired={REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
>
<Input
{...field}
placeholder="abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.eabHmacKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="EAB HMAC Key"
isError={Boolean(error)}
errorText={error?.message}
isOptional={!REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
isRequired={REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
>
<Input
{...field}
placeholder="dGhpc2lzYW5leGFtcGxlaG1hY2tleWZvcmRpZ2ljZXJ0YWNtZXRlc3RpbmcxMjM0NTY3ODkw"
/>
</FormControl>
)}
/>
</>
)}
{caType === CaType.AZURE_AD_CS && (
@@ -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 (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -115,6 +128,54 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
</FormControl>
)}
/>
{subscription.gateway && (
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="gatewayId"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<Select
isDisabled={!isAllowed}
value={value as string}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
)}
<Controller
name="credentials.instanceUrl"
control={control}
@@ -4,9 +4,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import {
OrgPermissionAuditLogsActions,
OrgPermissionSubjects,
ProjectPermissionAuditLogsActions,
ProjectPermissionSub,
useSubscription
} from "@app/context";
import { Timezone } from "@app/helpers/datetime";
import { withPermission } from "@app/hoc";
import { withPermission, withProjectPermission } from "@app/hoc";
import { Workspace } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -28,153 +34,173 @@ type Props = {
project?: Workspace;
};
export const LogsSection = withPermission(
({ presets, refetchInterval, showFilters = true, pageView = false, project }: Props) => {
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<TAuditLogFilterFormData>({
eventType: presets?.eventType || [],
actor: presets?.actorId,
eventMetadata: presets?.eventMetadata
});
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const [logFilter, setLogFilter] = useState<TAuditLogFilterFormData>({
eventType: presets?.eventType || [],
actor: presets?.actorId,
eventMetadata: presets?.eventMetadata
});
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
const [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>(
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<TAuditLogDateFilterFormData>(
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 (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
<div>
<div className="flex items-center gap-1 whitespace-nowrap">
<p className="text-xl font-semibold text-mineshaft-100">Audit History</p>
<a
href="https://infisical.com/docs/documentation/platform/audit-logs"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.1rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter
project={project}
presets={presets}
setFilter={setLogFilter}
filter={logFilter}
/>
)}
</div>
</div>
<div className="space-y-2">
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: project?.id || logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
</div>
);
useEffect(() => {
if (subscription && !subscription.auditLogs) {
handlePopUpOpen("upgradePlan");
}
}, [subscription]);
if (pageView)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter presets={presets} setFilter={setLogFilter} filter={logFilter} />
)}
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
<div>
<div className="flex items-center gap-1 whitespace-nowrap">
<p className="text-xl font-semibold text-mineshaft-100">Audit History</p>
<a
href="https://infisical.com/docs/documentation/platform/audit-logs"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.1rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter
project={project}
presets={presets}
setFilter={setLogFilter}
filter={logFilter}
/>
)}
</div>
</div>
<div className="space-y-2">
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: project?.id || logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs }
);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter presets={presets} setFilter={setLogFilter} filter={logFilter} />
)}
</div>
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: project?.id || logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
);
};
export const LogsSection = (props: Props) => {
const { project } = props;
if (project) {
const ProjectLogsSectionWithPermission = withProjectPermission(LogsSectionComponent, {
action: ProjectPermissionAuditLogsActions.Read,
subject: ProjectPermissionSub.AuditLogs
});
return <ProjectLogsSectionWithPermission {...props} />;
}
const OrgLogsSectionWithPermission = withPermission(LogsSectionComponent, {
action: OrgPermissionAuditLogsActions.Read,
subject: OrgPermissionSubjects.AuditLogs
});
return <OrgLogsSectionWithPermission {...props} />;
};
@@ -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 &&
@@ -193,7 +193,7 @@ export const MyProjectView = ({
<div
onClick={() => {
navigate({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -247,7 +247,7 @@ export const MyProjectView = ({
<div
onClick={() => {
navigate({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -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,
@@ -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<TFormSchema>;
control: Control<TFormSchema>;
};
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<keyof typeof rule>;
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 (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td className="w-4">
<FontAwesomeIcon className="w-4" icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td className="w-full select-none">Audit Logs</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="h-8 w-40 bg-mineshaft-700"
dropdownContainerClassName="border text-left border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
position="popper"
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td colSpan={3} className="border-mineshaft-500 bg-mineshaft-900 p-8">
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.audit-logs.${action}`}
key={`permissions.audit-logs.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={Boolean(field.value)}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.audit-logs.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -71,6 +71,7 @@ type Props = {
| "gateway"
| "secret-share"
| "billing"
| "audit-logs"
| "machine-identity-auth-template"
>;
setValue: UseFormSetValue<TFormSchema>;
@@ -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) => {
/>
);
})}
<OrgPermissionAuditLogsRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionIdentityRow
control={control}
setValue={setValue}
@@ -1,6 +1,4 @@
import { useState } from "react";
import { faKey, faVault } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
@@ -20,14 +18,16 @@ enum WizardSteps {
const PLATFORM_LIST = [
{
icon: faKey,
image: "/images/integrations/EnvKey.png",
platform: "env-key",
title: "Env Key"
title: "EnvKey",
size: 34
},
{
icon: faVault,
image: "/images/integrations/Vault.png",
platform: "vault",
title: "Vault"
title: "HCP Vault",
size: 40
}
] as const;
@@ -67,7 +67,7 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
{PLATFORM_LIST.map((platform, idx) => (
<div
key={`platform-${idx + 1}`}
className="flex h-28 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
className="flex h-28 w-32 cursor-pointer flex-col items-center justify-between rounded border border-mineshaft-500 bg-mineshaft-700 p-6 py-5 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
role="button"
tabIndex={0}
onClick={() => {
@@ -81,7 +81,11 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
}
}}
>
<FontAwesomeIcon icon={platform.icon} size="lg" />
<img
src={platform.image}
alt={`${platform.title} logo`}
style={{ width: platform.size }}
/>
<div className="whitespace-pre-wrap text-center text-sm">{platform.title}</div>
</div>
))}
@@ -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<typeof formSchema>;
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<TFormData>({
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.
<div className="mt-2 text-xs opacity-80">
Currently only KV Secret Engine V2 is supported for Vault migrations.
Currently only KV Secret Engine is supported for Vault migrations.
</div>
</p>
</NoticeBannerV2>
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="w-full flex-1">
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
isOptional
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value as string}
onValueChange={(v) => {
if (v !== "") {
onChange(v);
}
}}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewayLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={undefined as unknown as string}
onClick={() => {
onChange(undefined);
}}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<Controller
control={control}
name="vaultUrl"
@@ -51,7 +51,7 @@ export const GithubOrgSyncConfigModal = ({
formState: { isSubmitting }
} = useForm<FormData>({
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 = ({
</FormControl>
)}
/>
{/* <Controller
<Controller
control={control}
name="githubOrgAccessToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="GitHub Org Scoped Access Token"
label="GitHub Access Token"
isError={Boolean(error)}
isOptional
errorText={error?.message}
helperText="A GitHub access token is required only for private organizations. It will not be visible after saving."
helperText="Required for manual sync operations. The token must have 'read:org' permissions."
>
<Input {...field} placeholder="example" />
<Input {...field} type="password" placeholder="ghp_xxxxxxxxxxxx" />
</FormControl>
)}
/> */}
/>
<div className="flex gap-8 pt-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
{isUpdate ? "Update" : "Configure"}
@@ -1,10 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { githubOrgSyncConfigQueryKeys, useUpdateGithubSyncOrgConfig } from "@app/hooks/api";
import {
githubOrgSyncConfigQueryKeys,
useSyncAllGithubTeams,
useUpdateGithubSyncOrgConfig
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
@@ -24,10 +29,73 @@ export const OrgGithubSyncSection = () => {
});
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
const syncAllTeamsMutation = useSyncAllGithubTeams();
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined;
const handleBulkSync = async () => {
try {
const result = await syncAllTeamsMutation.mutateAsync();
let message = "Successfully synced teams";
const details = [];
if (result.createdTeams.length > 0) {
details.push(
`${result.createdTeams.length} new team${result.createdTeams.length === 1 ? "" : "s"} created`
);
}
if (result.updatedTeams.length > 0) {
details.push(
`${result.updatedTeams.length} team${result.updatedTeams.length === 1 ? "" : "s"} updated`
);
}
if (result.removedMemberships > 0) {
details.push(
`${result.removedMemberships} membership${result.removedMemberships === 1 ? "" : "s"} removed`
);
}
if (details.length > 0) {
message += `. ${details.join(", ")}`;
}
createNotification({
text: message,
type: "success"
});
if (result.errors && result.errors.length > 0) {
createNotification({
text: `Sync completed with ${result.errors.length} warnings. Check the console for details.`,
type: "warning"
});
console.warn("Sync errors:", result.errors);
}
} catch (error) {
const errorMessage =
(error as any)?.response?.data?.message || (error as Error)?.message || "Unknown error";
if (
errorMessage.includes("token") &&
(errorMessage.includes("required") ||
errorMessage.includes("invalid") ||
errorMessage.includes("expired") ||
errorMessage.includes("set a token first"))
) {
createNotification({
text: "Please set a GitHub access token in the configuration modal to continue with the sync",
type: "error"
});
} else {
createNotification({
text: `Failed to sync GitHub teams: ${errorMessage}`,
type: "error"
});
}
}
};
return (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">
@@ -86,6 +154,33 @@ export const OrgGithubSyncSection = () => {
</p>
</div>
)}
{data && data.isActive && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Sync Now</h2>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.GithubOrgSyncManual}
>
{(isAllowed) => (
<Button
onClick={() => handleBulkSync()}
colorSchema="primary"
variant="outline_bg"
isDisabled={!isAllowed || syncAllTeamsMutation.isPending}
isLoading={syncAllTeamsMutation.isPending}
>
Sync Now
</Button>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Manually sync GitHub teams for all organization members. This will update team
memberships for users who have previously logged in with GitHub.
</p>
</div>
)}
<Modal
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
onOpenChange={(isOpen) => {
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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,
@@ -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) => {
<LogsSection
refetchInterval={4000}
showFilters={false}
project={currentWorkspace}
presets={{
eventMetadata: { integrationId: integration.id },
startDate: new Date(
@@ -52,6 +52,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
import { SecretV3RawSanitized } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
import { useResizableColWidth } from "@app/hooks/useResizableColWidth";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
@@ -64,6 +65,8 @@ import { ActionBar } from "./components/ActionBar";
import { CommitForm } from "./components/CommitForm";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { EnvironmentTabs } from "./components/EnvironmentTabs";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
@@ -105,7 +108,7 @@ const Page = () => {
const { permission } = useProjectPermission();
const { mutateAsync: createCommit } = useCreateCommit();
const tableRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(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 (
<div className="flex h-full w-full flex-col items-center justify-center px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
</div>
);
return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader
title={
currentWorkspace.environments.find((env) => env.slug === environment)?.name ?? environment
}
title="Secrets Management"
description={
<p className="text-md text-bunker-300">
Inject your secrets using
@@ -759,6 +782,8 @@ const Page = () => {
}
/>
<SecretV2MigrationSection />
<FolderBreadCrumbs secretPath={secretPath} />
<EnvironmentTabs secretPath={secretPath} />
{!isRollbackMode ? (
<>
<ActionBar
@@ -938,6 +963,7 @@ const Page = () => {
workspaceId={workspaceId}
secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
canNavigate={isFetched}
/>
)}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
@@ -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<WorkspaceEnv[]>(() => {
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<Filter>(DEFAULT_FILTER_STATE);
const {
offset,
limit,
orderDirection,
setOrderDirection,
setPage,
perPage,
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(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<HTMLTableElement>(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
<div className="flex flex-1 flex-col-reverse overflow-hidden">
{!isOverviewLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
secretRotationCount={totalSecretRotationCount}
/>
}
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}
/>
)}
<div className="thin-scrollbar flex flex-1 flex-col overflow-y-auto">
<TableContainer
ref={tableRef}
className={twMerge(
"mt-4 flex flex-1 flex-col border-mineshaft-500 bg-mineshaft-700",
!isTableEmpty && "rounded-b-none"
)}
>
{/* eslint-disable-next-line no-nested-ternary */}
{isOverviewLoading ? (
<div className="flex h-full flex-col items-center justify-center">
<Lottie
isAutoPlay
icon="infisical_loading"
className="h-10 place-self-center self-center"
/>
</div>
) : isTableEmpty ? (
<EmptyState
titleClassName="text-base"
className="m-auto bg-mineshaft-700 text-lg"
title={
isTableFiltered
? "No secrets to compare with current filters"
: "No secrets to compare"
}
/>
) : (
<Table className="border-collapse bg-mineshaft-700">
<THead className="sticky top-0 z-20">
<Tr className="sticky top-0 z-20">
<Th className="sticky left-0 z-10 border-none p-0" style={{ width: colWidth }}>
<div className="relative">
<div
tabIndex={-1}
role="button"
className={`absolute -right-[0.02rem] z-40 h-full w-0.5 cursor-ew-resize hover:bg-blue-400/20 ${
isResizing ? "bg-blue-400/75" : "bg-transparent"
}`}
onMouseDown={handleMouseDown}
/>
<div className="pointer-events-none absolute -right-[0.02rem] top-[0.67rem] z-30">
<div className="h-5 w-0.5 rounded-[1.5px] bg-gray-400 opacity-50" />
</div>
<div className="flex h-full items-center border-b-2 border-r border-mineshaft-500 bg-mineshaft-700 bg-clip-padding p-0 px-4 py-2.5 text-sm normal-case">
Name
<IconButton
variant="plain"
className="ml-1 mt-[0.1rem]"
ariaLabel="sort"
onClick={() =>
setOrderDirection((prev) =>
prev === OrderByDirection.ASC
? OrderByDirection.DESC
: OrderByDirection.ASC
)
}
>
<FontAwesomeIcon
className="h-3"
icon={orderDirection === "asc" ? faArrowDown : faArrowUp}
/>
</IconButton>
</div>
</div>
</Th>
{compareEnvironments?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const importedSecKeyCount = getEnvImportedSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount;
return (
<Th
className="whitespace-nowrap border-none p-0 text-center"
key={`environment-${slug}`}
>
<div
className={twMerge(
"flex h-full w-full items-center justify-center gap-x-2 border-b-2 border-mineshaft-500 bg-mineshaft-700 p-0 px-4 py-2.5 text-center text-sm normal-case",
index < compareEnvironments.length - 1 && "border-r"
)}
>
{name}
{missingKeyCount > 0 && (
<Tooltip
className="max-w-none lowercase"
content={
<>
{missingKeyCount} secret{missingKeyCount > 1 ? "s" : ""} missing
compared to other environments on this page
</>
}
>
<Badge
variant="primary"
className="-mt-[0.05rem] flex h-4 items-center gap-x-1 pt-[0.1rem] font-normal leading-3"
>
<FontAwesomeIcon icon={faWarning} className="-mt-[0.1rem] w-2.5" />
{missingKeyCount}
</Badge>
</Tooltip>
)}
</div>
</Th>
);
})}
</Tr>
</THead>
<TBody>
{folderNamesAndDescriptions.map(({ name: folderName }, index) => (
<FolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={compareEnvironments}
key={`overview-${folderName}-${index + 1}`}
colWidth={colWidth}
/>
))}
{dynamicSecretNames.map((dynamicSecretName, index) => (
<DynamicSecretRow
dynamicSecretName={dynamicSecretName}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={compareEnvironments}
key={`overview-${dynamicSecretName}-${index + 1}`}
colWidth={colWidth}
/>
))}
{secretRotationNames.map((secretRotationName, index) => (
<SecretRotationRow
secretRotationName={secretRotationName}
isSecretRotationInEnv={isSecretRotationPresentInEnv}
environments={compareEnvironments}
getSecretRotationByName={getSecretRotationByName}
key={`overview-${secretRotationName}-${index + 1}`}
colWidth={colWidth}
tableWidth={tableWidth}
/>
))}
{secKeys.map((key, index) => (
<SecretRow
colWidth={colWidth}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={handleIsImportedSecretPresentInEnv}
key={`overview-${key}-${index + 1}`}
environments={compareEnvironments}
secretKey={key}
getSecretByKey={getSecretByKey}
tableWidth={tableWidth}
/>
))}
<SecretNoAccessRow
colWidth={colWidth}
environments={compareEnvironments}
count={Math.max(
(page * perPage > totalCount ? totalCount % perPage : perPage) -
(totalUniqueFoldersInPage || 0) -
(totalUniqueDynamicSecretsInPage || 0) -
(totalUniqueSecretsInPage || 0) -
(totalUniqueSecretImportsInPage || 0) -
(totalUniqueSecretRotationsInPage || 0),
0
)}
/>
</TBody>
</Table>
)}
</TableContainer>
</div>
<div className="mt-3 flex flex-row items-center justify-center space-x-2">
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="h-full flex-1"
placeholder="Search by resource name..."
leftIcon={<FontAwesomeIcon icon={faSearch} />}
containerClassName="h-10"
/>
{isTableFiltered && (
<Button
variant="plain"
colorSchema="secondary"
onClick={() => {
setFilter(DEFAULT_FILTER_STATE);
}}
>
Clear Filters
</Button>
)}
{compareEnvironments.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline_bg"
className={twMerge(
"flex h-[2.5rem]",
isTableFiltered && "border-primary/40 bg-primary/10"
)}
leftIcon={
<FontAwesomeIcon
icon={faFilter}
className={isTableFiltered ? "text-primary/80" : undefined}
/>
}
>
Filters
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
sideOffset={2}
>
<DropdownMenuLabel>Filter by Resource</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Folder);
}}
icon={filter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.DynamicSecret);
}}
icon={filter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.SecretRotation);
}}
icon={filter[RowType.SecretRotation] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
<span>Secret Rotations</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Secret);
}}
icon={filter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="!z-[99999999]">
<FormLabel label="Select Environments to Compare" />
<FilterableSelect
value={selectedEnvironments}
onChange={(value) => {
const selected = value as MultiValue<WorkspaceEnv>;
setSelectedEnvironments((selected as WorkspaceEnv[]) ?? []);
}}
placeholder="Leave blank to compare all environments"
options={currentWorkspace.environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
isMulti
/>
</div>
</div>
);
};
@@ -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 (
<Tr isHoverable className="group border-mineshaft-500">
<ResourceNameCell
label={dynamicSecretName}
icon={faFingerprint}
colWidth={colWidth}
iconClassName="text-yellow-700"
/>
{environments.map(({ slug }, i) => {
const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`dynamic-secret-${dynamicSecretName}-${i + 1}-value`}
/>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { DynamicSecretRow } from "./DynamicSecretRow";
@@ -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 (
<Tr isHoverable className="group border-mineshaft-500">
<ResourceNameCell
label={folderName}
icon={faFolder}
iconClassName="text-yellow-700"
colWidth={colWidth}
/>
{environments.map(({ slug }, i) => {
const isPresent = isFolderPresentInEnv(folderName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`folder-${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { FolderRow } from "./FolderRow";

Some files were not shown because too many files have changed in this diff Show More