mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'main' into feature/secret-reference-shortcut
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
|
||||
t.dropForeign(["projectId"]);
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
|
||||
t.dropForeign(["projectId"]);
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
gatewayClientCertificate: z.string(),
|
||||
gatewayClientPrivateKey: z.string(),
|
||||
gatewayServerCertificateChain: z.string(),
|
||||
relayHost: z.string()
|
||||
relayHost: z.string(),
|
||||
metadata: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -468,7 +468,10 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
|
||||
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
|
||||
.optional(),
|
||||
secretComment: z.string().trim().optional().default(""),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
skipMultilineEncoding: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((val) => (val === null ? false : val)),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
secretMetadata: ResourceMetadataSchema.optional(),
|
||||
tagIds: z.string().array().optional()
|
||||
|
||||
@@ -480,6 +480,36 @@ export const pamAccountServiceFactory = ({
|
||||
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
|
||||
}
|
||||
|
||||
let metadata;
|
||||
|
||||
switch (resourceType) {
|
||||
case PamResource.Postgres:
|
||||
case PamResource.MySQL:
|
||||
{
|
||||
const connectionCredentials = await decryptResourceConnectionDetails({
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
const credentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
metadata = {
|
||||
username: credentials.username,
|
||||
database: connectionCredentials.database,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
resourceType,
|
||||
@@ -491,7 +521,8 @@ export const pamAccountServiceFactory = ({
|
||||
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
|
||||
relayHost: gatewayConnectionDetails.relayHost,
|
||||
projectId: account.projectId,
|
||||
account
|
||||
account,
|
||||
metadata
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -201,11 +201,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
|
||||
if (actorType === ActorType.USER) {
|
||||
void queryBuilder
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`)
|
||||
.on(`${TableName.IdentityMetadata}.userId`, db.raw("?", [actorId]))
|
||||
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
|
||||
} else if (actorType === ActorType.IDENTITY) {
|
||||
void queryBuilder
|
||||
.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`)
|
||||
.on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId]))
|
||||
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
|
||||
}
|
||||
})
|
||||
@@ -488,7 +488,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
})
|
||||
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
|
||||
void queryBuilder
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`)
|
||||
.on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`)
|
||||
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
|
||||
})
|
||||
.where(`${TableName.Membership}.scopeOrgId`, orgId)
|
||||
|
||||
@@ -996,7 +996,9 @@ export const relayServiceFactory = ({
|
||||
);
|
||||
|
||||
if (existingRelay && (existingRelay.host !== host || existingRelay.name !== name)) {
|
||||
return relayDAL.updateById(existingRelay.id, { host, name }, tx);
|
||||
throw new BadRequestError({
|
||||
message: `Machine identity already has an existing relay with the name "${existingRelay.name}" and host "${existingRelay.host}". Delete the existing relay or use a different machine identity.`
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingRelay) {
|
||||
|
||||
@@ -670,6 +670,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
.select(
|
||||
db.ref("projectId").withSchema(TableName.Environment),
|
||||
db.ref("slug").withSchema(TableName.Environment).as("environment"),
|
||||
db.ref("name").withSchema(TableName.Environment).as("environmentName"),
|
||||
db.ref("id").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerId"),
|
||||
db.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
|
||||
db.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
|
||||
@@ -699,30 +700,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
)
|
||||
.as("inner");
|
||||
|
||||
const countQuery = (await (tx || db)
|
||||
.select(db.raw("count(*) OVER() as total_count"))
|
||||
.from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{
|
||||
total_count: number;
|
||||
}>;
|
||||
|
||||
const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery;
|
||||
|
||||
if (search) {
|
||||
void query.where((qb) => {
|
||||
void qb
|
||||
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
|
||||
db.ref("firstName").withSchema("committerUser"),
|
||||
db.ref("lastName").withSchema("committerUser"),
|
||||
db.ref("committerUserFirstName"),
|
||||
db.ref("committerUserLastName"),
|
||||
`%${search}%`
|
||||
])
|
||||
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`])
|
||||
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`])
|
||||
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`)
|
||||
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`)
|
||||
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`);
|
||||
.orWhereRaw(`?? ilike ?`, [db.ref("committerUserUsername"), `%${search}%`])
|
||||
.orWhereRaw(`?? ilike ?`, [db.ref("committerUserEmail"), `%${search}%`])
|
||||
.orWhereILike(`environmentName`, `%${search}%`)
|
||||
.orWhereILike(`environment`, `%${search}%`)
|
||||
.orWhereILike(`policySecretPath`, `%${search}%`);
|
||||
});
|
||||
}
|
||||
|
||||
const countQuery = (await (tx || db)
|
||||
.select(db.raw("count(*) OVER() as total_count"))
|
||||
.from(query.clone().as("outer"))) as Array<{
|
||||
total_count: number;
|
||||
}>;
|
||||
|
||||
const rankOffset = offset + 1;
|
||||
const docs = await (tx || db)
|
||||
.with("w", query)
|
||||
|
||||
@@ -141,7 +141,8 @@ export const secretRawSchema = z.object({
|
||||
actorId: z.string().nullable().optional(),
|
||||
actorType: z.string().nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
membershipId: z.string().nullable().optional()
|
||||
membershipId: z.string().nullable().optional(),
|
||||
groupId: z.string().nullable().optional()
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
AccessScope,
|
||||
OrgMembershipRole,
|
||||
ProjectMembershipRole,
|
||||
ProjectMembershipsSchema,
|
||||
ProjectUserMembershipRolesSchema,
|
||||
@@ -266,6 +267,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const usernamesAndEmails = [...req.body.emails, ...req.body.usernames];
|
||||
|
||||
await server.services.membershipUser.createMembership({
|
||||
permission: req.permission,
|
||||
scopeData: {
|
||||
scope: AccessScope.Organization,
|
||||
orgId: req.permission.orgId
|
||||
},
|
||||
data: {
|
||||
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }],
|
||||
usernames: usernamesAndEmails
|
||||
}
|
||||
});
|
||||
|
||||
const { memberships } = await server.services.membershipUser.createMembership({
|
||||
permission: req.permission,
|
||||
scopeData: {
|
||||
|
||||
@@ -550,12 +550,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
providerAuthToken: req.body.providerAuthToken
|
||||
});
|
||||
|
||||
void res.setCookie("jid", data.token.refresh, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
if ([AuthMethod.GOOGLE, AuthMethod.GITHUB, AuthMethod.GITLAB].includes(data.decodedProviderToken.authMethod)) {
|
||||
void res.setCookie("jid", data.token.refresh, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
}
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
|
||||
@@ -112,7 +112,20 @@ export const identityOidcAuthServiceFactory = ({
|
||||
});
|
||||
|
||||
const { kid } = decodedToken.header as { kid: string };
|
||||
const oidcSigningKey = await client.getSigningKey(kid);
|
||||
|
||||
let oidcSigningKey;
|
||||
try {
|
||||
oidcSigningKey = await client.getSigningKey(kid);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "SigningKeyNotFoundError") {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.`
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
|
||||
let tokenData: Record<string, string>;
|
||||
try {
|
||||
|
||||
@@ -244,8 +244,8 @@ export const identityTokenAuthServiceFactory = ({
|
||||
}
|
||||
|
||||
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) {
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Token Auth attached"
|
||||
throw new NotFoundError({
|
||||
message: "Token Auth configuration not found for identity"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -419,13 +419,14 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
.select(
|
||||
selectAllTableCols(TableName.SecretFolder),
|
||||
db.raw(
|
||||
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${
|
||||
orderDirection ?? OrderByDirection.ASC
|
||||
}) as rank`
|
||||
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}) as rank`
|
||||
),
|
||||
db.ref("slug").withSchema(TableName.Environment).as("environment")
|
||||
)
|
||||
.orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection);
|
||||
.orderByRaw(
|
||||
`${TableName.SecretFolder}.?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`,
|
||||
[orderBy]
|
||||
);
|
||||
|
||||
if (limit) {
|
||||
const rankOffset = offset + 1; // ranks start from 1
|
||||
@@ -434,7 +435,10 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
.select("*")
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", rankOffset)
|
||||
.andWhere("w.rank", "<", rankOffset + limit);
|
||||
.andWhere("w.rank", "<", rankOffset + limit)
|
||||
.orderByRaw(`"w".?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`, [
|
||||
orderBy
|
||||
]);
|
||||
}
|
||||
|
||||
const folders = await query;
|
||||
@@ -445,7 +449,10 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findByEnvsDeep = async ({ parentIds }: TFindFoldersDeepByParentIdsDTO, tx?: Knex) => {
|
||||
const findByEnvsDeep = async (
|
||||
{ parentIds, orderBy = SecretsOrderBy.Name, orderDirection = OrderByDirection.ASC }: TFindFoldersDeepByParentIdsDTO,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const folders = await (tx || db.replicaNode())
|
||||
.withRecursive("parents", (qb) =>
|
||||
@@ -480,7 +487,9 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
.select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*")
|
||||
.from("parents")
|
||||
.orderBy("depth")
|
||||
.orderBy(`name`);
|
||||
.orderByRaw(`"parents".?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`, [
|
||||
orderBy
|
||||
]);
|
||||
|
||||
return folders;
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PgSqlLock } from "@app/keystore/keystore";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
|
||||
|
||||
import {
|
||||
@@ -781,7 +782,11 @@ export const secretFolderServiceFactory = ({
|
||||
if (!parentFolder) return [];
|
||||
|
||||
if (recursive) {
|
||||
const recursiveFolders = await folderDAL.findByEnvsDeep({ parentIds: [parentFolder.id] });
|
||||
const recursiveFolders = await folderDAL.findByEnvsDeep({
|
||||
parentIds: [parentFolder.id],
|
||||
orderBy: orderBy || SecretsOrderBy.Name,
|
||||
orderDirection: orderDirection || OrderByDirection.ASC
|
||||
});
|
||||
// remove the parent folder
|
||||
return recursiveFolders
|
||||
.filter((folder) => {
|
||||
@@ -800,19 +805,15 @@ export const secretFolderServiceFactory = ({
|
||||
}));
|
||||
}
|
||||
|
||||
const folders = await folderDAL.find(
|
||||
{
|
||||
envId: env.id,
|
||||
parentId: parentFolder.id,
|
||||
isReserved: false,
|
||||
$search: search ? { name: `%${search}%` } : undefined
|
||||
},
|
||||
{
|
||||
sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
);
|
||||
const folders = await folderDAL.findByMultiEnv({
|
||||
environmentIds: [env.id],
|
||||
parentIds: [parentFolder.id],
|
||||
search,
|
||||
orderBy: orderBy || SecretsOrderBy.Name,
|
||||
orderDirection: orderDirection || OrderByDirection.ASC,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
if (lastSecretModified) {
|
||||
return folders.filter((el) =>
|
||||
el.lastSecretModified ? el.lastSecretModified >= new Date(lastSecretModified) : false
|
||||
|
||||
@@ -64,6 +64,8 @@ export type TGetFoldersDeepByEnvsDTO = {
|
||||
|
||||
export type TFindFoldersDeepByParentIdsDTO = {
|
||||
parentIds: string[];
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
};
|
||||
|
||||
export type TCreateManyFoldersDTO = {
|
||||
|
||||
@@ -793,6 +793,7 @@ export const reshapeBridgeSecret = (
|
||||
userActorId?: string | null;
|
||||
identityActorId?: string | null;
|
||||
membershipId?: string | null;
|
||||
groupId?: string | null;
|
||||
actorType?: string | null;
|
||||
tags?: {
|
||||
id: string;
|
||||
@@ -823,7 +824,8 @@ export const reshapeBridgeSecret = (
|
||||
actorType: secret.actorType,
|
||||
actorId: secret.userActorId || secret.identityActorId,
|
||||
name: secret.identityActorName || secret.userActorName,
|
||||
membershipId: secret.membershipId
|
||||
membershipId: secret.membershipId,
|
||||
groupId: secret.groupId
|
||||
}
|
||||
: undefined,
|
||||
tags: secret.tags,
|
||||
|
||||
@@ -182,7 +182,6 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
|
||||
const findVersionsBySecretIdWithActors = async ({
|
||||
secretId,
|
||||
projectId,
|
||||
secretVersions,
|
||||
findOpt = {},
|
||||
tx
|
||||
@@ -196,13 +195,22 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt;
|
||||
const query = (tx || db.replicaNode())(TableName.SecretVersionV2)
|
||||
.leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.SecretVersionV2}.folderId`)
|
||||
.leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`)
|
||||
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.Membership, (qb) => {
|
||||
void qb
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.andOn(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project]));
|
||||
.on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project]))
|
||||
.andOn(`${TableName.Membership}.scopeProjectId`, `${TableName.Environment}.projectId`)
|
||||
.andOn((sqb) => {
|
||||
void sqb
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`);
|
||||
});
|
||||
})
|
||||
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`)
|
||||
.leftJoin(
|
||||
TableName.SecretVersionV2Tag,
|
||||
@@ -216,12 +224,6 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
)
|
||||
.where((qb) => {
|
||||
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
|
||||
void qb.where(`${TableName.Membership}.scopeProjectId`, projectId);
|
||||
if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions);
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
|
||||
void qb.whereNull(`${TableName.Membership}.scopeProjectId`);
|
||||
if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions);
|
||||
})
|
||||
.select(
|
||||
@@ -229,6 +231,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
db.ref("username").withSchema(TableName.Users).as("userActorName"),
|
||||
db.ref("name").withSchema(TableName.Identity).as("identityActorName"),
|
||||
db.ref("id").withSchema(TableName.Membership).as("membershipId"),
|
||||
db.ref("actorGroupId").withSchema(TableName.Membership).as("groupId"),
|
||||
db.ref("id").withSchema(TableName.SecretTag).as("tagId"),
|
||||
db.ref("color").withSchema(TableName.SecretTag).as("tagColor"),
|
||||
db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")
|
||||
@@ -256,7 +259,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
...SecretVersionsV2Schema.parse(el),
|
||||
userActorName: el.userActorName,
|
||||
identityActorName: el.identityActorName,
|
||||
membershipId: el.membershipId
|
||||
membershipId: el.membershipId,
|
||||
groupId: el.groupId
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
|
||||
@@ -784,7 +784,10 @@
|
||||
"groups": [
|
||||
{
|
||||
"group": "Infisical PAM",
|
||||
"pages": ["documentation/platform/pam/overview"]
|
||||
"pages": [
|
||||
"documentation/platform/pam/overview",
|
||||
"documentation/platform/pam/session-recording"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ Infisical is designed to provide comprehensive, centralized, and efficient manag
|
||||
### 2. Projects
|
||||
|
||||
- **Definition and Role**: [Projects](/documentation/platform/project) are the highest-level construct within an [organization](/documentation/platform/organization) in Infisical. They serve as the primary container for all functionalities.
|
||||
- **Correspondence to Code Repositories**: Projects typically align with specific code repositories.
|
||||
- **Common Project Mappings**: Projects typically align with applications, services, or code repositories — each being a valid and common approach depending on your organizational structure.
|
||||
- **Functional Capabilities**: Each project encompasses features for managing secrets, certificates, and encryption keys, serving as the central hub for these resources.
|
||||
|
||||
<Note>Projects are isolated from one another. Secrets, certificates, and other resources cannot be shared or referenced across different projects. Each project maintains its own separate set of resources.</Note>
|
||||
|
||||
### 3. Environments
|
||||
|
||||
- **Purpose**: Environments are designed for organizing and compartmentalizing secrets within projects.
|
||||
@@ -40,8 +42,9 @@ Infisical is designed to provide comprehensive, centralized, and efficient manag
|
||||
|
||||
### 5. Imports
|
||||
|
||||
- **Purpose and Benefits**: To promote reusability and avoid redundancy, Infisical supports the use of imports. This allows secrets, folders, or entire environments to be referenced across multiple projects as needed.
|
||||
- **Best Practice**: Utilizing [secret imports](/documentation/platform/secret-reference#secret-imports) or [references](/documentation/platform/secret-reference#secret-referencing) ensures consistency and minimizes manual overhead.
|
||||
- **Purpose and Benefits**: To promote reusability and avoid redundancy within a project, Infisical supports the use of imports and references. This allows secrets, folders, or entire environments to be referenced within the same project as needed.
|
||||
- **Project Isolation**: Imports and references only work within a single project. Secrets cannot be imported or referenced across different projects, as projects are isolated from one another.
|
||||
- **Best Practice**: Utilizing [secret imports](/documentation/platform/secret-reference#secret-imports) or [references](/documentation/platform/secret-reference#secret-referencing) ensures consistency and minimizes manual overhead when managing secrets within a project.
|
||||
|
||||
### 6. Approval Workflows
|
||||
|
||||
|
||||
@@ -30,13 +30,96 @@ Using a hardware security module comes with the added benefit of having a secure
|
||||
Enabling HSM encryption has a set of key benefits:
|
||||
1. **Root Key Wrapping**: The root KMS encryption key that is used to secure your Infisical instance will be encrypted using the HSM device rather than the standard software-protected key.
|
||||
|
||||
|
||||
#### Caveats
|
||||
- **Performance**: Using an HSM device can have a performance impact on your Infisical instance. This is due to the additional latency introduced by the HSM device. This is however only noticeable when your instance(s) start up or when the encryption strategy is changed.
|
||||
- **Key Recovery**: If the HSM device is lost or destroyed, you will no longer be able to decrypt your data stored within Infisical. Most HSM providers offer recovery options, which you should consider when setting up an HSM device.
|
||||
|
||||
### Requirements
|
||||
- An Infisical instance with a version number that is equal to or greater than `v0.91.0`.
|
||||
- An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others.
|
||||
## Requirements
|
||||
- An HSM device _(PKCS#11 compatible library)_ from a compatible provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others.
|
||||
Infisical is validated to work with PKCS#11 2.30 and newer. If your HSM device doesn't follow the >=2.30 PKCS#11 standard you may see degraded performance.
|
||||
|
||||
|
||||
## Environment Variable Configuration
|
||||
To configure your Infisical instance to use an HSM, you must set the required environment variables. Below you'll find an example of the required environment variables.
|
||||
For further instructions on how to configure the HSM device for your Infisical instance, please see the [Setup Instructions](#setup-instructions) section.
|
||||
|
||||
|
||||
```dotenv
|
||||
HSM_LIB_PATH=/usr/local/lib/cloudhsm/cloudhsm.so
|
||||
HSM_SLOT=1
|
||||
HSM_KEY_LABEL=infisical-key
|
||||
HSM_PIN=your:pin
|
||||
```
|
||||
|
||||
- `HSM_LIB_PATH`: The path to the PKCS#11 library provided by the HSM provider. This usually comes in the form of a `.so` for Linux and MacOS, or a `.dll` file for Windows. For Docker, you need to mount the library path as a volume. Further instructions can be found below. If you are using Docker, make sure to set the HSM_LIB_PATH environment variable to the path where the library is mounted in the container.
|
||||
- `HSM_PIN`: The PKCS#11 PIN to use for authentication with the HSM device.
|
||||
- `HSM_SLOT`: The slot number to use for the HSM device. This is typically between `0` and `5` for most HSM devices.
|
||||
- `HSM_KEY_LABEL`: The label of the key to use for encryption. **Please note that if no key is found with the provided label, the HSM will create a new key with the provided label.**
|
||||
|
||||
You can read more about the [default instance configurations](/self-hosting/configuration/envars) here.
|
||||
|
||||
## PKCS#11 Key Attributes
|
||||
|
||||
If no AES key or HMAC key already exists with the label you defined on the `HSM_KEY_LABEL` environment variable, then Infisical will create one for you automatically using the label specified on `HSM_KEY_LABEL`.
|
||||
Below you'll find a list of the attributes each key will be created with.
|
||||
|
||||
### AES Key
|
||||
|
||||
<Accordion title="Bring your own key minimum requirements (optional)">
|
||||
If you bring your own AES key and don't let Infisical create it for you it must have at least the following attributes:
|
||||
|
||||
* `CKA_CLASS`: `CKO_SECRET_KEY` — Defines the key class _(secret key)_.
|
||||
* `CKA_KEY_TYPE`: `CKO_AES` — Defines the key type _(AES key)_.
|
||||
* `CKA_VALUE_LEN`: `32` — 256-bit key size.
|
||||
* `CKA_ENCRYPT`: `true` — Encryption capabilities enabled.
|
||||
* `CKA_DECRYPT`: `true` — Decryption capabilities enabled.
|
||||
* `CKA_TOKEN`: `true` — The key material will persist in your HSM so it can be reused.
|
||||
|
||||
<Warning>
|
||||
Note that for security reasons it is highly recommended to create an AES key with the full set of key attributes seen below if you're going to bring your own key.
|
||||
</Warning>
|
||||
</Accordion>
|
||||
|
||||
* `CKA_CLASS`: `CKO_SECRET_KEY` — Defines the key class _(secret key)_.
|
||||
* `CKA_KEY_TYPE`: `CKO_AES` — Defines the key type _(AES key)_.
|
||||
* `CKA_VALUE_LEN`: `32` — 256-bit key size.
|
||||
* `CKA_LABEL`: Your specified label in the `HSM_KEY_LABEL` environment variable.
|
||||
* `CKA_ENCRYPT`: `true` — Encryption capabilities enabled.
|
||||
* `CKA_DECRYPT`: `true` — Decryption capabilities enabled.
|
||||
* `CKA_TOKEN`: `true` — The key material will persist in your HSM so it can be reused.
|
||||
* `CKA_EXTRACTABLE`: `false` — The key material is not extractable from the HSM.
|
||||
* `CKA_SENSITIVE`: `true` — The key material is marked as sensitive.
|
||||
* `CKA_PRIVATE`: `true` — The key material is marked as private to the slot and can't be accessed from other slots.
|
||||
|
||||
### HMAC Key
|
||||
|
||||
<Accordion title="Bring your own key minimum requirements (optional)">
|
||||
If you bring your own HMAC key and don't let Infisical create it for you it must have at least the following attributes:
|
||||
|
||||
* `CKA_CLASS`: `CKO_SECRET_KEY` — Defines the key class _(secret key)_.
|
||||
* `CKA_KEY_TYPE`: `CKO_GENERIC_SECRET` — Defines the key class _(generic secret key)_.
|
||||
* `CKA_VALUE_LEN`: `32` — 256-bit key size
|
||||
* `CKA_SIGN`: `true` — Signing capabilities enabled
|
||||
* `CKA_VERIFY`: `true` — Verifying capabilities enabled.
|
||||
* `CKA_TOKEN`: `true` — The key material will persist in your HSM so it can be reused.
|
||||
|
||||
<Warning>
|
||||
Note that for security reasons it is highly recommended to create an HMAC key with the full set of key attributes seen below if you're going to bring your own key.
|
||||
</Warning>
|
||||
</Accordion>
|
||||
|
||||
* `CKA_CLASS`: `CKO_SECRET_KEY` — Defines the key class _(secret key)_.
|
||||
* `CKA_KEY_TYPE`: `CKO_GENERIC_SECRET` — Defines the key class _(generic secret key)_.
|
||||
* `CKA_VALUE_LEN`: `32` — 256-bit key size.
|
||||
* `CKA_LABEL`: Your specified label in the `HSM_KEY_LABEL` environment variable, suffixed with `_HMAC`. If you specify `infisical-key-v1`, then the HMAC key label will become `infisical-key-v1_HMAC`.
|
||||
* `CKA_SIGN`: `true` — Signing capabilities enabled
|
||||
* `CKA_VERIFY`: `true` — Verifying capabilities enabled.
|
||||
* `CKA_TOKEN`: `true` — The key material will persist in your HSM so it can be reused.
|
||||
* `CKA_EXTRACTABLE`: `false` — The key material is not extractable from the HSM.
|
||||
* `CKA_SENSITIVE`: `true` — The key material is marked as sensitive.
|
||||
* `CKA_PRIVATE`: `true` — The key material is marked as private to the slot and can't be accessed from other slots.
|
||||
|
||||
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
60
docs/documentation/platform/pam/session-recording.mdx
Normal file
60
docs/documentation/platform/pam/session-recording.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "Session Recording"
|
||||
sidebarTitle: "Session Recording"
|
||||
description: "Learn how Infisical records and stores session activity for auditing and monitoring."
|
||||
---
|
||||
|
||||
Infisical's Privileged Access Management (PAM) provides robust session recording capabilities to help you audit and monitor user activity across your infrastructure.
|
||||
|
||||
## How It Works
|
||||
|
||||
When a user initiates a session through the Infisical Gateway, a recording of the session begins. The gateway securely caches all recording data in temporary encrypted files on its local system.
|
||||
|
||||
Once the session concludes, the gateway transmits the complete recording to the Infisical platform for long-term, centralized storage. This asynchronous process ensures that sessions remain operational even if the connection to the Infisical platform is temporarily lost. After the upload is complete, administrators can search and review the session logs in the Infisical UI.
|
||||
|
||||
## What's Captured
|
||||
|
||||
The content captured during a session depends on the type of resource being accessed.
|
||||
|
||||
### Database Sessions
|
||||
|
||||
For database connections, Infisical captures all queries executed and their corresponding responses.
|
||||
|
||||
<Note>
|
||||
Support for additional resource types like SSH and RDP is coming soon.
|
||||
</Note>
|
||||
|
||||
## Viewing Recordings
|
||||
|
||||
To review session recordings:
|
||||
|
||||
1. Navigate to the **PAM Sessions** page in your project.
|
||||
2. Click on a session from the list to view its details.
|
||||
|
||||

|
||||
|
||||
The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more.
|
||||
|
||||

|
||||
|
||||
### Searching Logs
|
||||
|
||||
You can use the search bar to quickly find relevant information:
|
||||
|
||||
- **On the main Sessions page:** Search across all session logs to locate specific queries or outputs.
|
||||
- **On an individual session page:** Search within that specific session's logs to pinpoint activity.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## FAQ
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Are session recordings encrypted?">
|
||||
Yes. All session recordings are encrypted at rest by default, ensuring your audit data is always secure.
|
||||
</Accordion>
|
||||
<Accordion title="Why aren't recordings streamed in real-time?">
|
||||
Currently, Infisical uses an asynchronous approach where the gateway records the entire session locally before uploading it. This design makes your PAM sessions more resilient, as they don't depend on a constant, active connection to the Infisical platform. We may introduce live streaming capabilities in a future release.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 415 KiB |
BIN
docs/images/pam/session-recording/individual-session-page.png
Normal file
BIN
docs/images/pam/session-recording/individual-session-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 462 KiB |
BIN
docs/images/pam/session-recording/sessions-page-search.png
Normal file
BIN
docs/images/pam/session-recording/sessions-page-search.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 500 KiB |
BIN
docs/images/pam/session-recording/sessions-page.png
Normal file
BIN
docs/images/pam/session-recording/sessions-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
@@ -51,6 +51,27 @@ $ kubectl logs deployment/infisical-agent-injector
|
||||
2025/05/19 14:20:06 Successfully updated webhook configuration with CA bundle
|
||||
```
|
||||
|
||||
## Windows support
|
||||
|
||||
The Infisical Agent Injector supports both running on Windows-based pods, and injecting the agent into Windows-based pods.
|
||||
|
||||
To run the agent injector on a Windows pod, it's important that you add the `nodeSelector.kubernetes.io/os` label to the pod's deployment with the value `windows`.
|
||||
This can be done by changing the helm values.yaml by adding the following:
|
||||
|
||||
```yaml values.yaml
|
||||
nodeSelector:
|
||||
kubernetes.io/os: windows
|
||||
```
|
||||
|
||||
By default the agent injector will run on Linux-based pods, unless you specify otherwise like in the example above.
|
||||
No extra configuration is needed to inject into Windows-based pods, as the agent injector will detect and handle the injection automatically.
|
||||
|
||||
The Agent Injector will only run and inject into Windows-based pods that are running on the supported Windows versions:
|
||||
- **Windows Server 2022**
|
||||
|
||||
We're looking to add support for other Windows versions in the future. If you're using a different Windows version, please let us know by opening [an issue](https://github.com/Infisical/infisical-agent-injector/issues/new), and we'll look into adding support for your desired version as soon as possible.
|
||||
|
||||
|
||||
## Supported annotations
|
||||
|
||||
The Infisical Agent Injector supports the following annotations:
|
||||
|
||||
@@ -159,8 +159,8 @@ export type TCreateCertificateDTO = {
|
||||
ttl: string; // string compatible with ms
|
||||
notBefore?: string;
|
||||
notAfter?: string;
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
keyUsages: string[];
|
||||
extendedKeyUsages: string[];
|
||||
};
|
||||
|
||||
export type TCreateCertificateResponse = {
|
||||
|
||||
@@ -90,7 +90,12 @@ export const useCreateCertTemplateV2 = () => {
|
||||
return data.certificateTemplate;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const [firstKey, queryProjectId] = query.queryKey;
|
||||
return firstKey === "list-template" && queryProjectId === projectId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -107,7 +112,12 @@ export const useUpdateCertTemplateV2 = () => {
|
||||
return data.certificateTemplate;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const [firstKey, queryProjectId] = query.queryKey;
|
||||
return firstKey === "list-template" && queryProjectId === projectId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -127,7 +137,12 @@ export const useDeleteCertTemplateV2 = () => {
|
||||
return data.certificateTemplate;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const [firstKey, queryProjectId] = query.queryKey;
|
||||
return firstKey === "list-template" && queryProjectId === projectId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -109,6 +109,7 @@ export type SecretVersions = {
|
||||
actorType?: string | null;
|
||||
name?: string | null;
|
||||
membershipId?: string | null;
|
||||
groupId?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -94,9 +94,9 @@ const createSchema = (shouldShowSubjectSection: boolean) => {
|
||||
export type FormData = z.infer<ReturnType<typeof createSchema>>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["certificateIssuance"]>;
|
||||
popUp: UsePopUpState<["issueCertificate"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["certificateIssuance"]>,
|
||||
popUpName: keyof UsePopUpState<["issueCertificate"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
profileId?: string;
|
||||
@@ -115,7 +115,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const inputSerialNumber =
|
||||
(popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "";
|
||||
(popUp?.issueCertificate?.data as { serialNumber: string })?.serialNumber || "";
|
||||
const sanitizedSerialNumber = inputSerialNumber.replace(/[^a-fA-F0-9:]/g, "");
|
||||
|
||||
const { data: cert } = useGetCert(sanitizedSerialNumber);
|
||||
@@ -181,7 +181,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
} = useCertificateTemplate(
|
||||
templateData,
|
||||
actualSelectedProfile,
|
||||
popUp?.certificateIssuance?.isOpen || false,
|
||||
popUp?.issueCertificate?.isOpen || false,
|
||||
setValue,
|
||||
watch
|
||||
);
|
||||
@@ -227,10 +227,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
}, [cert, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (popUp?.certificateIssuance?.isOpen && profileId && !cert) {
|
||||
if (popUp?.issueCertificate?.isOpen && profileId && !cert) {
|
||||
setValue("profileId", profileId);
|
||||
}
|
||||
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
|
||||
}, [popUp?.issueCertificate?.isOpen, profileId, cert, setValue]);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
async ({
|
||||
@@ -332,9 +332,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.certificateIssuance?.isOpen}
|
||||
isOpen={popUp?.issueCertificate?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("certificateIssuance", isOpen);
|
||||
handlePopUpToggle("issueCertificate", isOpen);
|
||||
if (!isOpen) {
|
||||
resetAllState();
|
||||
}
|
||||
@@ -503,7 +503,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => {
|
||||
handlePopUpToggle("certificateIssuance", false);
|
||||
handlePopUpToggle("issueCertificate", false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -75,6 +75,7 @@ export type FormData = z.infer<typeof schema>;
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["certificate"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void;
|
||||
preselectedTemplate?: { id: string; name: string };
|
||||
};
|
||||
|
||||
type TCertificateDetails = {
|
||||
@@ -86,7 +87,7 @@ type TCertificateDetails = {
|
||||
|
||||
const CERT_TEMPLATE_NONE_VALUE = "none";
|
||||
|
||||
export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
export const CertificateModal = ({ popUp, handlePopUpToggle, preselectedTemplate }: Props) => {
|
||||
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
|
||||
const { currentProject } = useProject();
|
||||
const { data: cert } = useGetCert(
|
||||
@@ -147,13 +148,15 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
(cert.extendedKeyUsages || []).map((name) => [name, true])
|
||||
)
|
||||
});
|
||||
} else {
|
||||
} else if (popUp?.certificate?.isOpen) {
|
||||
const templateId = preselectedTemplate?.id || CERT_TEMPLATE_NONE_VALUE;
|
||||
|
||||
reset({
|
||||
caId: "",
|
||||
commonName: "",
|
||||
subjectAltNames: "",
|
||||
ttl: "",
|
||||
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
|
||||
certificateTemplateId: templateId,
|
||||
keyUsages: {
|
||||
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
|
||||
[CertKeyUsage.KEY_ENCIPHERMENT]: true
|
||||
@@ -161,7 +164,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
extendedKeyUsages: {}
|
||||
});
|
||||
}
|
||||
}, [cert]);
|
||||
}, [cert, preselectedTemplate, popUp?.certificate?.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cert && selectedCertTemplate) {
|
||||
@@ -198,10 +201,14 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
ttl,
|
||||
keyUsages: Object.entries(keyUsages)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key as CertKeyUsage),
|
||||
.map(([key]) =>
|
||||
key === CertKeyUsage.CRL_SIGN
|
||||
? "cRLSign"
|
||||
: key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
),
|
||||
extendedKeyUsages: Object.entries(extendedKeyUsages)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key as CertExtendedKeyUsage)
|
||||
.map(([key]) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()))
|
||||
});
|
||||
|
||||
reset();
|
||||
@@ -269,15 +276,25 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
isRequired
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
isDisabled={Boolean(cert)}
|
||||
isDisabled={Boolean(cert) || Boolean(preselectedTemplate)}
|
||||
>
|
||||
<SelectItem value={CERT_TEMPLATE_NONE_VALUE} key="cert-template-none">
|
||||
None
|
||||
</SelectItem>
|
||||
{preselectedTemplate &&
|
||||
!templatesData?.certificateTemplates?.find(
|
||||
(t) => t.id === preselectedTemplate.id
|
||||
) && (
|
||||
<SelectItem
|
||||
value={preselectedTemplate.id}
|
||||
key={`cert-template-preselected-${preselectedTemplate.id}`}
|
||||
>
|
||||
{preselectedTemplate.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
{(templatesData?.certificateTemplates || []).map(({ id, name }) => (
|
||||
<SelectItem value={id} key={`cert-template-${id}`}>
|
||||
{name}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { CertificateImportModal } from "./CertificateImportModal";
|
||||
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
|
||||
import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal";
|
||||
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
|
||||
import { CertificateModal } from "./CertificateModal";
|
||||
import { CertificateRenewalModal } from "./CertificateRenewalModal";
|
||||
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
||||
import { CertificatesTable } from "./CertificatesTable";
|
||||
@@ -26,12 +25,8 @@ export const CertificatesSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: deleteCert } = useDeleteCert();
|
||||
|
||||
// TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation
|
||||
const isLegacyTemplatesEnabled = true;
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"certificateIssuance",
|
||||
"certificate",
|
||||
"issueCertificate",
|
||||
"certificateImport",
|
||||
"certificateCert",
|
||||
"deleteCertificate",
|
||||
@@ -76,9 +71,7 @@ export const CertificatesSection = () => {
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance")
|
||||
}
|
||||
onClick={() => handlePopUpOpen("issueCertificate")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Issue
|
||||
@@ -88,11 +81,7 @@ export const CertificatesSection = () => {
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
|
||||
{isLegacyTemplatesEnabled ? (
|
||||
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
) : (
|
||||
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
)}
|
||||
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
|
||||
@@ -64,7 +64,7 @@ type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
[
|
||||
"certificate",
|
||||
"issueCertificate",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"certificateCert",
|
||||
@@ -297,7 +297,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificate", {
|
||||
handlePopUpOpen("issueCertificate", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
faCertificate,
|
||||
faCog,
|
||||
faEllipsis,
|
||||
faFileContract,
|
||||
faPencil,
|
||||
faPlus,
|
||||
faTrash
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSub,
|
||||
useProject,
|
||||
@@ -50,6 +52,7 @@ import { useDeleteCertTemplateV2 } from "@app/hooks/api";
|
||||
import { useListCertificateTemplates } from "@app/hooks/api/certificateTemplates/queries";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
|
||||
import { CertificateModal } from "../CertificatesPage/components/CertificateModal";
|
||||
import { CertificateTemplateEnrollmentModal } from "../CertificatesPage/components/CertificateTemplateEnrollmentModal";
|
||||
import { PkiTemplateForm } from "./components/PkiTemplateForm";
|
||||
|
||||
@@ -64,7 +67,8 @@ export const PkiTemplateListPage = () => {
|
||||
"certificateTemplate",
|
||||
"deleteTemplate",
|
||||
"enrollmentOptions",
|
||||
"estUpgradePlan"
|
||||
"estUpgradePlan",
|
||||
"certificateFromTemplate"
|
||||
] as const);
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
@@ -160,6 +164,27 @@ export const PkiTemplateListPage = () => {
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Create}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("certificateFromTemplate", template);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faFileContract} />}
|
||||
>
|
||||
Issue Certificate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPkiTemplateActions.Edit}
|
||||
a={ProjectPermissionSub.CertificateTemplates}
|
||||
@@ -284,6 +309,16 @@ export const PkiTemplateListPage = () => {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<CertificateTemplateEnrollmentModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateModal
|
||||
popUp={{
|
||||
certificate: {
|
||||
isOpen: popUp.certificateFromTemplate.isOpen,
|
||||
data: popUp.certificateFromTemplate.data
|
||||
}
|
||||
}}
|
||||
handlePopUpToggle={(_, state) => handlePopUpToggle("certificateFromTemplate", state)}
|
||||
preselectedTemplate={popUp.certificateFromTemplate.data}
|
||||
/>
|
||||
</div>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.estUpgradePlan.isOpen}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
|
||||
const { data: caData } = useGetCaById(profile.caId);
|
||||
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const);
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const);
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
@@ -147,7 +147,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpToggle("certificateIssuance");
|
||||
handlePopUpToggle("issueCertificate");
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
|
||||
@@ -251,7 +251,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: getDefaultValues()
|
||||
defaultValues: getDefaultValues(),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
criteriaMode: "all"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MultiValue, SingleValue } from "react-select";
|
||||
import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -11,13 +12,10 @@ import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem
|
||||
Input
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useOrganization } from "@app/context";
|
||||
@@ -132,7 +130,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="mt-4 overflow-visible py-4">
|
||||
<form onSubmit={handleSubmit(setFilter)}>
|
||||
<div className="flex min-w-64 flex-col font-inter">
|
||||
<div className="flex max-w-96 min-w-96 flex-col font-inter">
|
||||
<div className="mb-3 flex items-center border-b border-b-mineshaft-500 px-3 pb-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -173,76 +171,24 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
name="eventType"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="inline-flex thin-scrollbar w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal whitespace-nowrap text-bunker-200 outline-hidden data-placeholder:text-mineshaft-200">
|
||||
{selectedEventTypes?.length === 1
|
||||
? filteredEventTypes.find(
|
||||
(eventType) => eventType.value === selectedEventTypes[0]
|
||||
)?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
? "All events"
|
||||
: `${selectedEventTypes?.length} events selected`}
|
||||
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={2}
|
||||
className="z-100 max-h-80 thin-scrollbar overflow-hidden"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filteredEventTypes.length > 0 ? (
|
||||
filteredEventTypes.map((eventType) => {
|
||||
const isSelected = selectedEventTypes?.includes(
|
||||
eventType.value as EventType
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) =>
|
||||
filteredEventTypes.length > 1 && event.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
selectedEventTypes?.includes(eventType.value as EventType)
|
||||
) {
|
||||
field.onChange(
|
||||
selectedEventTypes?.filter(
|
||||
(e: string) => e !== eventType.value
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([
|
||||
...(selectedEventTypes || []),
|
||||
eventType.value
|
||||
]);
|
||||
}
|
||||
}}
|
||||
key={`event-type-${eventType.value}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{eventType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
value={filteredEventTypes.filter((eventType) =>
|
||||
field.value.includes(eventType.value as EventType)
|
||||
)}
|
||||
isMulti
|
||||
isClearable
|
||||
onChange={(options) =>
|
||||
field.onChange(
|
||||
(options as MultiValue<(typeof filteredEventTypes)[number]>).map(
|
||||
(option) => option.value
|
||||
)
|
||||
)
|
||||
}
|
||||
placeholder="All events"
|
||||
options={filteredEventTypes}
|
||||
getOptionValue={(option) => option.value}
|
||||
getOptionLabel={(option) => option.label}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -256,31 +202,27 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="userAgentType"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
{...field}
|
||||
value={value === undefined ? "all" : value}
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else setValue("userAgentType", e as UserAgentType, { shouldDirty: true });
|
||||
}}
|
||||
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem value="all" key="all">
|
||||
All sources
|
||||
</SelectItem>
|
||||
{userAgentTypes.map(({ label, value: userAgent }) => (
|
||||
<SelectItem value={userAgent} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
value={
|
||||
userAgentTypes.find(
|
||||
(userAgentType) => value === (userAgentType.value as UserAgentType)
|
||||
) ?? null
|
||||
}
|
||||
isClearable
|
||||
onChange={(option) =>
|
||||
onChange((option as SingleValue<(typeof userAgentTypes)[number]>)?.value)
|
||||
}
|
||||
placeholder="All sources"
|
||||
options={userAgentTypes}
|
||||
getOptionValue={(option) => option.value}
|
||||
getOptionLabel={(option) => option.label}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,6 @@ import { RelayOption } from "./RelayOption";
|
||||
|
||||
const baseFormSchema = z.object({
|
||||
name: slugSchema({ field: "name" }),
|
||||
instanceDomain: z.string().url("Must be a valid URL").or(z.literal("")),
|
||||
relay: z
|
||||
.object(
|
||||
{
|
||||
@@ -78,7 +77,6 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
const [autogenerateToken, setAutogenerateToken] = useState(true);
|
||||
const [step, setStep] = useState<"form" | "command">("form");
|
||||
const [name, setName] = useState("");
|
||||
const [instanceDomain, setInstanceDomain] = useState(siteURL);
|
||||
const [relay, setRelay] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -131,8 +129,7 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
const validation = formSchemaWithIdentity.safeParse({
|
||||
name,
|
||||
relay,
|
||||
identity,
|
||||
instanceDomain
|
||||
identity
|
||||
});
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
@@ -175,8 +172,7 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
const validation = formSchemaWithToken.safeParse({
|
||||
name,
|
||||
relay,
|
||||
identityToken,
|
||||
instanceDomain
|
||||
identityToken
|
||||
});
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
@@ -187,11 +183,10 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
};
|
||||
|
||||
const command = useMemo(() => {
|
||||
const domainFlag = instanceDomain ? ` --domain=${instanceDomain}` : "";
|
||||
return `infisical gateway start --name=${name} --relay=${
|
||||
relay?.name || ""
|
||||
}${domainFlag} --token=${identityToken}`;
|
||||
}, [name, relay, identityToken, instanceDomain]);
|
||||
} --domain=${siteURL} --token=${identityToken}`;
|
||||
}, [name, relay, identityToken, siteURL]);
|
||||
|
||||
if (step === "command") {
|
||||
return (
|
||||
@@ -274,19 +269,6 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
/>
|
||||
{errors.relay && <p className="mt-1 text-sm text-red">{errors.relay}</p>}
|
||||
|
||||
<FormLabel
|
||||
label="Infisical Instance Host Address"
|
||||
tooltipText="The host address of the infisical instance that's accessible by the gateway."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={instanceDomain}
|
||||
onChange={(e) => setInstanceDomain(e.target.value)}
|
||||
placeholder="https://app.infisical.com"
|
||||
isError={Boolean(errors.instanceDomain)}
|
||||
/>
|
||||
{errors.instanceDomain && <p className="mt-1 text-sm text-red">{errors.instanceDomain}</p>}
|
||||
|
||||
{canCreateToken && autogenerateToken ? (
|
||||
<>
|
||||
<FormLabel
|
||||
|
||||
@@ -31,8 +31,7 @@ import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const baseFormSchema = z.object({
|
||||
name: slugSchema({ field: "name" }),
|
||||
host: z.string().min(1, "Host is required"),
|
||||
instanceDomain: z.string().url("Must be a valid URL").or(z.literal(""))
|
||||
host: z.string().min(1, "Host is required")
|
||||
});
|
||||
|
||||
const formSchemaWithIdentity = baseFormSchema.extend({
|
||||
@@ -62,7 +61,6 @@ export const RelayCliDeploymentMethod = () => {
|
||||
const [name, setName] = useState("");
|
||||
const [host, setHost] = useState("");
|
||||
|
||||
const [instanceDomain, setInstanceDomain] = useState(siteURL);
|
||||
const [identity, setIdentity] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -106,7 +104,7 @@ export const RelayCliDeploymentMethod = () => {
|
||||
setFormErrors([]);
|
||||
|
||||
if (canCreateToken && autogenerateToken) {
|
||||
const validation = formSchemaWithIdentity.safeParse({ name, host, instanceDomain, identity });
|
||||
const validation = formSchemaWithIdentity.safeParse({ name, host, identity });
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
@@ -148,7 +146,6 @@ export const RelayCliDeploymentMethod = () => {
|
||||
const validation = formSchemaWithToken.safeParse({
|
||||
name,
|
||||
host,
|
||||
instanceDomain,
|
||||
identityToken
|
||||
});
|
||||
if (!validation.success) {
|
||||
@@ -169,9 +166,8 @@ export const RelayCliDeploymentMethod = () => {
|
||||
};
|
||||
|
||||
const command = useMemo(() => {
|
||||
const domainFlag = instanceDomain ? ` --domain=${instanceDomain}` : "";
|
||||
return `infisical relay start --name=${name}${domainFlag} --host=${host} --token=${identityToken}`;
|
||||
}, [name, instanceDomain, host, identityToken]);
|
||||
return `infisical relay start --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`;
|
||||
}, [name, siteURL, host, identityToken]);
|
||||
|
||||
if (step === "command") {
|
||||
return (
|
||||
@@ -239,19 +235,6 @@ export const RelayCliDeploymentMethod = () => {
|
||||
/>
|
||||
{errors.host && <p className="mt-1 text-sm text-red">{errors.host}</p>}
|
||||
|
||||
<FormLabel
|
||||
label="Infisical Instance Host Address"
|
||||
tooltipText="The host address of the infisical instance that's accessible by the relay."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={instanceDomain}
|
||||
onChange={(e) => setInstanceDomain(e.target.value)}
|
||||
placeholder="https://app.infisical.com"
|
||||
isError={Boolean(errors.instanceDomain)}
|
||||
/>
|
||||
{errors.instanceDomain && <p className="mt-1 text-sm text-red">{errors.instanceDomain}</p>}
|
||||
|
||||
{canCreateToken && autogenerateToken ? (
|
||||
<>
|
||||
<FormLabel
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect";
|
||||
|
||||
import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod";
|
||||
import { RelayTerraformDeploymentMethod } from "./RelayTerraformDeploymentMethod";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -11,7 +12,12 @@ type Props = {
|
||||
};
|
||||
|
||||
export const RelayDeploymentInfoMap = {
|
||||
cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod }
|
||||
cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod },
|
||||
terraform: {
|
||||
name: "Terraform",
|
||||
image: "Terraform.png",
|
||||
component: RelayTerraformDeploymentMethod
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type RelayDeploymentMethod = keyof typeof RelayDeploymentInfoMap;
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faCopy, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FilterableSelect,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
ModalClose,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionIdentityActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useOrgPermission
|
||||
} from "@app/context";
|
||||
import { AWS_REGIONS } from "@app/helpers/appConnections";
|
||||
import {
|
||||
useAddIdentityTokenAuth,
|
||||
useCreateTokenIdentityTokenAuth,
|
||||
useGetIdentityMembershipOrgs,
|
||||
useGetIdentityTokenAuth
|
||||
} from "@app/hooks/api";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const baseFormSchema = z.object({
|
||||
name: slugSchema({ field: "name" })
|
||||
});
|
||||
|
||||
const formSchemaWithIdentity = baseFormSchema.extend({
|
||||
identity: z
|
||||
.object(
|
||||
{
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
},
|
||||
{ required_error: "Identity is required" }
|
||||
)
|
||||
.nullable()
|
||||
.refine((val) => val !== null, { message: "Identity is required" })
|
||||
});
|
||||
|
||||
const formSchemaWithToken = baseFormSchema.extend({
|
||||
identityToken: z.string().min(1, "Token is required")
|
||||
});
|
||||
|
||||
const ec2FormSchema = z.object({
|
||||
awsRegion: z.string().min(1, "AWS Region is required"),
|
||||
vpcId: z.string().min(1, "VPC ID is required"),
|
||||
ami: z.string().min(1, "AMI ID is required"),
|
||||
subnetId: z.string().min(1, "Subnet ID is required")
|
||||
});
|
||||
|
||||
export const RelayTerraformDeploymentMethod = () => {
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port && port !== "80" ? `:${port}` : "";
|
||||
const siteURL = `${protocol}//${hostname}${portSuffix}`;
|
||||
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const [autogenerateToken, setAutogenerateToken] = useState(true);
|
||||
const [step, setStep] = useState<"form" | "command">("form");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const [identity, setIdentity] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
}>(null);
|
||||
const [identityToken, setIdentityToken] = useState("");
|
||||
const [formErrors, setFormErrors] = useState<z.ZodIssue[]>([]);
|
||||
|
||||
const [awsRegion, setAwsRegion] = useState("us-east-1");
|
||||
const [vpcId, setVpcId] = useState("");
|
||||
const [ami, setAmi] = useState("ami-01b2110eef525172b");
|
||||
const [subnetId, setSubnetId] = useState("");
|
||||
|
||||
const errors = useMemo(() => {
|
||||
const errorMap: Record<string, string | undefined> = {};
|
||||
formErrors.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errorMap[String(issue.path[0])] = issue.message;
|
||||
}
|
||||
});
|
||||
return errorMap;
|
||||
}, [formErrors]);
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const organizationId = currentOrg?.id || "";
|
||||
|
||||
const { permission } = useOrgPermission();
|
||||
const canCreateToken = permission.can(
|
||||
OrgPermissionIdentityActions.CreateToken,
|
||||
OrgPermissionSubjects.Identity
|
||||
);
|
||||
|
||||
const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } =
|
||||
useGetIdentityMembershipOrgs({
|
||||
organizationId,
|
||||
limit: 20000
|
||||
});
|
||||
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || [];
|
||||
|
||||
const { mutateAsync: createToken, isPending: isCreatingToken } =
|
||||
useCreateTokenIdentityTokenAuth();
|
||||
const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } =
|
||||
useAddIdentityTokenAuth();
|
||||
const { refetch } = useGetIdentityTokenAuth(identity?.id ?? "");
|
||||
|
||||
const handleGenerateCommand = async () => {
|
||||
setFormErrors([]);
|
||||
|
||||
if (canCreateToken && autogenerateToken) {
|
||||
const validation = formSchemaWithIdentity.safeParse({ name, identity });
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTabIndex === 0) {
|
||||
const ec2Validation = ec2FormSchema.safeParse({ awsRegion, vpcId, ami, subnetId });
|
||||
if (!ec2Validation.success) {
|
||||
setFormErrors(ec2Validation.error.issues);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const validatedIdentity = validation.data.identity;
|
||||
|
||||
try {
|
||||
const { data: identityTokenAuth } = await refetch();
|
||||
if (!identityTokenAuth) {
|
||||
await addIdentityTokenAuth({
|
||||
identityId: validatedIdentity.id,
|
||||
organizationId,
|
||||
accessTokenTTL: 2592000,
|
||||
accessTokenMaxTTL: 2592000,
|
||||
accessTokenNumUsesLimit: 0,
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
|
||||
});
|
||||
createNotification({
|
||||
text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.",
|
||||
type: "warning"
|
||||
});
|
||||
}
|
||||
|
||||
const token = await createToken({
|
||||
identityId: validatedIdentity.id,
|
||||
name: `relay token for ${name} (autogenerated)`
|
||||
});
|
||||
setIdentityToken(token.accessToken);
|
||||
createNotification({
|
||||
text: "Automatically generated a token for the selected identity.",
|
||||
type: "info"
|
||||
});
|
||||
setStep("command");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to generate token for the selected identity",
|
||||
type: "error"
|
||||
});
|
||||
setIdentityToken("");
|
||||
}
|
||||
} else {
|
||||
const validation = formSchemaWithToken.safeParse({
|
||||
name,
|
||||
identityToken
|
||||
});
|
||||
if (!validation.success) {
|
||||
setFormErrors(validation.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTabIndex === 0) {
|
||||
const ec2Validation = ec2FormSchema.safeParse({ awsRegion, vpcId, ami, subnetId });
|
||||
if (!ec2Validation.success) {
|
||||
setFormErrors(ec2Validation.error.issues);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStep("command");
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdentityChange = (
|
||||
selectedIdentity: SingleValue<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>
|
||||
) => {
|
||||
setIdentity(selectedIdentity);
|
||||
};
|
||||
|
||||
const terraformCommand = useMemo(() => {
|
||||
return `terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "${awsRegion}"
|
||||
}
|
||||
|
||||
# Security Group for the Infisical Relay instance
|
||||
resource "aws_security_group" "infisical_relay_sg" {
|
||||
name = "${name}-relay-sg"
|
||||
description = "Allows inbound traffic for Infisical Relay and SSH"
|
||||
vpc_id = "${vpcId}"
|
||||
|
||||
# Inbound: Allows the Infisical platform to securely communicate with the Relay server.
|
||||
ingress {
|
||||
from_port = 8443
|
||||
to_port = 8443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
# Inbound: Allows Infisical Gateway to securely communicate via the Relay.
|
||||
ingress {
|
||||
from_port = 2222
|
||||
to_port = 2222
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
# Inbound: Allows secure shell (SSH) access for administration.
|
||||
ingress {
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"] # Restrict this to your IP in production
|
||||
}
|
||||
|
||||
# Outbound: Allows the Relay server to make necessary outbound connections to the Infisical platform.
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "${name}-relay-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# Elastic IP for a static public IP address
|
||||
resource "aws_eip" "infisical_relay_eip" {
|
||||
tags = {
|
||||
Name = "${name}-relay-eip"
|
||||
}
|
||||
}
|
||||
|
||||
# EC2 instance to run Infisical Relay
|
||||
module "infisical_relay_instance" {
|
||||
source = "terraform-aws-modules/ec2-instance/aws"
|
||||
version = "~> 5.6"
|
||||
|
||||
name = "${name}-relay-instance"
|
||||
ami = "${ami}"
|
||||
instance_type = "t3.micro"
|
||||
subnet_id = "${subnetId}"
|
||||
|
||||
vpc_security_group_ids = [aws_security_group.infisical_relay_sg.id]
|
||||
associate_public_ip_address = false # We are using an Elastic IP instead
|
||||
|
||||
user_data = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
# Install Infisical CLI
|
||||
curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash
|
||||
apt-get update && apt-get install -y infisical
|
||||
|
||||
# Install the relay as a systemd service.
|
||||
# This example uses a Machine Identity token for authentication via the INFISICAL_TOKEN environment variable.
|
||||
#
|
||||
# Note: For production environments, you might consider fetching the token from AWS Parameter Store or AWS Secrets Manager.
|
||||
export INFISICAL_TOKEN="${identityToken}"
|
||||
sudo -E infisical relay systemd install \\
|
||||
--name "${name}" \\
|
||||
--domain "${siteURL}" \\
|
||||
--host "\${aws_eip.infisical_relay_eip.public_ip}"
|
||||
|
||||
# Start and enable the service to run on boot
|
||||
sudo systemctl start infisical-relay
|
||||
sudo systemctl enable infisical-relay
|
||||
EOT
|
||||
}
|
||||
|
||||
# Associate the Elastic IP with the EC2 instance
|
||||
resource "aws_eip_association" "eip_assoc" {
|
||||
instance_id = module.infisical_relay_instance.id
|
||||
allocation_id = aws_eip.infisical_relay_eip.id
|
||||
}
|
||||
`;
|
||||
}, [name, siteURL, identityToken, awsRegion, vpcId, ami, subnetId]);
|
||||
|
||||
if (step === "command") {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span>Terraform Configuration</span>
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(terraformCommand);
|
||||
createNotification({
|
||||
text: "Terraform configuration copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="h-80 overflow-y-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 font-mono text-sm text-bunker-300">
|
||||
<pre>
|
||||
<code>{terraformCommand}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center">
|
||||
<ModalClose asChild>
|
||||
<Button className="mr-4" size="sm" colorSchema="secondary">
|
||||
Done
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormLabel label="Name" tooltipText="The name for your relay." />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter relay name..."
|
||||
isError={Boolean(errors.name)}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red">{errors.name}</p>}
|
||||
|
||||
{canCreateToken && autogenerateToken ? (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Identity"
|
||||
tooltipText="The identity that your relay will use for authentication."
|
||||
className="mt-4"
|
||||
/>
|
||||
<FilterableSelect
|
||||
value={identity}
|
||||
onChange={(e) =>
|
||||
handleIdentityChange(
|
||||
e as SingleValue<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>
|
||||
)
|
||||
}
|
||||
isLoading={isIdentitiesLoading}
|
||||
placeholder="Select identity..."
|
||||
options={identityMembershipOrgs.map((membership) => membership.identity)}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
{errors.identity && <p className="mt-1 text-sm text-red">{errors.identity}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormLabel
|
||||
label="Identity Token"
|
||||
tooltipText="The identity token that your relay will use for authentication."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={identityToken}
|
||||
onChange={(e) => setIdentityToken(e.target.value)}
|
||||
placeholder="Enter identity token..."
|
||||
isError={Boolean(errors.identityToken)}
|
||||
/>
|
||||
{errors.identityToken && <p className="mt-1 text-sm text-red">{errors.identityToken}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCreateToken && (
|
||||
<div className="mt-2">
|
||||
<Checkbox
|
||||
isChecked={autogenerateToken}
|
||||
onCheckedChange={(e) => {
|
||||
setAutogenerateToken(Boolean(e));
|
||||
}}
|
||||
id="autogenerate-token"
|
||||
className="mr-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>Automatically enable token auth and generate a token for identity</span>
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
content={
|
||||
<>
|
||||
Token authentication will be automatically enabled for the selected identity if
|
||||
it isn't already configured. By default, it will be configured to allow all
|
||||
IP addresses with a token TTL of 30 days. You can manage these settings in
|
||||
Access Control.
|
||||
<br />
|
||||
<br />A token will automatically be generated to be used with the CLI command.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="mt-0.5 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
|
||||
<Tab.List className="-pb-1 mt-4 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${
|
||||
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
EC2
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
|
||||
<Tab.Panel>
|
||||
<FormLabel label="AWS Region" />
|
||||
<FilterableSelect
|
||||
value={AWS_REGIONS.find((r) => r.slug === awsRegion)}
|
||||
onChange={(selected) => {
|
||||
if (selected) {
|
||||
setAwsRegion((selected as SingleValue<{ slug: string; name: string }>)!.slug);
|
||||
}
|
||||
}}
|
||||
options={AWS_REGIONS}
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
{errors.awsRegion && <p className="mt-1 text-sm text-red">{errors.awsRegion}</p>}
|
||||
<FormLabel label="VPC ID" className="mt-4" />
|
||||
<Input
|
||||
value={vpcId}
|
||||
onChange={(e) => setVpcId(e.target.value)}
|
||||
placeholder="vpc-..."
|
||||
isError={Boolean(errors.vpcId)}
|
||||
/>
|
||||
{errors.vpcId && <p className="mt-1 text-sm text-red">{errors.vpcId}</p>}
|
||||
<FormLabel
|
||||
label="AMI ID"
|
||||
tooltipText="The ID of the Amazon Machine Image (AMI) for the EC2 linux instance."
|
||||
className="mt-4"
|
||||
/>
|
||||
<Input
|
||||
value={ami}
|
||||
onChange={(e) => setAmi(e.target.value)}
|
||||
placeholder="ami-..."
|
||||
isError={Boolean(errors.ami)}
|
||||
/>
|
||||
{errors.ami && <p className="mt-1 text-sm text-red">{errors.ami}</p>}
|
||||
<FormLabel label="Subnet ID" className="mt-4" />
|
||||
<Input
|
||||
value={subnetId}
|
||||
onChange={(e) => setSubnetId(e.target.value)}
|
||||
placeholder="subnet-..."
|
||||
isError={Boolean(errors.subnetId)}
|
||||
/>
|
||||
{errors.subnetId && <p className="mt-1 text-sm text-red">{errors.subnetId}</p>}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
onClick={handleGenerateCommand}
|
||||
isLoading={isCreatingToken || isAddingTokenAuth}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { PamResourceType } from "@app/hooks/api/pam";
|
||||
|
||||
type TableLog = {
|
||||
command?: string;
|
||||
data_rows: Record<string, string | number | null>[];
|
||||
total_rows?: number;
|
||||
};
|
||||
|
||||
export const PamSessionLogOutput = ({
|
||||
content,
|
||||
resourceType,
|
||||
search
|
||||
}: {
|
||||
content: string;
|
||||
resourceType: PamResourceType;
|
||||
search: string;
|
||||
}) => {
|
||||
const [isRawView, setIsRawView] = useState(false);
|
||||
|
||||
let parsedContent: TableLog | null = null;
|
||||
|
||||
if (resourceType === PamResourceType.Postgres || resourceType === PamResourceType.MySQL) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
parsed.data_rows &&
|
||||
Array.isArray(parsed.data_rows) &&
|
||||
parsed.data_rows.length > 0 &&
|
||||
typeof parsed.data_rows[0] === "object" &&
|
||||
parsed.data_rows[0] !== null
|
||||
) {
|
||||
parsedContent = parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid JSON or doesn't match structure, will render as plain text
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedContent) {
|
||||
const headers = Object.keys(parsedContent.data_rows[0]);
|
||||
return (
|
||||
<div className="font-sans">
|
||||
{isRawView ? (
|
||||
<div className="font-mono break-all whitespace-pre-wrap">
|
||||
<HighlightText text={content} highlight={search} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{parsedContent.command && (
|
||||
<div className="mb-2 font-mono">{`> ${parsedContent.command}`}</div>
|
||||
)}
|
||||
<div className="overflow-x-auto rounded-md border border-mineshaft-600">
|
||||
<table className="w-full min-w-max text-left">
|
||||
<thead className="bg-mineshaft-800">
|
||||
<tr className="border-b border-mineshaft-600">
|
||||
{headers.map((header) => (
|
||||
<th key={header} className="p-2 font-semibold text-mineshaft-200 capitalize">
|
||||
<HighlightText text={header.replace(/_/g, " ")} highlight={search} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedContent.data_rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`row-${rowIndex}`}
|
||||
className="border-b border-mineshaft-700 bg-mineshaft-900 last:border-b-0 hover:bg-mineshaft-800/50"
|
||||
>
|
||||
{headers.map((header) => (
|
||||
<td key={header} className="p-2">
|
||||
<HighlightText text={String(row[header] ?? "")} highlight={search} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm text-bunker-400 underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRawView((v) => !v);
|
||||
}}
|
||||
>
|
||||
{isRawView ? "View Formatted" : "View Raw"}
|
||||
</button>
|
||||
|
||||
{parsedContent.total_rows !== undefined && (
|
||||
<div className="ml-auto text-right text-sm text-bunker-400">
|
||||
Total rows: {parsedContent.total_rows}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono break-all whitespace-pre-wrap">
|
||||
<HighlightText text={content} highlight={search} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import { Input } from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { TPamSession } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamSessionLogOutput } from "./PamSessionLogOutput";
|
||||
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
||||
|
||||
type Props = {
|
||||
@@ -104,20 +103,9 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{log.output && (
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="h-px w-full bg-mineshaft-400" />
|
||||
<span className="text-xs text-mineshaft-400">OUTPUT</span>
|
||||
<div className="h-px w-full bg-mineshaft-400" />
|
||||
</div>
|
||||
<div className="pt-2 text-bunker-300">
|
||||
<PamSessionLogOutput
|
||||
content={log.output}
|
||||
resourceType={session.resourceType}
|
||||
search={search}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="pt-2 text-bunker-300">
|
||||
<HighlightText text={log.output} highlight={search} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,14 +25,12 @@ import {
|
||||
useProject
|
||||
} from "@app/context";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useAddUserToWsNonE2EE,
|
||||
useGetOrgUsers,
|
||||
useGetProjectRoles,
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectVersion } from "@app/hooks/api/projects/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
@@ -86,7 +84,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
|
||||
});
|
||||
|
||||
const { mutateAsync: addMemberToOrg } = useAddUsersToOrg();
|
||||
const { mutateAsync: addUserToProject } = useAddUserToWsNonE2EE();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,13 +137,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newInvitees.length) {
|
||||
await addMemberToOrg({
|
||||
inviteeEmails: newInvitees,
|
||||
organizationId: orgId,
|
||||
organizationRoleSlug: ProjectMembershipRole.Member // only applies to new invites
|
||||
});
|
||||
}
|
||||
if (newInvitees.length || inviteeEmails.length) {
|
||||
await addUserToProject({
|
||||
usernames: [...inviteeEmails, ...newInvitees],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ enum GroupMembersOrderBy {
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
@@ -62,6 +64,21 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
initPerPage: getUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
// this handles links from secret versions when the actor is in a group membership
|
||||
const { username, ...restSearch } = useSearch({
|
||||
strict: false
|
||||
});
|
||||
useEffect(() => {
|
||||
if (username) {
|
||||
setSearch(username);
|
||||
navigate({
|
||||
to: ".",
|
||||
replace: true,
|
||||
search: restSearch
|
||||
});
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["assumePrivileges"] as const);
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
|
||||
@@ -8,6 +10,11 @@ export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
validateSearch: zodValidator(
|
||||
z.object({
|
||||
username: z.string().optional().catch(undefined)
|
||||
})
|
||||
),
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useGetSecretApprovalRequestCount,
|
||||
useGetSecretApprovalRequests,
|
||||
@@ -99,6 +99,12 @@ export const SecretApprovalRequest = () => {
|
||||
const totalApprovalCount = data?.totalCount ?? 0;
|
||||
const secretApprovalRequests = data?.approvals ?? [];
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: totalApprovalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||
useGetSecretApprovalRequestCount({ projectId });
|
||||
const { user: userSession } = useUser();
|
||||
@@ -107,7 +113,7 @@ export const SecretApprovalRequest = () => {
|
||||
});
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { data: members } = useGetWorkspaceUsers(projectId);
|
||||
const { data: members } = useGetWorkspaceUsers(projectId, true);
|
||||
const isSecretApprovalScreen = Boolean(selectedApprovalId);
|
||||
const { requestId } = search;
|
||||
|
||||
|
||||
@@ -221,9 +221,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Multi-line Encoding</div>
|
||||
<div className="text-sm">
|
||||
{secretVersion?.skipMultilineEncoding?.toString() || (
|
||||
<span className="text-sm text-mineshaft-300">-</span>
|
||||
)}{" "}
|
||||
{secretVersion?.skipMultilineEncoding?.toString() || "false"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,9 +364,8 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<div className="text-sm font-medium text-mineshaft-300">Multi-line Encoding</div>
|
||||
<div className="text-sm">
|
||||
{newVersion?.skipMultilineEncoding?.toString() ??
|
||||
secretVersion?.skipMultilineEncoding?.toString() ?? (
|
||||
<span className="text-sm text-mineshaft-300">-</span>
|
||||
)}{" "}
|
||||
secretVersion?.skipMultilineEncoding?.toString() ??
|
||||
"false"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { faEye } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faBan,
|
||||
faDesktop,
|
||||
faEyeSlash,
|
||||
faServer,
|
||||
@@ -68,10 +69,14 @@ export const SecretVersionItem = ({
|
||||
const getLinkToModifyHistoryEntity = (
|
||||
actorId: string,
|
||||
actorType: string,
|
||||
membershipId: string | null = ""
|
||||
membershipId: string | null = "",
|
||||
groupId: string | null = "",
|
||||
actorName: string | null = ""
|
||||
) => {
|
||||
switch (actorType) {
|
||||
case ActorType.USER:
|
||||
if (groupId)
|
||||
return `/projects/secret-management/${currentProject.id}/groups/${groupId}?username=${actorName}`;
|
||||
return `/projects/secret-management/${currentProject.id}/members/${membershipId}`;
|
||||
case ActorType.IDENTITY:
|
||||
return `/projects/secret-management/${currentProject.id}/identities/${actorId}`;
|
||||
@@ -83,10 +88,26 @@ export const SecretVersionItem = ({
|
||||
const onModifyHistoryClick = (
|
||||
actorId: string | undefined | null,
|
||||
actorType: string | undefined | null,
|
||||
membershipId: string | undefined | null
|
||||
membershipId: string | undefined | null,
|
||||
groupId: string | undefined | null,
|
||||
actorName: string | undefined | null
|
||||
) => {
|
||||
if (!membershipId) {
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: `This ${actorType === ActorType.USER ? "user" : "identity"} is no longer a member of this project.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (actorType && actorId && actorType !== ActorType.PLATFORM) {
|
||||
const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType, membershipId);
|
||||
const redirectLink = getLinkToModifyHistoryEntity(
|
||||
actorId,
|
||||
actorType,
|
||||
membershipId,
|
||||
groupId,
|
||||
actorName
|
||||
);
|
||||
if (redirectLink) {
|
||||
navigate({ to: redirectLink });
|
||||
}
|
||||
@@ -157,15 +178,35 @@ export const SecretVersionItem = ({
|
||||
<div className="flex flex-row">
|
||||
<div className="flex w-fit flex-row text-sm">
|
||||
Modified by:
|
||||
<Tooltip content={getModifiedByName(actor.actorType, actor.name)}>
|
||||
<Tooltip
|
||||
className="z-[100] max-w-sm"
|
||||
content={
|
||||
getModifiedByName(actor.actorType, actor.name) +
|
||||
(!actor.membershipId && actor.actorId ? " (Removed from project)" : "")
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
onClick={() =>
|
||||
onModifyHistoryClick(actor.actorId, actor.actorType, actor.membershipId)
|
||||
onClick={
|
||||
actor.membershipId
|
||||
? () =>
|
||||
onModifyHistoryClick(
|
||||
actor.actorId,
|
||||
actor.actorType,
|
||||
actor.membershipId,
|
||||
actor.groupId,
|
||||
actor.name
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className="cursor-pointer"
|
||||
className={actor.membershipId ? "cursor-pointer" : undefined}
|
||||
>
|
||||
<FontAwesomeIcon icon={getModifiedByIcon(actor.actorType)} className="ml-2" />
|
||||
{!actor.membershipId &&
|
||||
actor.actorType &&
|
||||
[ActorType.USER, ActorType.IDENTITY].includes(
|
||||
actor.actorType as ActorType
|
||||
) && <FontAwesomeIcon className="ml-1 text-mineshaft-400" icon={faBan} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user