Merge branch 'main' into ENG-3506-LDAP
@@ -37,7 +37,7 @@
|
|||||||
"build": "tsup --sourcemap",
|
"build": "tsup --sourcemap",
|
||||||
"build:frontend": "npm run build --prefix ../frontend",
|
"build:frontend": "npm run build --prefix ../frontend",
|
||||||
"start": "node --enable-source-maps dist/main.mjs",
|
"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: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'",
|
"lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'",
|
||||||
"test:unit": "vitest run -c vitest.unit.config.ts",
|
"test:unit": "vitest run -c vitest.unit.config.ts",
|
||||||
|
|||||||
@@ -126,4 +126,39 @@ export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) =>
|
|||||||
return { githubOrgSyncConfig };
|
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 { BadRequestError } from "@app/lib/errors";
|
||||||
import { ActorType } from "@app/services/auth/auth-type";
|
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 { 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 { TAuditLogDALFactory } from "./audit-log-dal";
|
||||||
import { TAuditLogQueueServiceFactory } from "./audit-log-queue";
|
import { TAuditLogQueueServiceFactory } from "./audit-log-queue";
|
||||||
import { EventType, TAuditLogServiceFactory } from "./audit-log-types";
|
import { EventType, TAuditLogServiceFactory } from "./audit-log-types";
|
||||||
@@ -41,7 +41,10 @@ export const auditLogServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
actionProjectType: ActionProjectType.Any
|
actionProjectType: ActionProjectType.Any
|
||||||
});
|
});
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionAuditLogsActions.Read,
|
||||||
|
ProjectPermissionSub.AuditLogs
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Organization-wide logs
|
// Organization-wide logs
|
||||||
const { permission } = await permissionService.getOrgPermission(
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
@@ -52,7 +55,10 @@ export const auditLogServiceFactory = ({
|
|||||||
actorOrgId
|
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.
|
// 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 { ForbiddenError } from "@casl/ability";
|
||||||
import { Octokit } from "@octokit/core";
|
import { Octokit } from "@octokit/core";
|
||||||
import { paginateGraphql } from "@octokit/plugin-paginate-graphql";
|
import { paginateGraphql } from "@octokit/plugin-paginate-graphql";
|
||||||
import { Octokit as OctokitRest } from "@octokit/rest";
|
import { Octokit as OctokitRest } from "@octokit/rest";
|
||||||
|
import RE2 from "re2";
|
||||||
|
|
||||||
import { OrgMembershipRole } from "@app/db/schemas";
|
import { OrgMembershipRole } from "@app/db/schemas";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { groupBy } from "@app/lib/fn";
|
import { groupBy } from "@app/lib/fn";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { retryWithBackoff } from "@app/lib/retry";
|
||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
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 { TGroupDALFactory } from "../group/group-dal";
|
||||||
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-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 { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||||
import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal";
|
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);
|
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 = {
|
type TGithubOrgSyncServiceFactoryDep = {
|
||||||
githubOrgSyncDAL: TGithubOrgSyncDALFactory;
|
githubOrgSyncDAL: TGithubOrgSyncDALFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
userGroupMembershipDAL: Pick<
|
userGroupMembershipDAL: Pick<
|
||||||
TUserGroupMembershipDALFactory,
|
TUserGroupMembershipDALFactory,
|
||||||
"findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete"
|
"findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete"
|
||||||
>;
|
>;
|
||||||
groupDAL: Pick<TGroupDALFactory, "insertMany" | "transaction" | "find">;
|
groupDAL: Pick<TGroupDALFactory, "insertMany" | "transaction" | "find">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
|
orgMembershipDAL: Pick<
|
||||||
|
TOrgMembershipDALFactory,
|
||||||
|
"find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId"
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGithubOrgSyncServiceFactory = ReturnType<typeof githubOrgSyncServiceFactory>;
|
export type TGithubOrgSyncServiceFactory = ReturnType<typeof githubOrgSyncServiceFactory>;
|
||||||
@@ -40,7 +92,8 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
kmsService,
|
kmsService,
|
||||||
userGroupMembershipDAL,
|
userGroupMembershipDAL,
|
||||||
groupDAL,
|
groupDAL,
|
||||||
licenseService
|
licenseService,
|
||||||
|
orgMembershipDAL
|
||||||
}: TGithubOrgSyncServiceFactoryDep) => {
|
}: TGithubOrgSyncServiceFactoryDep) => {
|
||||||
const createGithubOrgSync = async ({
|
const createGithubOrgSync = async ({
|
||||||
githubOrgName,
|
githubOrgName,
|
||||||
@@ -304,8 +357,8 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName));
|
const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName));
|
||||||
|
|
||||||
if (newTeams.length || updateTeams.length || removeFromTeams.length) {
|
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(
|
const newGroups = await groupDAL.insertMany(
|
||||||
newTeams.map((newGroupName) => ({
|
newTeams.map((newGroupName) => ({
|
||||||
name: newGroupName,
|
name: newGroupName,
|
||||||
@@ -322,9 +375,11 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
})),
|
})),
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (updateTeams.length) {
|
if (updateTeams.length) {
|
||||||
|
await groupDAL.transaction(async (tx) => {
|
||||||
await userGroupMembershipDAL.insertMany(
|
await userGroupMembershipDAL.insertMany(
|
||||||
updateTeams.map((el) => ({
|
updateTeams.map((el) => ({
|
||||||
groupId: githubUserTeamOnInfisicalGroupByName[el][0].id,
|
groupId: githubUserTeamOnInfisicalGroupByName[el][0].id,
|
||||||
@@ -332,16 +387,433 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
})),
|
})),
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (removeFromTeams.length) {
|
if (removeFromTeams.length) {
|
||||||
|
await groupDAL.transaction(async (tx) => {
|
||||||
await userGroupMembershipDAL.delete(
|
await userGroupMembershipDAL.delete(
|
||||||
{ userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } },
|
{ userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } },
|
||||||
tx
|
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 {
|
return {
|
||||||
@@ -349,6 +821,8 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
updateGithubOrgSync,
|
updateGithubOrgSync,
|
||||||
deleteGithubOrgSync,
|
deleteGithubOrgSync,
|
||||||
getGithubOrgSync,
|
getGithubOrgSync,
|
||||||
syncUserGroups
|
syncUserGroups,
|
||||||
|
syncAllTeams,
|
||||||
|
validateGithubToken
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,3 +21,21 @@ export interface TDeleteGithubOrgSyncDTO {
|
|||||||
export interface TGetGithubOrgSyncDTO {
|
export interface TGetGithubOrgSyncDTO {
|
||||||
orgPermission: OrgServiceActor;
|
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 {
|
import {
|
||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionAuditLogsActions,
|
||||||
ProjectPermissionCertificateActions,
|
ProjectPermissionCertificateActions,
|
||||||
ProjectPermissionCmekActions,
|
ProjectPermissionCmekActions,
|
||||||
ProjectPermissionCommitsActions,
|
ProjectPermissionCommitsActions,
|
||||||
@@ -394,7 +395,7 @@ const buildMemberPermissionRules = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.Role);
|
can([ProjectPermissionActions.Read], ProjectPermissionSub.Role);
|
||||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs);
|
can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs);
|
||||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList);
|
can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList);
|
||||||
|
|
||||||
// double check if all CRUD are needed for CA and Certificates
|
// 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.Settings);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
|
can(ProjectPermissionAuditLogsActions.Read, ProjectPermissionSub.AuditLogs);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
|
||||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities);
|
can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities);
|
||||||
can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates);
|
can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates);
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export enum OrgPermissionAppConnectionActions {
|
|||||||
Connect = "connect"
|
Connect = "connect"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum OrgPermissionAuditLogsActions {
|
||||||
|
Read = "read"
|
||||||
|
}
|
||||||
|
|
||||||
export enum OrgPermissionKmipActions {
|
export enum OrgPermissionKmipActions {
|
||||||
Proxy = "proxy",
|
Proxy = "proxy",
|
||||||
Setup = "setup"
|
Setup = "setup"
|
||||||
@@ -90,6 +94,7 @@ export enum OrgPermissionSubjects {
|
|||||||
Sso = "sso",
|
Sso = "sso",
|
||||||
Scim = "scim",
|
Scim = "scim",
|
||||||
GithubOrgSync = "github-org-sync",
|
GithubOrgSync = "github-org-sync",
|
||||||
|
GithubOrgSyncManual = "github-org-sync-manual",
|
||||||
Ldap = "ldap",
|
Ldap = "ldap",
|
||||||
Groups = "groups",
|
Groups = "groups",
|
||||||
Billing = "billing",
|
Billing = "billing",
|
||||||
@@ -119,13 +124,14 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync]
|
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
||||||
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||||
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
|
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
|
||||||
| [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
|
| [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
| [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
||||||
| [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway]
|
| [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."),
|
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.")
|
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({
|
z.object({
|
||||||
subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."),
|
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.")
|
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({
|
z.object({
|
||||||
subject: z.literal(OrgPermissionSubjects.AuditLogs).describe("The entity this permission pertains to."),
|
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({
|
z.object({
|
||||||
subject: z.literal(OrgPermissionSubjects.ProjectTemplates).describe("The entity this permission pertains to."),
|
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.Edit, OrgPermissionSubjects.GithubOrgSync);
|
||||||
can(OrgPermissionActions.Delete, 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.Read, OrgPermissionSubjects.Ldap);
|
||||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap);
|
can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap);
|
||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap);
|
||||||
@@ -340,10 +357,7 @@ const buildAdminPermission = () => {
|
|||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms);
|
||||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms);
|
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms);
|
||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs);
|
|
||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs);
|
|
||||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs);
|
|
||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates);
|
can(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates);
|
||||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates);
|
can(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates);
|
||||||
@@ -416,7 +430,7 @@ const buildMemberPermission = () => {
|
|||||||
can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
|
can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
|
||||||
can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity);
|
can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity);
|
||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||||
|
|
||||||
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
|
can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections);
|
||||||
can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway);
|
can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway);
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ export enum ProjectPermissionSecretEventActions {
|
|||||||
SubscribeImportMutations = "subscribe-on-import-mutations"
|
SubscribeImportMutations = "subscribe-on-import-mutations"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectPermissionAuditLogsActions {
|
||||||
|
Read = "read"
|
||||||
|
}
|
||||||
|
|
||||||
export enum ProjectPermissionSub {
|
export enum ProjectPermissionSub {
|
||||||
Role = "role",
|
Role = "role",
|
||||||
Member = "member",
|
Member = "member",
|
||||||
@@ -304,7 +308,7 @@ export type ProjectPermissionSet =
|
|||||||
| [ProjectPermissionGroupActions, ProjectPermissionSub.Groups]
|
| [ProjectPermissionGroupActions, ProjectPermissionSub.Groups]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
|
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
|
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.AuditLogs]
|
| [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
|
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
||||||
@@ -645,7 +649,7 @@ const GeneralPermissionSchema = [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
subject: z.literal(ProjectPermissionSub.AuditLogs).describe("The entity this permission pertains to."),
|
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."
|
"Describe what action an entity can take."
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const KeyStorePrefixes = {
|
|||||||
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
|
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
|
||||||
SecretScanningLock: (dataSourceId: string, resourceExternalId: string) =>
|
SecretScanningLock: (dataSourceId: string, resourceExternalId: string) =>
|
||||||
`secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const,
|
`secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const,
|
||||||
|
IdentityLockoutLock: (lockoutKey: string) => `identity-lockout-lock-${lockoutKey}` as const,
|
||||||
CaOrderCertificateForSubscriberLock: (subscriberId: string) =>
|
CaOrderCertificateForSubscriberLock: (subscriberId: string) =>
|
||||||
`ca-order-certificate-for-subscriber-lock-${subscriberId}` as const,
|
`ca-order-certificate-for-subscriber-lock-${subscriberId}` as const,
|
||||||
SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const,
|
SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const,
|
||||||
|
|||||||
@@ -2174,7 +2174,9 @@ export const CertificateAuthorities = {
|
|||||||
directoryUrl: `The directory URL for the ACME Certificate Authority.`,
|
directoryUrl: `The directory URL for the ACME Certificate Authority.`,
|
||||||
accountEmail: `The email address for the ACME Certificate Authority.`,
|
accountEmail: `The email address for the ACME Certificate Authority.`,
|
||||||
provider: `The DNS provider 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: {
|
INTERNAL: {
|
||||||
type: "The type of CA to create.",
|
type: "The type of CA to create.",
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
};
|
||||||
@@ -680,7 +680,8 @@ export const registerRoutes = async (
|
|||||||
kmsService,
|
kmsService,
|
||||||
permissionService,
|
permissionService,
|
||||||
groupDAL,
|
groupDAL,
|
||||||
userGroupMembershipDAL
|
userGroupMembershipDAL,
|
||||||
|
orgMembershipDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
const ldapService = ldapConfigServiceFactory({
|
const ldapService = ldapConfigServiceFactory({
|
||||||
@@ -1747,7 +1748,8 @@ export const registerRoutes = async (
|
|||||||
const migrationService = externalMigrationServiceFactory({
|
const migrationService = externalMigrationServiceFactory({
|
||||||
externalMigrationQueue,
|
externalMigrationQueue,
|
||||||
userDAL,
|
userDAL,
|
||||||
permissionService
|
permissionService,
|
||||||
|
gatewayService
|
||||||
});
|
});
|
||||||
|
|
||||||
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
||||||
|
|||||||
@@ -703,6 +703,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
// prevent older projects from accessing endpoint
|
// prevent older projects from accessing endpoint
|
||||||
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
|
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(",") ?? [];
|
const tags = req.query.tags?.split(",") ?? [];
|
||||||
|
|
||||||
let remainingLimit = limit;
|
let remainingLimit = limit;
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
|
|||||||
vaultAccessToken: z.string(),
|
vaultAccessToken: z.string(),
|
||||||
vaultNamespace: z.string().trim().optional(),
|
vaultNamespace: z.string().trim().optional(),
|
||||||
vaultUrl: z.string(),
|
vaultUrl: z.string(),
|
||||||
mappingType: z.nativeEnum(VaultMappingType)
|
mappingType: z.nativeEnum(VaultMappingType),
|
||||||
|
gatewayId: z.string().optional()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
|||||||
200: z.object({
|
200: z.object({
|
||||||
secret: secretRawSchema.extend({
|
secret: secretRawSchema.extend({
|
||||||
secretValueHidden: z.boolean(),
|
secretValueHidden: z.boolean(),
|
||||||
|
secretPath: z.string(),
|
||||||
tags: SanitizedTagSchema.array().optional(),
|
tags: SanitizedTagSchema.array().optional(),
|
||||||
secretMetadata: ResourceMetadataSchema.optional()
|
secretMetadata: ResourceMetadataSchema.optional()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ export const appConnectionServiceFactory = ({
|
|||||||
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||||
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||||
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||||
hcvault: hcVaultConnectionService(connectAppConnectionById),
|
hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService),
|
||||||
windmill: windmillConnectionService(connectAppConnectionById),
|
windmill: windmillConnectionService(connectAppConnectionById),
|
||||||
teamcity: teamcityConnectionService(connectAppConnectionById),
|
teamcity: teamcityConnectionService(connectAppConnectionById),
|
||||||
oci: ociConnectionService(connectAppConnectionById, licenseService),
|
oci: ociConnectionService(connectAppConnectionById, licenseService),
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0
|
|||||||
};
|
};
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestError({
|
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;
|
tokenError = e;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError({
|
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;
|
tokenError = e;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError({
|
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;
|
tokenError = e;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError({
|
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;
|
tokenError = e;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError({
|
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) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestError({
|
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) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestError({
|
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({
|
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({
|
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 { request } from "@app/lib/config/request";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { removeTrailingSlash } from "@app/lib/fn";
|
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 { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
|
||||||
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
||||||
import {
|
import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
|
||||||
THCVaultConnection,
|
|
||||||
THCVaultConnectionConfig,
|
|
||||||
THCVaultMountResponse,
|
|
||||||
TValidateHCVaultConnectionCredentials
|
|
||||||
} from "./hc-vault-connection-types";
|
|
||||||
|
|
||||||
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
|
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
|
||||||
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
|
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
|
// Return access token directly if not using AppRole method
|
||||||
if (connection.method !== HCVaultConnectionMethod.AppRole) {
|
if (connection.method !== HCVaultConnectionMethod.AppRole) {
|
||||||
return connection.credentials.accessToken;
|
return connection.credentials.accessToken;
|
||||||
@@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
|
|||||||
// Generate temporary token for AppRole method
|
// Generate temporary token for AppRole method
|
||||||
try {
|
try {
|
||||||
const { instanceUrl, roleId, secretId } = connection.credentials;
|
const { instanceUrl, roleId, secretId } = connection.credentials;
|
||||||
const tokenResp = await request.post<TokenRespData>(
|
|
||||||
`${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
|
const tokenResp = await requestWithHCVaultGateway<TokenRespData>(connection, gatewayService, {
|
||||||
{ role_id: roleId, secret_id: secretId },
|
url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
|
||||||
{
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
|
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
|
||||||
}
|
},
|
||||||
}
|
data: { role_id: roleId, secret_id: secretId }
|
||||||
);
|
});
|
||||||
|
|
||||||
if (tokenResp.status !== 200) {
|
if (tokenResp.status !== 200) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
@@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => {
|
export const validateHCVaultConnectionCredentials = async (
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(config);
|
connection: THCVaultConnection,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
|
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await getHCVaultAccessToken(config);
|
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||||
|
|
||||||
// Verify token
|
// 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 }
|
headers: { "X-Vault-Token": accessToken }
|
||||||
});
|
});
|
||||||
|
|
||||||
return config.credentials;
|
return connection.credentials;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
logger.error(error, "Unable to verify HC Vault connection");
|
||||||
|
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
|
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof BadRequestError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Unable to validate connection: verify credentials"
|
message: "Unable to validate connection: verify credentials"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listHCVaultMounts = async (appConnection: THCVaultConnection) => {
|
export const listHCVaultMounts = async (
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(appConnection);
|
connection: THCVaultConnection,
|
||||||
const accessToken = await getHCVaultAccessToken(appConnection);
|
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: {
|
headers: {
|
||||||
"X-Vault-Token": accessToken,
|
"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", [
|
export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [
|
||||||
BaseHCVaultConnectionSchema.extend({
|
BaseHCVaultConnectionSchema.extend({
|
||||||
method: z.literal(HCVaultConnectionMethod.AccessToken),
|
method: z.literal(HCVaultConnectionMethod.AccessToken),
|
||||||
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({})
|
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({
|
||||||
|
namespace: true,
|
||||||
|
instanceUrl: true
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
BaseHCVaultConnectionSchema.extend({
|
BaseHCVaultConnectionSchema.extend({
|
||||||
method: z.literal(HCVaultConnectionMethod.AppRole),
|
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(
|
export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and(
|
||||||
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault)
|
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
export const UpdateHCVaultConnectionSchema = z
|
export const UpdateHCVaultConnectionSchema = z
|
||||||
@@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.describe(AppConnections.UPDATE(AppConnection.HCVault).credentials)
|
.describe(AppConnections.UPDATE(AppConnection.HCVault).credentials)
|
||||||
})
|
})
|
||||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault));
|
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }));
|
||||||
|
|
||||||
export const HCVaultConnectionListItemSchema = z.object({
|
export const HCVaultConnectionListItemSchema = z.object({
|
||||||
name: z.literal("HCVault"),
|
name: z.literal("HCVault"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { OrgServiceActor } from "@app/lib/types";
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
|
|
||||||
@@ -11,12 +12,15 @@ type TGetAppConnectionFunc = (
|
|||||||
actor: OrgServiceActor
|
actor: OrgServiceActor
|
||||||
) => Promise<THCVaultConnection>;
|
) => Promise<THCVaultConnection>;
|
||||||
|
|
||||||
export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
|
export const hcVaultConnectionService = (
|
||||||
|
getAppConnection: TGetAppConnectionFunc,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
const listMounts = async (connectionId: string, actor: OrgServiceActor) => {
|
const listMounts = async (connectionId: string, actor: OrgServiceActor) => {
|
||||||
const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor);
|
const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mounts = await listHCVaultMounts(appConnection);
|
const mounts = await listHCVaultMounts(appConnection, gatewayService);
|
||||||
return mounts;
|
return mounts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
||||||
|
|||||||
@@ -453,10 +453,14 @@ export const authLoginServiceFactory = ({
|
|||||||
|
|
||||||
const selectedOrg = await orgDAL.findById(organizationId);
|
const selectedOrg = await orgDAL.findById(organizationId);
|
||||||
|
|
||||||
// Check if authEnforced is true, if that's the case, throw an error
|
// Check if authEnforced is true and the current auth method is not an enforced method
|
||||||
if (selectedOrg.authEnforced) {
|
if (
|
||||||
|
selectedOrg.authEnforced &&
|
||||||
|
!isAuthMethodSaml(decodedToken.authMethod) &&
|
||||||
|
decodedToken.authMethod !== AuthMethod.OIDC
|
||||||
|
) {
|
||||||
throw new BadRequestError({
|
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;
|
directoryUrl: string;
|
||||||
accountEmail: string;
|
accountEmail: string;
|
||||||
hostedZoneId: string;
|
hostedZoneId: string;
|
||||||
|
eabKid?: string;
|
||||||
|
eabHmacKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const castDbEntryToAcmeCertificateAuthority = (
|
export const castDbEntryToAcmeCertificateAuthority = (
|
||||||
@@ -89,7 +91,9 @@ export const castDbEntryToAcmeCertificateAuthority = (
|
|||||||
hostedZoneId: dbConfigurationCol.hostedZoneId
|
hostedZoneId: dbConfigurationCol.hostedZoneId
|
||||||
},
|
},
|
||||||
directoryUrl: dbConfigurationCol.directoryUrl,
|
directoryUrl: dbConfigurationCol.directoryUrl,
|
||||||
accountEmail: dbConfigurationCol.accountEmail
|
accountEmail: dbConfigurationCol.accountEmail,
|
||||||
|
eabKid: dbConfigurationCol.eabKid,
|
||||||
|
eabHmacKey: dbConfigurationCol.eabHmacKey
|
||||||
},
|
},
|
||||||
status: ca.status as CaStatus
|
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);
|
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
|
||||||
|
|
||||||
if (!appConnection) {
|
if (!appConnection) {
|
||||||
@@ -171,7 +175,9 @@ export const AcmeCertificateAuthorityFns = ({
|
|||||||
directoryUrl,
|
directoryUrl,
|
||||||
accountEmail,
|
accountEmail,
|
||||||
dnsProvider: dnsProviderConfig.provider,
|
dnsProvider: dnsProviderConfig.provider,
|
||||||
hostedZoneId: dnsProviderConfig.hostedZoneId
|
hostedZoneId: dnsProviderConfig.hostedZoneId,
|
||||||
|
eabKid,
|
||||||
|
eabHmacKey
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
@@ -214,7 +220,7 @@ export const AcmeCertificateAuthorityFns = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => {
|
const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => {
|
||||||
if (configuration) {
|
if (configuration) {
|
||||||
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration;
|
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration;
|
||||||
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
|
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
|
||||||
|
|
||||||
if (!appConnection) {
|
if (!appConnection) {
|
||||||
@@ -254,7 +260,9 @@ export const AcmeCertificateAuthorityFns = ({
|
|||||||
directoryUrl,
|
directoryUrl,
|
||||||
accountEmail,
|
accountEmail,
|
||||||
dnsProvider: dnsProviderConfig.provider,
|
dnsProvider: dnsProviderConfig.provider,
|
||||||
hostedZoneId: dnsProviderConfig.hostedZoneId
|
hostedZoneId: dnsProviderConfig.hostedZoneId,
|
||||||
|
eabKid,
|
||||||
|
eabHmacKey
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
@@ -354,10 +362,19 @@ export const AcmeCertificateAuthorityFns = ({
|
|||||||
|
|
||||||
await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl);
|
await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl);
|
||||||
|
|
||||||
const acmeClient = new acme.Client({
|
const acmeClientOptions: acme.ClientOptions = {
|
||||||
directoryUrl: acmeCa.configuration.directoryUrl,
|
directoryUrl: acmeCa.configuration.directoryUrl,
|
||||||
accountKey
|
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);
|
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)
|
hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId)
|
||||||
}),
|
}),
|
||||||
directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl),
|
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({
|
export const AcmeCertificateAuthorityCredentialsSchema = z.object({
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
import https from "node:https";
|
||||||
|
|
||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||||
|
|
||||||
import { InfisicalImportData, VaultMappingType } from "../external-migration-types";
|
import { InfisicalImportData, VaultMappingType } from "../external-migration-types";
|
||||||
|
|
||||||
|
enum KvVersion {
|
||||||
|
V1 = "1",
|
||||||
|
V2 = "2"
|
||||||
|
}
|
||||||
|
|
||||||
type VaultData = {
|
type VaultData = {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
mount: string;
|
mount: string;
|
||||||
@@ -14,7 +23,42 @@ type VaultData = {
|
|||||||
secretData: Record<string, string>;
|
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 getMounts = async (request: AxiosInstance) => {
|
||||||
const response = await request
|
const response = await request
|
||||||
.get<{
|
.get<{
|
||||||
@@ -31,11 +75,24 @@ const vaultFactory = () => {
|
|||||||
|
|
||||||
const getPaths = async (
|
const getPaths = async (
|
||||||
request: AxiosInstance,
|
request: AxiosInstance,
|
||||||
{ mountPath, secretPath = "" }: { mountPath: string; secretPath?: string }
|
{ mountPath, secretPath = "" }: { mountPath: string; secretPath?: string },
|
||||||
|
kvVersion: KvVersion
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// For KV v2: /v1/{mount}/metadata/{path}?list=true
|
if (kvVersion === KvVersion.V2) {
|
||||||
const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
|
// 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<{
|
const response = await request.get<{
|
||||||
data: {
|
data: {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
@@ -56,21 +113,42 @@ const vaultFactory = () => {
|
|||||||
|
|
||||||
const getSecrets = async (
|
const getSecrets = async (
|
||||||
request: AxiosInstance,
|
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
|
const response = await request
|
||||||
.get<{
|
.get<{
|
||||||
data: {
|
data: Record<string, string>; // KV v1 has flat data structure
|
||||||
data: Record<string, string>; // KV v2 has nested data structure
|
lease_duration: number;
|
||||||
metadata: {
|
lease_id: string;
|
||||||
created_time: string;
|
renewable: boolean;
|
||||||
deletion_time: string;
|
}>(`/v1/${mountPath}/${secretPath}`)
|
||||||
destroyed: boolean;
|
|
||||||
version: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>(`/v1/${mountPath}/data/${secretPath}`)
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (axios.isAxiosError(err)) {
|
if (axios.isAxiosError(err)) {
|
||||||
logger.error(err.response?.data, "External migration: Failed to get Vault secret");
|
logger.error(err.response?.data, "External migration: Failed to get Vault secret");
|
||||||
@@ -78,7 +156,7 @@ const vaultFactory = () => {
|
|||||||
throw err;
|
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)
|
// 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 (
|
const recursivelyGetAllPaths = async (
|
||||||
request: AxiosInstance,
|
request: AxiosInstance,
|
||||||
mountPath: string,
|
mountPath: string,
|
||||||
|
kvVersion: KvVersion,
|
||||||
currentPath: string = ""
|
currentPath: string = ""
|
||||||
): Promise<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) {
|
if (paths === null || paths.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -105,7 +184,7 @@ const vaultFactory = () => {
|
|||||||
|
|
||||||
if (path.endsWith("/")) {
|
if (path.endsWith("/")) {
|
||||||
// it's a folder so we recurse into it
|
// 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);
|
allSecrets.push(...subSecrets);
|
||||||
} else {
|
} else {
|
||||||
// it's a secret so we add it to our results
|
// it's a secret so we add it to our results
|
||||||
@@ -119,60 +198,93 @@ const vaultFactory = () => {
|
|||||||
async function collectVaultData({
|
async function collectVaultData({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
namespace,
|
namespace,
|
||||||
accessToken
|
accessToken,
|
||||||
|
gatewayId
|
||||||
}: {
|
}: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
gatewayId?: string;
|
||||||
}): Promise<VaultData[]> {
|
}): Promise<VaultData[]> {
|
||||||
const request = axios.create({
|
const getData = async (host: string, port?: number, httpsAgent?: https.Agent) => {
|
||||||
baseURL: baseUrl,
|
const allData: VaultData[] = [];
|
||||||
headers: {
|
|
||||||
"X-Vault-Token": accessToken,
|
const request = axios.create({
|
||||||
...(namespace ? { "X-Vault-Namespace": namespace } : {})
|
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 kvVersion = mountInfo.options?.version === "2" ? KvVersion.V2 : KvVersion.V1;
|
||||||
const mounts = await getMounts(request);
|
|
||||||
|
|
||||||
for (const mount of Object.keys(mounts)) {
|
// get all paths in this mount
|
||||||
if (!mount.endsWith("/")) {
|
const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`, kvVersion);
|
||||||
delete mounts[mount];
|
|
||||||
|
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)) {
|
return data;
|
||||||
// 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 {
|
return {
|
||||||
@@ -296,17 +408,22 @@ export const transformToInfisicalFormatNamespaceToProjects = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importVaultDataFn = async ({
|
export const importVaultDataFn = async (
|
||||||
vaultAccessToken,
|
{
|
||||||
vaultNamespace,
|
vaultAccessToken,
|
||||||
vaultUrl,
|
vaultNamespace,
|
||||||
mappingType
|
vaultUrl,
|
||||||
}: {
|
mappingType,
|
||||||
vaultAccessToken: string;
|
gatewayId
|
||||||
vaultNamespace?: string;
|
}: {
|
||||||
vaultUrl: string;
|
vaultAccessToken: string;
|
||||||
mappingType: VaultMappingType;
|
vaultNamespace?: string;
|
||||||
}) => {
|
vaultUrl: string;
|
||||||
|
mappingType: VaultMappingType;
|
||||||
|
gatewayId?: string;
|
||||||
|
},
|
||||||
|
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
|
||||||
|
) => {
|
||||||
await blockLocalAndPrivateIpAddresses(vaultUrl);
|
await blockLocalAndPrivateIpAddresses(vaultUrl);
|
||||||
|
|
||||||
if (mappingType === VaultMappingType.Namespace && !vaultNamespace) {
|
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({
|
const vaultData = await vaultApi.collectVaultData({
|
||||||
accessToken: vaultAccessToken,
|
accessToken: vaultAccessToken,
|
||||||
baseUrl: vaultUrl,
|
baseUrl: vaultUrl,
|
||||||
namespace: vaultNamespace
|
namespace: vaultNamespace,
|
||||||
|
gatewayId
|
||||||
});
|
});
|
||||||
|
|
||||||
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
|
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OrgMembershipRole } from "@app/db/schemas";
|
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 { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
import { crypto } from "@app/lib/crypto/cryptography";
|
||||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||||
@@ -12,6 +13,7 @@ type TExternalMigrationServiceFactoryDep = {
|
|||||||
permissionService: TPermissionServiceFactory;
|
permissionService: TPermissionServiceFactory;
|
||||||
externalMigrationQueue: TExternalMigrationQueueFactory;
|
externalMigrationQueue: TExternalMigrationQueueFactory;
|
||||||
userDAL: Pick<TUserDALFactory, "findById">;
|
userDAL: Pick<TUserDALFactory, "findById">;
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
||||||
@@ -19,7 +21,8 @@ export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrati
|
|||||||
export const externalMigrationServiceFactory = ({
|
export const externalMigrationServiceFactory = ({
|
||||||
permissionService,
|
permissionService,
|
||||||
externalMigrationQueue,
|
externalMigrationQueue,
|
||||||
userDAL
|
userDAL,
|
||||||
|
gatewayService
|
||||||
}: TExternalMigrationServiceFactoryDep) => {
|
}: TExternalMigrationServiceFactoryDep) => {
|
||||||
const importEnvKeyData = async ({
|
const importEnvKeyData = async ({
|
||||||
decryptionKey,
|
decryptionKey,
|
||||||
@@ -72,6 +75,7 @@ export const externalMigrationServiceFactory = ({
|
|||||||
vaultNamespace,
|
vaultNamespace,
|
||||||
mappingType,
|
mappingType,
|
||||||
vaultUrl,
|
vaultUrl,
|
||||||
|
gatewayId,
|
||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
@@ -91,12 +95,18 @@ export const externalMigrationServiceFactory = ({
|
|||||||
|
|
||||||
const user = await userDAL.findById(actorId);
|
const user = await userDAL.findById(actorId);
|
||||||
|
|
||||||
const vaultData = await importVaultDataFn({
|
const vaultData = await importVaultDataFn(
|
||||||
vaultAccessToken,
|
{
|
||||||
vaultNamespace,
|
vaultAccessToken,
|
||||||
vaultUrl,
|
vaultNamespace,
|
||||||
mappingType
|
vaultUrl,
|
||||||
});
|
mappingType,
|
||||||
|
gatewayId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gatewayService
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const stringifiedJson = JSON.stringify({
|
const stringifiedJson = JSON.stringify({
|
||||||
data: vaultData,
|
data: vaultData,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type TImportVaultDataDTO = {
|
|||||||
vaultNamespace?: string;
|
vaultNamespace?: string;
|
||||||
mappingType: VaultMappingType;
|
mappingType: VaultMappingType;
|
||||||
vaultUrl: string;
|
vaultUrl: string;
|
||||||
|
gatewayId?: string;
|
||||||
} & Omit<TOrgPermission, "orgId">;
|
} & Omit<TOrgPermission, "orgId">;
|
||||||
|
|
||||||
export type TImportInfisicalDataCreate = {
|
export type TImportInfisicalDataCreate = {
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ import {
|
|||||||
validatePrivilegeChangeOperation
|
validatePrivilegeChangeOperation
|
||||||
} from "@app/ee/services/permission/permission-fns";
|
} from "@app/ee/services/permission/permission-fns";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
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 { getConfig } from "@app/lib/config/env";
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
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 { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
|
||||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||||
@@ -40,7 +47,10 @@ type TIdentityUaServiceFactoryDep = {
|
|||||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
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>;
|
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 LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
|
||||||
|
|
||||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
|
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
|
||||||
if (!identityMembershipOrg) {
|
try {
|
||||||
throw new UnauthorizedError({
|
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, {
|
||||||
message: "Invalid credentials"
|
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) => {
|
try {
|
||||||
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.IdentityLogin(identityUa.identityId, clientId)]);
|
|
||||||
|
|
||||||
// Lockout Check
|
|
||||||
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
|
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
|
||||||
|
|
||||||
let lockout: LockoutObject | undefined;
|
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 clientSecretPrefix = clientSecret.slice(0, 4);
|
||||||
const clientSecretInfo = await identityUaClientSecretDAL.find({
|
const clientSecretInfo = await identityUaClientSecretDAL.find({
|
||||||
identityUAId: identityUa.id,
|
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
|
// number of times client secret can be used for
|
||||||
// a login operation reached
|
// a login operation reached
|
||||||
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
||||||
@@ -183,57 +204,61 @@ export const identityUaServiceFactory = ({
|
|||||||
accessTokenMaxTTL: 1000000000
|
accessTokenMaxTTL: 1000000000
|
||||||
};
|
};
|
||||||
|
|
||||||
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx);
|
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
|
||||||
await identityOrgMembershipDAL.updateById(
|
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
|
||||||
identityMembershipOrg.id,
|
await identityOrgMembershipDAL.updateById(
|
||||||
{
|
identityMembershipOrg.id,
|
||||||
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
|
{
|
||||||
lastLoginTime: new Date()
|
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
|
||||||
},
|
lastLoginTime: new Date()
|
||||||
tx
|
},
|
||||||
);
|
tx
|
||||||
const newToken = await identityAccessTokenDAL.create(
|
);
|
||||||
|
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,
|
identityId: identityUa.identityId,
|
||||||
isAccessTokenRevoked: false,
|
clientSecretId: validClientSecretInfo.id,
|
||||||
identityUAClientSecretId: uaClientSecretDoc.id,
|
identityAccessTokenId: identityAccessToken.id,
|
||||||
accessTokenNumUses: 0,
|
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
||||||
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
|
} as TIdentityAccessTokenJwtPayload,
|
||||||
accessTokenPeriod: identityUa.accessTokenPeriod,
|
appCfg.AUTH_SECRET,
|
||||||
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
|
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
|
||||||
...accessTokenTTLParams
|
Number(identityAccessToken.accessTokenTTL) === 0
|
||||||
},
|
? undefined
|
||||||
tx
|
: {
|
||||||
|
expiresIn: Number(identityAccessToken.accessTokenTTL)
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return { newToken, validClientSecretInfo, accessTokenTTLParams };
|
return {
|
||||||
});
|
accessToken,
|
||||||
|
identityUa,
|
||||||
const appCfg = getConfig();
|
validClientSecretInfo,
|
||||||
const accessToken = crypto.jwt().sign(
|
identityAccessToken,
|
||||||
{
|
identityMembershipOrg,
|
||||||
identityId: identityUa.identityId,
|
...accessTokenTTLParams
|
||||||
clientSecretId: identityTx.validClientSecretInfo.id,
|
};
|
||||||
identityAccessTokenId: identityTx.newToken.id,
|
} finally {
|
||||||
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
await lock.release();
|
||||||
} 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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachUniversalAuth = async ({
|
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 {
|
return {
|
||||||
...orgMembershipOrm,
|
...orgMembershipOrm,
|
||||||
findOrgMembershipById,
|
findOrgMembershipById,
|
||||||
findRecentInvitedMemberships,
|
findRecentInvitedMemberships,
|
||||||
updateLastInvitedAtByIds
|
updateLastInvitedAtByIds,
|
||||||
|
findOrgMembershipsWithUsersByOrgId
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
TDeleteFolderDTO,
|
TDeleteFolderDTO,
|
||||||
TDeleteManyFoldersDTO,
|
TDeleteManyFoldersDTO,
|
||||||
TGetFolderByIdDTO,
|
TGetFolderByIdDTO,
|
||||||
|
TGetFolderByPathDTO,
|
||||||
TGetFolderDTO,
|
TGetFolderDTO,
|
||||||
TGetFoldersDeepByEnvsDTO,
|
TGetFoldersDeepByEnvsDTO,
|
||||||
TUpdateFolderDTO,
|
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 {
|
return {
|
||||||
createFolder,
|
createFolder,
|
||||||
updateFolder,
|
updateFolder,
|
||||||
@@ -1405,6 +1431,7 @@ export const secretFolderServiceFactory = ({
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
getFolders,
|
getFolders,
|
||||||
getFolderById,
|
getFolderById,
|
||||||
|
getFolderByPath,
|
||||||
getProjectFolderCount,
|
getProjectFolderCount,
|
||||||
getFoldersMultiEnv,
|
getFoldersMultiEnv,
|
||||||
getFoldersDeepByEnvs,
|
getFoldersDeepByEnvs,
|
||||||
|
|||||||
@@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = {
|
|||||||
idOrName: string;
|
idOrName: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TGetFolderByPathDTO = {
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { isAxiosError } from "axios";
|
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 { removeTrailingSlash } from "@app/lib/fn";
|
||||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
import {
|
||||||
import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault";
|
getHCVaultAccessToken,
|
||||||
|
getHCVaultInstanceUrl,
|
||||||
|
requestWithHCVaultGateway,
|
||||||
|
THCVaultConnection
|
||||||
|
} from "@app/services/app-connection/hc-vault";
|
||||||
import {
|
import {
|
||||||
THCVaultListVariables,
|
THCVaultListVariables,
|
||||||
THCVaultListVariablesResponse,
|
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 { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => {
|
const listHCVaultVariables = async (
|
||||||
await blockLocalAndPrivateIpAddresses(instanceUrl);
|
{ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables,
|
||||||
|
connection: THCVaultConnection,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await request.get<THCVaultListVariablesResponse>(
|
const { data } = await requestWithHCVaultGateway<THCVaultListVariablesResponse>(connection, gatewayService, {
|
||||||
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
||||||
{
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Vault-Token": accessToken,
|
"X-Vault-Token": accessToken,
|
||||||
...(namespace ? { "X-Vault-Namespace": namespace } : {})
|
...(namespace ? { "X-Vault-Namespace": namespace } : {})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
return data.data.data;
|
return data.data.data;
|
||||||
} catch (error: unknown) {
|
} 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
|
// Hashicorp Vault updates all variables in one batch. This is to respect their versioning
|
||||||
const updateHCVaultVariables = async ({
|
const updateHCVaultVariables = async (
|
||||||
path,
|
{ path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable,
|
||||||
instanceUrl,
|
connection: THCVaultConnection,
|
||||||
namespace,
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
accessToken,
|
) => {
|
||||||
mount,
|
return requestWithHCVaultGateway(connection, gatewayService, {
|
||||||
data
|
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
||||||
}: TPostHCVaultVariable) => {
|
method: "POST",
|
||||||
await blockLocalAndPrivateIpAddresses(instanceUrl);
|
headers: {
|
||||||
|
"X-Vault-Token": accessToken,
|
||||||
return request.post(
|
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
|
||||||
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
"Content-Type": "application/json"
|
||||||
{
|
|
||||||
data
|
|
||||||
},
|
},
|
||||||
{
|
data: { data }
|
||||||
headers: {
|
});
|
||||||
"X-Vault-Token": accessToken,
|
|
||||||
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HCVaultSyncFns = {
|
export const HCVaultSyncFns = {
|
||||||
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
|
syncSecrets: async (
|
||||||
|
secretSync: THCVaultSyncWithCredentials,
|
||||||
|
secretMap: TSecretMap,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
const {
|
const {
|
||||||
connection,
|
connection,
|
||||||
environment,
|
environment,
|
||||||
@@ -74,16 +75,20 @@ export const HCVaultSyncFns = {
|
|||||||
} = secretSync;
|
} = secretSync;
|
||||||
|
|
||||||
const { namespace } = connection.credentials;
|
const { namespace } = connection.credentials;
|
||||||
const accessToken = await getHCVaultAccessToken(connection);
|
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||||
|
|
||||||
const variables = await listHCVaultVariables({
|
const variables = await listHCVaultVariables(
|
||||||
instanceUrl,
|
{
|
||||||
accessToken,
|
instanceUrl,
|
||||||
namespace,
|
accessToken,
|
||||||
mount,
|
namespace,
|
||||||
path
|
mount,
|
||||||
});
|
path
|
||||||
|
},
|
||||||
|
connection,
|
||||||
|
gatewayService
|
||||||
|
);
|
||||||
let tainted = false;
|
let tainted = false;
|
||||||
|
|
||||||
for (const entry of Object.entries(secretMap)) {
|
for (const entry of Object.entries(secretMap)) {
|
||||||
@@ -110,24 +115,36 @@ export const HCVaultSyncFns = {
|
|||||||
if (!tainted) return;
|
if (!tainted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
|
await updateHCVaultVariables(
|
||||||
|
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
|
||||||
|
connection,
|
||||||
|
gatewayService
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new SecretSyncError({
|
throw new SecretSyncError({
|
||||||
error
|
error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
|
removeSecrets: async (
|
||||||
|
secretSync: THCVaultSyncWithCredentials,
|
||||||
|
secretMap: TSecretMap,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
const {
|
const {
|
||||||
connection,
|
connection,
|
||||||
destinationConfig: { mount, path }
|
destinationConfig: { mount, path }
|
||||||
} = secretSync;
|
} = secretSync;
|
||||||
|
|
||||||
const { namespace } = connection.credentials;
|
const { namespace } = connection.credentials;
|
||||||
const accessToken = await getHCVaultAccessToken(connection);
|
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
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)) {
|
for await (const [key] of Object.entries(variables)) {
|
||||||
if (key in secretMap) {
|
if (key in secretMap) {
|
||||||
@@ -136,30 +153,41 @@ export const HCVaultSyncFns = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
|
await updateHCVaultVariables(
|
||||||
|
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
|
||||||
|
connection,
|
||||||
|
gatewayService
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new SecretSyncError({
|
throw new SecretSyncError({
|
||||||
error
|
error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getSecrets: async (secretSync: THCVaultSyncWithCredentials) => {
|
getSecrets: async (
|
||||||
|
secretSync: THCVaultSyncWithCredentials,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
const {
|
const {
|
||||||
connection,
|
connection,
|
||||||
destinationConfig: { mount, path }
|
destinationConfig: { mount, path }
|
||||||
} = secretSync;
|
} = secretSync;
|
||||||
|
|
||||||
const { namespace } = connection.credentials;
|
const { namespace } = connection.credentials;
|
||||||
const accessToken = await getHCVaultAccessToken(connection);
|
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||||
|
|
||||||
const variables = await listHCVaultVariables({
|
const variables = await listHCVaultVariables(
|
||||||
instanceUrl,
|
{
|
||||||
namespace,
|
instanceUrl,
|
||||||
accessToken,
|
namespace,
|
||||||
mount,
|
accessToken,
|
||||||
path
|
mount,
|
||||||
});
|
path
|
||||||
|
},
|
||||||
|
connection,
|
||||||
|
gatewayService
|
||||||
|
);
|
||||||
|
|
||||||
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }]));
|
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export const SecretSyncFns = {
|
|||||||
case SecretSync.Windmill:
|
case SecretSync.Windmill:
|
||||||
return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap);
|
return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||||
case SecretSync.HCVault:
|
case SecretSync.HCVault:
|
||||||
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap);
|
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService);
|
||||||
case SecretSync.TeamCity:
|
case SecretSync.TeamCity:
|
||||||
return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap);
|
return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||||
case SecretSync.OCIVault:
|
case SecretSync.OCIVault:
|
||||||
@@ -283,7 +283,7 @@ export const SecretSyncFns = {
|
|||||||
},
|
},
|
||||||
getSecrets: async (
|
getSecrets: async (
|
||||||
secretSync: TSecretSyncWithCredentials,
|
secretSync: TSecretSyncWithCredentials,
|
||||||
{ kmsService, appConnectionDAL }: TSyncSecretDeps
|
{ kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps
|
||||||
): Promise<TSecretMap> => {
|
): Promise<TSecretMap> => {
|
||||||
let secretMap: TSecretMap;
|
let secretMap: TSecretMap;
|
||||||
switch (secretSync.destination) {
|
switch (secretSync.destination) {
|
||||||
@@ -341,7 +341,7 @@ export const SecretSyncFns = {
|
|||||||
secretMap = await WindmillSyncFns.getSecrets(secretSync);
|
secretMap = await WindmillSyncFns.getSecrets(secretSync);
|
||||||
break;
|
break;
|
||||||
case SecretSync.HCVault:
|
case SecretSync.HCVault:
|
||||||
secretMap = await HCVaultSyncFns.getSecrets(secretSync);
|
secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService);
|
||||||
break;
|
break;
|
||||||
case SecretSync.TeamCity:
|
case SecretSync.TeamCity:
|
||||||
secretMap = await TeamCitySyncFns.getSecrets(secretSync);
|
secretMap = await TeamCitySyncFns.getSecrets(secretSync);
|
||||||
@@ -451,7 +451,7 @@ export const SecretSyncFns = {
|
|||||||
case SecretSync.Windmill:
|
case SecretSync.Windmill:
|
||||||
return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||||
case SecretSync.HCVault:
|
case SecretSync.HCVault:
|
||||||
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService);
|
||||||
case SecretSync.TeamCity:
|
case SecretSync.TeamCity:
|
||||||
return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap);
|
return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||||
case SecretSync.OCIVault:
|
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:
|
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 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}>
|
<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.
|
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
|
### 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.
|
- 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.
|
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)
|
||||||
|
|
||||||
|

|
||||||
|
</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)
|
||||||
|
|
||||||
|

|
||||||
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The sync operation will process all organization members who have previously logged in with GitHub and update their team memberships accordingly.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
<Accordion title="Please check if your organization has approved the Infisical OAuth application.">
|
<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).
|
- **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.
|
- **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.
|
- **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.
|
Finally, press **Create** to register the ACME CA with Infisical.
|
||||||
</Step>
|
</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.
|
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>
|
</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
|
## FAQ
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 643 KiB After Width: | Height: | Size: 542 KiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
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">
|
<Tab title="App Role">
|
||||||
- **Name**: The name of the connection being created. Must be slug-friendly.
|
- **Name**: The name of the connection being created. Must be slug-friendly.
|
||||||
- **Description**: An optional description to provide details about this connection.
|
- **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.
|
- **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.
|
- **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.
|
- **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">
|
<Tab title="Access Token">
|
||||||
- **Name**: The name of the connection being created. Must be slug-friendly.
|
- **Name**: The name of the connection being created. Must be slug-friendly.
|
||||||
- **Description**: An optional description to provide details about this connection.
|
- **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.
|
- **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.
|
- **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.
|
- **Access Token**: The Access Token generated in the steps above.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 18 KiB |
@@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
|
|||||||
reset();
|
reset();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
navigate({
|
navigate({
|
||||||
to: getProjectHomePage(project.type),
|
to: getProjectHomePage(project.type, project.environments),
|
||||||
params: { projectId: project.id }
|
params: { projectId: project.id }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
icon?: IconDefinition;
|
icon?: IconDefinition;
|
||||||
iconSize?: SizeProp;
|
iconSize?: SizeProp;
|
||||||
|
titleClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmptyState = ({
|
export const EmptyState = ({
|
||||||
@@ -17,7 +18,8 @@ export const EmptyState = ({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
icon = faCubesStacked,
|
icon = faCubesStacked,
|
||||||
iconSize = "2x"
|
iconSize = "2x",
|
||||||
|
titleClassName
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
@@ -27,7 +29,7 @@ export const EmptyState = ({
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={icon} size={iconSize} />
|
<FontAwesomeIcon icon={icon} size={iconSize} />
|
||||||
<div className="flex flex-col items-center py-4">
|
<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>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const FilterableSelect = <T,>({
|
|||||||
}),
|
}),
|
||||||
menuPortal: (provided) => ({
|
menuPortal: (provided) => ({
|
||||||
...provided,
|
...provided,
|
||||||
zIndex: 9999
|
zIndex: 99999
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
tabSelectsValue={tabSelectsValue}
|
tabSelectsValue={tabSelectsValue}
|
||||||
|
|||||||
@@ -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 { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { Skeleton } from "../Skeleton";
|
import { Skeleton } from "../Skeleton";
|
||||||
@@ -9,22 +9,20 @@ export type TableContainerProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||||
|
|
||||||
export const TableContainer = ({
|
export const TableContainer = forwardRef<HTMLDivElement, TableContainerProps>(
|
||||||
children,
|
({ children, className, isRounded = true, ...props }, ref): JSX.Element => (
|
||||||
className,
|
<div
|
||||||
isRounded = true,
|
ref={ref}
|
||||||
...props
|
className={twMerge(
|
||||||
}: TableContainerProps): JSX.Element => (
|
"relative w-full overflow-x-auto border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter",
|
||||||
<div
|
isRounded && "rounded-lg",
|
||||||
className={twMerge(
|
className
|
||||||
"relative w-full overflow-x-auto border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter",
|
)}
|
||||||
isRounded && "rounded-lg",
|
{...props}
|
||||||
className
|
>
|
||||||
)}
|
{children}
|
||||||
{...props}
|
</div>
|
||||||
>
|
)
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// main parent table
|
// main parent table
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { useOrgPermission } from "./OrgPermissionContext";
|
|||||||
export type { TOrgPermission } from "./types";
|
export type { TOrgPermission } from "./types";
|
||||||
export {
|
export {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
|
OrgPermissionAuditLogsActions,
|
||||||
OrgPermissionBillingActions,
|
OrgPermissionBillingActions,
|
||||||
OrgPermissionGroupActions,
|
OrgPermissionGroupActions,
|
||||||
OrgPermissionIdentityActions,
|
OrgPermissionIdentityActions,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export enum OrgPermissionSubjects {
|
|||||||
Gateway = "gateway",
|
Gateway = "gateway",
|
||||||
SecretShare = "secret-share",
|
SecretShare = "secret-share",
|
||||||
GithubOrgSync = "github-org-sync",
|
GithubOrgSync = "github-org-sync",
|
||||||
|
GithubOrgSyncManual = "github-org-sync-manual",
|
||||||
MachineIdentityAuthTemplate = "machine-identity-auth-template"
|
MachineIdentityAuthTemplate = "machine-identity-auth-template"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,10 @@ export enum OrgPermissionAppConnectionActions {
|
|||||||
Connect = "connect"
|
Connect = "connect"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum OrgPermissionAuditLogsActions {
|
||||||
|
Read = "read"
|
||||||
|
}
|
||||||
|
|
||||||
export enum OrgPermissionKmipActions {
|
export enum OrgPermissionKmipActions {
|
||||||
Proxy = "proxy",
|
Proxy = "proxy",
|
||||||
Setup = "setup"
|
Setup = "setup"
|
||||||
@@ -111,6 +116,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync]
|
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
||||||
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
| [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
|
||||||
@@ -118,7 +124,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
|
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
| [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
|
||||||
| [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections]
|
| [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections]
|
||||||
| [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
|
| [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext";
|
|||||||
export type { ProjectPermissionSet, TProjectPermission } from "./types";
|
export type { ProjectPermissionSet, TProjectPermission } from "./types";
|
||||||
export {
|
export {
|
||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionAuditLogsActions,
|
||||||
ProjectPermissionCertificateActions,
|
ProjectPermissionCertificateActions,
|
||||||
ProjectPermissionCmekActions,
|
ProjectPermissionCmekActions,
|
||||||
ProjectPermissionDynamicSecretActions,
|
ProjectPermissionDynamicSecretActions,
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ export enum ProjectPermissionSecretEventActions {
|
|||||||
SubscribeImportMutations = "subscribe-on-import-mutations"
|
SubscribeImportMutations = "subscribe-on-import-mutations"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectPermissionAuditLogsActions {
|
||||||
|
Read = "read"
|
||||||
|
}
|
||||||
|
|
||||||
export enum PermissionConditionOperators {
|
export enum PermissionConditionOperators {
|
||||||
$IN = "$in",
|
$IN = "$in",
|
||||||
$ALL = "$all",
|
$ALL = "$all",
|
||||||
@@ -365,7 +369,7 @@ export type ProjectPermissionSet =
|
|||||||
| [ProjectPermissionActions, ProjectPermissionSub.Groups]
|
| [ProjectPermissionActions, ProjectPermissionSub.Groups]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
|
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
|
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.AuditLogs]
|
| [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
|
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
||||||
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext";
|
|||||||
export type { TOrgPermission } from "./OrgPermissionContext";
|
export type { TOrgPermission } from "./OrgPermissionContext";
|
||||||
export {
|
export {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
|
OrgPermissionAuditLogsActions,
|
||||||
OrgPermissionBillingActions,
|
OrgPermissionBillingActions,
|
||||||
OrgPermissionGroupActions,
|
OrgPermissionGroupActions,
|
||||||
OrgPermissionIdentityActions,
|
OrgPermissionIdentityActions,
|
||||||
@@ -11,6 +12,7 @@ export {
|
|||||||
export type { TProjectPermission } from "./ProjectPermissionContext";
|
export type { TProjectPermission } from "./ProjectPermissionContext";
|
||||||
export {
|
export {
|
||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionAuditLogsActions,
|
||||||
ProjectPermissionCertificateActions,
|
ProjectPermissionCertificateActions,
|
||||||
ProjectPermissionCmekActions,
|
ProjectPermissionCmekActions,
|
||||||
ProjectPermissionDynamicSecretActions,
|
ProjectPermissionDynamicSecretActions,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
import { createWorkspace } from "@app/hooks/api/workspace/queries";
|
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 = [
|
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) {
|
switch (type) {
|
||||||
case ProjectType.SecretManager:
|
case ProjectType.SecretManager:
|
||||||
return "/projects/secret-management/$projectId/overview";
|
return "/projects/secret-management/$projectId/overview" as const;
|
||||||
case ProjectType.CertificateManager:
|
case ProjectType.CertificateManager:
|
||||||
return "/projects/cert-management/$projectId/subscribers";
|
return "/projects/cert-management/$projectId/subscribers" as const;
|
||||||
case ProjectType.SecretScanning:
|
case ProjectType.SecretScanning:
|
||||||
return `/projects/${type}/$projectId/data-sources` as const;
|
return `/projects/${type}/$projectId/data-sources` as const;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type TAcmeCertificateAuthority = {
|
|||||||
};
|
};
|
||||||
directoryUrl: string;
|
directoryUrl: string;
|
||||||
accountEmail: string;
|
accountEmail: string;
|
||||||
|
eabKid?: string;
|
||||||
|
eabHmacKey?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
import {
|
import {
|
||||||
@@ -273,6 +274,12 @@ export const useGetProjectSecretsDetails = (
|
|||||||
...options,
|
...options,
|
||||||
// wait for all values to be available
|
// wait for all values to be available
|
||||||
enabled: Boolean(projectId) && (options?.enabled ?? true),
|
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({
|
queryKey: dashboardKeys.getProjectSecretsDetails({
|
||||||
secretPath,
|
secretPath,
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit<
|
|||||||
DashboardProjectSecretsOverviewResponse,
|
DashboardProjectSecretsOverviewResponse,
|
||||||
"secrets" | "secretRotations"
|
"secrets" | "secretRotations"
|
||||||
> & {
|
> & {
|
||||||
secrets?: SecretV3RawSanitized[];
|
secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[];
|
||||||
secretRotations?: (TSecretRotationV2 & {
|
secretRotations?: (TSecretRotationV2 & {
|
||||||
secrets: (SecretV3RawSanitized | null)[];
|
secrets: (SecretV3RawSanitized | null)[];
|
||||||
})[];
|
})[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
useCreateGithubSyncOrgConfig,
|
useCreateGithubSyncOrgConfig,
|
||||||
useDeleteGithubSyncOrgConfig,
|
useDeleteGithubSyncOrgConfig,
|
||||||
|
useSyncAllGithubTeams,
|
||||||
useUpdateGithubSyncOrgConfig
|
useUpdateGithubSyncOrgConfig
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
export { githubOrgSyncConfigQueryKeys } from "./queries";
|
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,
|
vaultAccessToken,
|
||||||
vaultNamespace,
|
vaultNamespace,
|
||||||
vaultUrl,
|
vaultUrl,
|
||||||
mappingType
|
mappingType,
|
||||||
|
gatewayId
|
||||||
}: {
|
}: {
|
||||||
vaultAccessToken: string;
|
vaultAccessToken: string;
|
||||||
vaultNamespace?: string;
|
vaultNamespace?: string;
|
||||||
vaultUrl: string;
|
vaultUrl: string;
|
||||||
mappingType: string;
|
mappingType: string;
|
||||||
|
gatewayId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await apiRequest.post("/api/v3/external-migration/vault/", {
|
await apiRequest.post("/api/v3/external-migration/vault/", {
|
||||||
vaultAccessToken,
|
vaultAccessToken,
|
||||||
vaultNamespace,
|
vaultNamespace,
|
||||||
vaultUrl,
|
vaultUrl,
|
||||||
mappingType
|
mappingType,
|
||||||
|
gatewayId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ import {
|
|||||||
UpdateEnvironmentDTO,
|
UpdateEnvironmentDTO,
|
||||||
UpdatePitVersionLimitDTO,
|
UpdatePitVersionLimitDTO,
|
||||||
UpdateProjectDTO,
|
UpdateProjectDTO,
|
||||||
Workspace
|
Workspace,
|
||||||
|
WorkspaceEnv
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const fetchWorkspaceById = async (workspaceId: string) => {
|
export const fetchWorkspaceById = async (workspaceId: string) => {
|
||||||
@@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => {
|
|||||||
export const useCreateWsEnvironment = () => {
|
export const useCreateWsEnvironment = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<object, object, CreateEnvironmentDTO>({
|
return useMutation<WorkspaceEnv, WorkspaceEnv, CreateEnvironmentDTO>({
|
||||||
mutationFn: ({ workspaceId, name, slug }) => {
|
mutationFn: async ({ workspaceId, name, slug }) => {
|
||||||
return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, {
|
const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>(
|
||||||
name,
|
`/api/v1/workspace/${workspaceId}/environments`,
|
||||||
slug
|
{
|
||||||
});
|
name,
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.environment;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { MouseEvent, RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
minWidth: number;
|
minWidth: number;
|
||||||
maxWidth: number;
|
maxWidth: number;
|
||||||
initialWidth: 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 [colWidth, setColWidth] = useState(initialWidth);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
@@ -63,6 +64,28 @@ export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Param
|
|||||||
};
|
};
|
||||||
}, [isResizing, handleMouseMove, handleMouseUp]);
|
}, [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 {
|
return {
|
||||||
colWidth,
|
colWidth,
|
||||||
handleMouseDown,
|
handleMouseDown,
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr
|
|||||||
|
|
||||||
const getEnvSecretKeyCount = useCallback(
|
const getEnvSecretKeyCount = useCallback(
|
||||||
(env: string) => {
|
(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]
|
[secrets]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const url = getProjectHomePage(currentWorkspace.type);
|
const url = getProjectHomePage(
|
||||||
|
currentWorkspace.type,
|
||||||
|
currentWorkspace.environments
|
||||||
|
);
|
||||||
window.location.href = url.replace("$projectId", currentWorkspace.id);
|
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">
|
<div className="-mr-2 flex w-full items-center gap-1">
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<Link
|
<Link
|
||||||
to={getProjectHomePage(currentWorkspace.type)}
|
to={getProjectHomePage(currentWorkspace.type, currentWorkspace.environments)}
|
||||||
params={{
|
params={{
|
||||||
projectId: currentWorkspace.id
|
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
|
// to reproduce change this back to router.push and switch between two projects with different env count
|
||||||
// look into this on dashboard revamp
|
// look into this on dashboard revamp
|
||||||
const url = linkOptions({
|
const url = linkOptions({
|
||||||
to: getProjectHomePage(workspace.type),
|
to: getProjectHomePage(workspace.type, workspace.environments),
|
||||||
params: {
|
params: {
|
||||||
projectId: workspace.id
|
projectId: workspace.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
faVault
|
faVault
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { motion } from "framer-motion";
|
||||||
|
|
||||||
import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
|
||||||
@@ -31,6 +31,7 @@ export const SecretManagerLayout = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
const projectSlug = currentWorkspace?.slug || "";
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
|
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
|
||||||
workspaceId
|
workspaceId
|
||||||
@@ -73,11 +74,21 @@ export const SecretManagerLayout = () => {
|
|||||||
<Link
|
<Link
|
||||||
to="/projects/secret-management/$projectId/overview"
|
to="/projects/secret-management/$projectId/overview"
|
||||||
params={{
|
params={{
|
||||||
projectId: currentWorkspace.id
|
projectId: currentWorkspace.id,
|
||||||
|
...(currentWorkspace.environments.length
|
||||||
|
? { envSlug: currentWorkspace.environments[0]?.slug }
|
||||||
|
: {})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ 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="mx-1 flex gap-2">
|
||||||
<div className="w-6">
|
<div className="w-6">
|
||||||
<FontAwesomeIcon icon={faVault} />
|
<FontAwesomeIcon icon={faVault} />
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ import {
|
|||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
import { slugSchema } from "@app/lib/schemas";
|
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({
|
const baseSchema = z.object({
|
||||||
type: z.nativeEnum(CaType),
|
type: z.nativeEnum(CaType),
|
||||||
name: slugSchema({
|
name: slugSchema({
|
||||||
@@ -51,18 +62,39 @@ const baseSchema = z.object({
|
|||||||
status: z.nativeEnum(CaStatus)
|
status: z.nativeEnum(CaStatus)
|
||||||
});
|
});
|
||||||
|
|
||||||
const acmeConfigurationSchema = z.object({
|
const acmeConfigurationSchema = z
|
||||||
dnsAppConnection: z.object({
|
.object({
|
||||||
id: z.string(),
|
dnsAppConnection: z.object({
|
||||||
name: z.string()
|
id: z.string(),
|
||||||
}),
|
name: z.string()
|
||||||
dnsProviderConfig: z.object({
|
}),
|
||||||
provider: z.nativeEnum(AcmeDnsProvider),
|
dnsProviderConfig: z.object({
|
||||||
hostedZoneId: z.string()
|
provider: z.nativeEnum(AcmeDnsProvider),
|
||||||
}),
|
hostedZoneId: z.string()
|
||||||
directoryUrl: z.string(),
|
}),
|
||||||
accountEmail: 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({
|
const azureAdCsConfigurationSchema = z.object({
|
||||||
azureAdcsConnection: z.object({
|
azureAdcsConnection: z.object({
|
||||||
@@ -122,6 +154,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration
|
caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration
|
||||||
? configuration.dnsProviderConfig.provider
|
? configuration.dnsProviderConfig.provider
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const directoryUrl =
|
||||||
|
caType === CaType.ACME && configuration && "directoryUrl" in configuration
|
||||||
|
? configuration.directoryUrl
|
||||||
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialType = (popUp?.ca?.data as { type: CaType })?.type;
|
const initialType = (popUp?.ca?.data as { type: CaType })?.type;
|
||||||
@@ -155,7 +191,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
hostedZoneId: ""
|
hostedZoneId: ""
|
||||||
},
|
},
|
||||||
directoryUrl: "",
|
directoryUrl: "",
|
||||||
accountEmail: ""
|
accountEmail: "",
|
||||||
|
eabKid: "",
|
||||||
|
eabHmacKey: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -178,13 +216,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const availableConnections: TAvailableAppConnection[] = useMemo(() => {
|
const availableConnections: TAvailableAppConnection[] = useMemo(() => {
|
||||||
if (caType === CaType.ACME) {
|
|
||||||
return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])];
|
|
||||||
}
|
|
||||||
if (caType === CaType.AZURE_AD_CS) {
|
if (caType === CaType.AZURE_AD_CS) {
|
||||||
return availableAzureConnections || [];
|
return availableAzureConnections || [];
|
||||||
}
|
}
|
||||||
return [];
|
return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])];
|
||||||
}, [
|
}, [
|
||||||
caType,
|
caType,
|
||||||
availableRoute53Connections,
|
availableRoute53Connections,
|
||||||
@@ -192,7 +227,8 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
availableAzureConnections
|
availableAzureConnections
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isPending = isRoute53Pending || isCloudflarePending || isAzurePending;
|
const isPending =
|
||||||
|
isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS);
|
||||||
|
|
||||||
const dnsAppConnection =
|
const dnsAppConnection =
|
||||||
caType === CaType.ACME && configuration && "dnsAppConnection" in configuration
|
caType === CaType.ACME && configuration && "dnsAppConnection" in configuration
|
||||||
@@ -227,7 +263,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId
|
hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId
|
||||||
},
|
},
|
||||||
directoryUrl: ca.configuration.directoryUrl,
|
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) {
|
} else if (ca.type === CaType.AZURE_AD_CS && availableConnections?.length) {
|
||||||
@@ -268,7 +306,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
dnsProviderConfig: formConfiguration.dnsProviderConfig,
|
dnsProviderConfig: formConfiguration.dnsProviderConfig,
|
||||||
directoryUrl: formConfiguration.directoryUrl,
|
directoryUrl: formConfiguration.directoryUrl,
|
||||||
accountEmail: formConfiguration.accountEmail,
|
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) {
|
} else if (type === CaType.AZURE_AD_CS && "azureAdcsConnection" in formConfiguration) {
|
||||||
configPayload = {
|
configPayload = {
|
||||||
@@ -499,6 +539,44 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
</FormControl>
|
</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 && (
|
{caType === CaType.AZURE_AD_CS && (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -9,9 +11,16 @@ import {
|
|||||||
ModalClose,
|
ModalClose,
|
||||||
SecretInput,
|
SecretInput,
|
||||||
Select,
|
Select,
|
||||||
SelectItem
|
SelectItem,
|
||||||
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} 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 { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||||
|
import { gatewaysQueryKeys } from "@app/hooks/api";
|
||||||
import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections";
|
import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections";
|
||||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||||
|
|
||||||
@@ -66,7 +75,8 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
|||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: appConnection ?? {
|
defaultValues: appConnection ?? {
|
||||||
app: AppConnection.HCVault,
|
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 selectedMethod = watch("method");
|
||||||
|
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
@@ -115,6 +128,54 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
|||||||
</FormControl>
|
</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
|
<Controller
|
||||||
name="credentials.instanceUrl"
|
name="credentials.instanceUrl"
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import ms from "ms";
|
import ms from "ms";
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
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 { 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 { Workspace } from "@app/hooks/api/workspace/types";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
@@ -28,153 +34,173 @@ type Props = {
|
|||||||
project?: Workspace;
|
project?: Workspace;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogsSection = withPermission(
|
const LogsSectionComponent = ({
|
||||||
({ presets, refetchInterval, showFilters = true, pageView = false, project }: Props) => {
|
presets,
|
||||||
const { subscription } = useSubscription();
|
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 [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>(
|
||||||
const [logFilter, setLogFilter] = useState<TAuditLogFilterFormData>({
|
presets?.endDate || presets?.startDate
|
||||||
eventType: presets?.eventType || [],
|
? {
|
||||||
actor: presets?.actorId,
|
type: AuditLogDateFilterType.Absolute,
|
||||||
eventMetadata: presets?.eventMetadata
|
startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")),
|
||||||
});
|
endDate: presets?.endDate || new Date()
|
||||||
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
|
}
|
||||||
|
: {
|
||||||
|
startDate: new Date(Number(new Date()) - ms("1h")),
|
||||||
|
endDate: new Date(),
|
||||||
|
type: AuditLogDateFilterType.Relative,
|
||||||
|
relativeModeValue: "1h"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>(
|
useEffect(() => {
|
||||||
presets?.endDate || presets?.startDate
|
if (subscription && !subscription.auditLogs) {
|
||||||
? {
|
handlePopUpOpen("upgradePlan");
|
||||||
type: AuditLogDateFilterType.Absolute,
|
}
|
||||||
startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")),
|
}, [subscription]);
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (pageView)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
|
||||||
{showFilters && (
|
<div>
|
||||||
<LogsDateFilter
|
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||||
filter={dateFilter}
|
<p className="text-xl font-semibold text-mineshaft-100">Audit History</p>
|
||||||
setFilter={setDateFilter}
|
<a
|
||||||
timezone={timezone}
|
href="https://infisical.com/docs/documentation/platform/audit-logs"
|
||||||
setTimezone={setTimezone}
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
)}
|
>
|
||||||
{showFilters && (
|
<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">
|
||||||
<LogsFilter presets={presets} setFilter={setLogFilter} filter={logFilter} />
|
<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>
|
||||||
<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>
|
</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,
|
useRequestProjectAccess,
|
||||||
useSearchProjects
|
useSearchProjects
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
|
import { ProjectType, Workspace, WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||||
import {
|
import {
|
||||||
ProjectListToggle,
|
ProjectListToggle,
|
||||||
ProjectListView
|
ProjectListView
|
||||||
@@ -152,13 +152,17 @@ export const AllProjectView = ({
|
|||||||
type: projectTypeFilter
|
type: projectTypeFilter
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAccessProject = async (type: ProjectType, projectId: string) => {
|
const handleAccessProject = async (
|
||||||
|
type: ProjectType,
|
||||||
|
projectId: string,
|
||||||
|
environments: WorkspaceEnv[]
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await orgAdminAccessProject.mutateAsync({
|
await orgAdminAccessProject.mutateAsync({
|
||||||
projectId
|
projectId
|
||||||
});
|
});
|
||||||
await navigate({
|
await navigate({
|
||||||
to: getProjectHomePage(type),
|
to: getProjectHomePage(type, environments),
|
||||||
params: {
|
params: {
|
||||||
projectId
|
projectId
|
||||||
}
|
}
|
||||||
@@ -315,7 +319,7 @@ export const AllProjectView = ({
|
|||||||
onKeyDown={(evt) => {
|
onKeyDown={(evt) => {
|
||||||
if (evt.key === "Enter" && workspace.isMember) {
|
if (evt.key === "Enter" && workspace.isMember) {
|
||||||
navigate({
|
navigate({
|
||||||
to: getProjectHomePage(workspace.type),
|
to: getProjectHomePage(workspace.type, workspace.environments),
|
||||||
params: {
|
params: {
|
||||||
projectId: workspace.id
|
projectId: workspace.id
|
||||||
}
|
}
|
||||||
@@ -325,7 +329,7 @@ export const AllProjectView = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (workspace.isMember) {
|
if (workspace.isMember) {
|
||||||
navigate({
|
navigate({
|
||||||
to: getProjectHomePage(workspace.type),
|
to: getProjectHomePage(workspace.type, workspace.environments),
|
||||||
params: {
|
params: {
|
||||||
projectId: workspace.id
|
projectId: workspace.id
|
||||||
}
|
}
|
||||||
@@ -371,7 +375,7 @@ export const AllProjectView = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleAccessProject(workspace.type, workspace.id);
|
handleAccessProject(workspace.type, workspace.id, workspace.environments);
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
orgAdminAccessProject.variables?.projectId === workspace.id &&
|
orgAdminAccessProject.variables?.projectId === workspace.id &&
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export const MyProjectView = ({
|
|||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate({
|
navigate({
|
||||||
to: getProjectHomePage(workspace.type),
|
to: getProjectHomePage(workspace.type, workspace.environments),
|
||||||
params: {
|
params: {
|
||||||
projectId: workspace.id
|
projectId: workspace.id
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ export const MyProjectView = ({
|
|||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate({
|
navigate({
|
||||||
to: getProjectHomePage(workspace.type),
|
to: getProjectHomePage(workspace.type, workspace.environments),
|
||||||
params: {
|
params: {
|
||||||
projectId: workspace.id
|
projectId: workspace.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { OrgPermissionSubjects } from "@app/context";
|
|||||||
import {
|
import {
|
||||||
OrgGatewayPermissionActions,
|
OrgGatewayPermissionActions,
|
||||||
OrgPermissionAppConnectionActions,
|
OrgPermissionAppConnectionActions,
|
||||||
|
OrgPermissionAuditLogsActions,
|
||||||
OrgPermissionBillingActions,
|
OrgPermissionBillingActions,
|
||||||
OrgPermissionGroupActions,
|
OrgPermissionGroupActions,
|
||||||
OrgPermissionIdentityActions,
|
OrgPermissionIdentityActions,
|
||||||
@@ -23,6 +24,12 @@ const generalPermissionSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const auditLogsPermissionSchema = z
|
||||||
|
.object({
|
||||||
|
[OrgPermissionAuditLogsActions.Read]: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
const billingPermissionSchema = z
|
const billingPermissionSchema = z
|
||||||
.object({
|
.object({
|
||||||
[OrgPermissionBillingActions.Read]: z.boolean().optional(),
|
[OrgPermissionBillingActions.Read]: z.boolean().optional(),
|
||||||
@@ -121,7 +128,7 @@ export const formSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
"audit-logs": generalPermissionSchema,
|
"audit-logs": auditLogsPermissionSchema,
|
||||||
member: generalPermissionSchema,
|
member: generalPermissionSchema,
|
||||||
groups: groupPermissionSchema,
|
groups: groupPermissionSchema,
|
||||||
role: generalPermissionSchema,
|
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"
|
| "gateway"
|
||||||
| "secret-share"
|
| "secret-share"
|
||||||
| "billing"
|
| "billing"
|
||||||
|
| "audit-logs"
|
||||||
| "machine-identity-auth-template"
|
| "machine-identity-auth-template"
|
||||||
>;
|
>;
|
||||||
setValue: UseFormSetValue<TFormSchema>;
|
setValue: UseFormSetValue<TFormSchema>;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TFormSchema
|
TFormSchema
|
||||||
} from "../OrgRoleModifySection.utils";
|
} from "../OrgRoleModifySection.utils";
|
||||||
import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow";
|
import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow";
|
||||||
|
import { OrgPermissionAuditLogsRow } from "./OrgPermissionAuditLogsRow";
|
||||||
import { OrgPermissionBillingRow } from "./OrgPermissionBillingRow";
|
import { OrgPermissionBillingRow } from "./OrgPermissionBillingRow";
|
||||||
import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow";
|
import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow";
|
||||||
import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow";
|
import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow";
|
||||||
@@ -39,10 +40,6 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
|||||||
title: "Incident Contacts",
|
title: "Incident Contacts",
|
||||||
formName: "incident-contact"
|
formName: "incident-contact"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Audit Logs",
|
|
||||||
formName: "audit-logs"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Organization Profile",
|
title: "Organization Profile",
|
||||||
formName: "settings"
|
formName: "settings"
|
||||||
@@ -166,6 +163,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<OrgPermissionAuditLogsRow
|
||||||
|
control={control}
|
||||||
|
setValue={setValue}
|
||||||
|
isEditable={isCustomRole}
|
||||||
|
/>
|
||||||
<OrgPermissionIdentityRow
|
<OrgPermissionIdentityRow
|
||||||
control={control}
|
control={control}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState } from "react";
|
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 { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
import { Modal, ModalContent } from "@app/components/v2";
|
import { Modal, ModalContent } from "@app/components/v2";
|
||||||
@@ -20,14 +18,16 @@ enum WizardSteps {
|
|||||||
|
|
||||||
const PLATFORM_LIST = [
|
const PLATFORM_LIST = [
|
||||||
{
|
{
|
||||||
icon: faKey,
|
image: "/images/integrations/EnvKey.png",
|
||||||
platform: "env-key",
|
platform: "env-key",
|
||||||
title: "Env Key"
|
title: "EnvKey",
|
||||||
|
size: 34
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: faVault,
|
image: "/images/integrations/Vault.png",
|
||||||
platform: "vault",
|
platform: "vault",
|
||||||
title: "Vault"
|
title: "HCP Vault",
|
||||||
|
size: 40
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
|
|||||||
{PLATFORM_LIST.map((platform, idx) => (
|
{PLATFORM_LIST.map((platform, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`platform-${idx + 1}`}
|
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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => {
|
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 className="whitespace-pre-wrap text-center text-sm">{platform.title}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
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 { 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";
|
import { useImportVault } from "@app/hooks/api/migration/mutations";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -62,36 +69,32 @@ const MAPPING_TYPE_MENU_ITEMS = [
|
|||||||
export const VaultPlatformModal = ({ onClose }: Props) => {
|
export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
vaultUrl: z.string().min(1),
|
vaultUrl: z.string().min(1),
|
||||||
|
gatewayId: z.string().optional(),
|
||||||
vaultNamespace: z.string().trim().optional(),
|
vaultNamespace: z.string().trim().optional(),
|
||||||
vaultAccessToken: z.string().min(1),
|
vaultAccessToken: z.string().min(1),
|
||||||
mappingType: z.nativeEnum(VaultMappingType).default(VaultMappingType.KeyVault)
|
mappingType: z.nativeEnum(VaultMappingType).default(VaultMappingType.KeyVault)
|
||||||
});
|
});
|
||||||
type TFormData = z.infer<typeof formSchema>;
|
type TFormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list());
|
||||||
const { mutateAsync: importVault } = useImportVault();
|
const { mutateAsync: importVault } = useImportVault();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
formState: { isLoading, isDirty, isSubmitting, isValid, errors }
|
formState: { isLoading, isDirty, isSubmitting, isValid }
|
||||||
} = useForm<TFormData>({
|
} = useForm<TFormData>({
|
||||||
resolver: zodResolver(formSchema)
|
resolver: zodResolver(formSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log({
|
|
||||||
isSubmitting,
|
|
||||||
isLoading,
|
|
||||||
isValid,
|
|
||||||
errors
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TFormData) => {
|
const onSubmit = async (data: TFormData) => {
|
||||||
await importVault({
|
await importVault({
|
||||||
vaultAccessToken: data.vaultAccessToken,
|
vaultAccessToken: data.vaultAccessToken,
|
||||||
vaultNamespace: data.vaultNamespace,
|
vaultNamespace: data.vaultNamespace,
|
||||||
vaultUrl: data.vaultUrl,
|
vaultUrl: data.vaultUrl,
|
||||||
mappingType: data.mappingType
|
mappingType: data.mappingType,
|
||||||
|
...(data.gatewayId && { gatewayId: data.gatewayId })
|
||||||
});
|
});
|
||||||
createNotification({
|
createNotification({
|
||||||
title: "Import started",
|
title: "Import started",
|
||||||
@@ -110,11 +113,70 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
|
|||||||
The Vault migration currently supports importing static secrets from Vault
|
The Vault migration currently supports importing static secrets from Vault
|
||||||
Dedicated/Self-Hosted.
|
Dedicated/Self-Hosted.
|
||||||
<div className="mt-2 text-xs opacity-80">
|
<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>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</NoticeBannerV2>
|
</NoticeBannerV2>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
<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
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="vaultUrl"
|
name="vaultUrl"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const GithubOrgSyncConfigModal = ({
|
|||||||
formState: { isSubmitting }
|
formState: { isSubmitting }
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: data ? { githubOrgName: data.githubOrgName } : undefined
|
values: data ? { githubOrgName: data.githubOrgName, githubOrgAccessToken: "" } : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => {
|
const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => {
|
||||||
@@ -123,21 +123,21 @@ export const GithubOrgSyncConfigModal = ({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* <Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="githubOrgAccessToken"
|
name="githubOrgAccessToken"
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl
|
<FormControl
|
||||||
label="GitHub Org Scoped Access Token"
|
label="GitHub Access Token"
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
isOptional
|
isOptional
|
||||||
errorText={error?.message}
|
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>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/> */}
|
/>
|
||||||
<div className="flex gap-8 pt-4">
|
<div className="flex gap-8 pt-4">
|
||||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||||
{isUpdate ? "Update" : "Configure"}
|
{isUpdate ? "Update" : "Configure"}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { OrgPermissionCan } from "@app/components/permissions";
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2";
|
import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
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 { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
|
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
|
||||||
@@ -24,10 +29,73 @@ export const OrgGithubSyncSection = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
|
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
|
||||||
|
const syncAllTeamsMutation = useSyncAllGithubTeams();
|
||||||
|
|
||||||
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
|
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
|
||||||
const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined;
|
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 (
|
return (
|
||||||
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||||
<p className="text-xl font-semibold text-gray-200">
|
<p className="text-xl font-semibold text-gray-200">
|
||||||
@@ -86,6 +154,33 @@ export const OrgGithubSyncSection = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Modal
|
||||||
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
|
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
|||||||
text: "User privilege assumption has started"
|
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);
|
window.location.href = url.replace("$projectId", currentWorkspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const Page = () => {
|
|||||||
type: "success",
|
type: "success",
|
||||||
text: "Identity privilege assumption has started"
|
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);
|
window.location.href = url.replace("$projectId", currentWorkspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const Page = () => {
|
|||||||
text: "User privilege assumption has started"
|
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);
|
window.location.href = url.replace("$projectId", currentWorkspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import {
|
import {
|
||||||
PermissionConditionOperators,
|
PermissionConditionOperators,
|
||||||
|
ProjectPermissionAuditLogsActions,
|
||||||
ProjectPermissionCommitsActions,
|
ProjectPermissionCommitsActions,
|
||||||
ProjectPermissionDynamicSecretActions,
|
ProjectPermissionDynamicSecretActions,
|
||||||
ProjectPermissionGroupActions,
|
ProjectPermissionGroupActions,
|
||||||
@@ -41,6 +42,10 @@ const GeneralPolicyActionSchema = z.object({
|
|||||||
create: z.boolean().optional()
|
create: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AuditLogsPolicyActionSchema = z.object({
|
||||||
|
[ProjectPermissionAuditLogsActions.Read]: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
const CertificatePolicyActionSchema = z.object({
|
const CertificatePolicyActionSchema = z.object({
|
||||||
[ProjectPermissionCertificateActions.Create]: z.boolean().optional(),
|
[ProjectPermissionCertificateActions.Create]: z.boolean().optional(),
|
||||||
[ProjectPermissionCertificateActions.Delete]: z.boolean().optional(),
|
[ProjectPermissionCertificateActions.Delete]: z.boolean().optional(),
|
||||||
@@ -316,7 +321,7 @@ export const projectRoleFormSchema = z.object({
|
|||||||
[ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.AuditLogs]: AuditLogsPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.Certificates]: CertificatePolicyActionSchema.array().default([]),
|
[ProjectPermissionSub.Certificates]: CertificatePolicyActionSchema.array().default([]),
|
||||||
@@ -1324,12 +1329,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
|||||||
},
|
},
|
||||||
[ProjectPermissionSub.AuditLogs]: {
|
[ProjectPermissionSub.AuditLogs]: {
|
||||||
title: "Audit Logs",
|
title: "Audit Logs",
|
||||||
actions: [
|
actions: [{ label: "Read", value: ProjectPermissionAuditLogsActions.Read }]
|
||||||
{ label: "Read", value: "read" },
|
|
||||||
{ label: "Create", value: "create" },
|
|
||||||
{ label: "Modify", value: "edit" },
|
|
||||||
{ label: "Remove", value: "delete" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
[ProjectPermissionSub.IpAllowList]: {
|
[ProjectPermissionSub.IpAllowList]: {
|
||||||
title: "IP Allowlist",
|
title: "IP Allowlist",
|
||||||
@@ -1721,7 +1721,7 @@ const projectManagerTemplate = (
|
|||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
subject: ProjectPermissionSub.AuditLogs,
|
subject: ProjectPermissionSub.AuditLogs,
|
||||||
actions: Object.values(ProjectPermissionActions)
|
actions: Object.values(ProjectPermissionAuditLogsActions)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
subject: ProjectPermissionSub.Groups,
|
subject: ProjectPermissionSub.Groups,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { EmptyState } from "@app/components/v2";
|
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 { EventType } from "@app/hooks/api/auditLogs/enums";
|
||||||
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
|
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
|
||||||
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection";
|
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection";
|
||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
|
|
||||||
export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
|
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
|||||||
<LogsSection
|
<LogsSection
|
||||||
refetchInterval={4000}
|
refetchInterval={4000}
|
||||||
showFilters={false}
|
showFilters={false}
|
||||||
|
project={currentWorkspace}
|
||||||
presets={{
|
presets={{
|
||||||
eventMetadata: { integrationId: integration.id },
|
eventMetadata: { integrationId: integration.id },
|
||||||
startDate: new Date(
|
startDate: new Date(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
|
|||||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
|
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
|
||||||
import { SecretV3RawSanitized } from "@app/hooks/api/types";
|
import { SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||||
|
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||||
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
|
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
|
||||||
import { useResizableColWidth } from "@app/hooks/useResizableColWidth";
|
import { useResizableColWidth } from "@app/hooks/useResizableColWidth";
|
||||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||||
@@ -64,6 +65,8 @@ import { ActionBar } from "./components/ActionBar";
|
|||||||
import { CommitForm } from "./components/CommitForm";
|
import { CommitForm } from "./components/CommitForm";
|
||||||
import { CreateSecretForm } from "./components/CreateSecretForm";
|
import { CreateSecretForm } from "./components/CreateSecretForm";
|
||||||
import { DynamicSecretListView } from "./components/DynamicSecretListView";
|
import { DynamicSecretListView } from "./components/DynamicSecretListView";
|
||||||
|
import { EnvironmentTabs } from "./components/EnvironmentTabs";
|
||||||
|
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
|
||||||
import { FolderListView } from "./components/FolderListView";
|
import { FolderListView } from "./components/FolderListView";
|
||||||
import { PitDrawer } from "./components/PitDrawer";
|
import { PitDrawer } from "./components/PitDrawer";
|
||||||
import { SecretDropzone } from "./components/SecretDropzone";
|
import { SecretDropzone } from "./components/SecretDropzone";
|
||||||
@@ -105,7 +108,7 @@ const Page = () => {
|
|||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const { mutateAsync: createCommit } = useCreateCommit();
|
const { mutateAsync: createCommit } = useCreateCommit();
|
||||||
|
|
||||||
const tableRef = useRef<HTMLDivElement>(null);
|
const tableRef = useRef<HTMLTableElement>(null);
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const { isBatchMode, pendingChanges } = useBatchMode();
|
const { isBatchMode, pendingChanges } = useBatchMode();
|
||||||
@@ -249,7 +252,8 @@ const Page = () => {
|
|||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isPending: isDetailsLoading,
|
isPending: isDetailsLoading,
|
||||||
isFetching: isDetailsFetching
|
isFetching: isDetailsFetching,
|
||||||
|
isFetched
|
||||||
} = useGetProjectSecretsDetails({
|
} = useGetProjectSecretsDetails({
|
||||||
environment,
|
environment,
|
||||||
projectId: workspaceId,
|
projectId: workspaceId,
|
||||||
@@ -270,6 +274,18 @@ const Page = () => {
|
|||||||
tags: filter.tags
|
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 {
|
const {
|
||||||
imports,
|
imports,
|
||||||
folders,
|
folders,
|
||||||
@@ -491,7 +507,8 @@ const Page = () => {
|
|||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
maxWidth: tableRef.current
|
maxWidth: tableRef.current
|
||||||
? tableRef.current.clientWidth - 148 // ensure value column can't collapse completely
|
? tableRef.current.clientWidth - 148 // ensure value column can't collapse completely
|
||||||
: 800
|
: 800,
|
||||||
|
ref: tableRef
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -710,12 +727,18 @@ const Page = () => {
|
|||||||
|
|
||||||
const mergedSecrets = getMergedSecretsWithPending();
|
const mergedSecrets = getMergedSecretsWithPending();
|
||||||
const mergedFolders = getMergedFoldersWithPending();
|
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 (
|
return (
|
||||||
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
|
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
title="Secrets Management"
|
||||||
currentWorkspace.environments.find((env) => env.slug === environment)?.name ?? environment
|
|
||||||
}
|
|
||||||
description={
|
description={
|
||||||
<p className="text-md text-bunker-300">
|
<p className="text-md text-bunker-300">
|
||||||
Inject your secrets using
|
Inject your secrets using
|
||||||
@@ -759,6 +782,8 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SecretV2MigrationSection />
|
<SecretV2MigrationSection />
|
||||||
|
<FolderBreadCrumbs secretPath={secretPath} />
|
||||||
|
<EnvironmentTabs secretPath={secretPath} />
|
||||||
{!isRollbackMode ? (
|
{!isRollbackMode ? (
|
||||||
<>
|
<>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@@ -938,6 +963,7 @@ const Page = () => {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
onNavigateToFolder={handleResetFilter}
|
onNavigateToFolder={handleResetFilter}
|
||||||
|
canNavigate={isFetched}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
|
{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";
|
||||||