Merge branch 'main' into feature/secret-reference-shortcut

This commit is contained in:
Victor Santos
2025-11-07 12:26:06 -03:00
49 changed files with 1099 additions and 410 deletions

View File

@@ -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);
});
}
}

View File

@@ -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()
})
}
},

View File

@@ -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()

View File

@@ -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
};
};

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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(),

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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"
});
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -64,6 +64,8 @@ export type TGetFoldersDeepByEnvsDTO = {
export type TFindFoldersDeepByParentIdsDTO = {
parentIds: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
};
export type TCreateManyFoldersDTO = {

View File

@@ -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,

View File

@@ -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: [
{

View File

@@ -784,7 +784,10 @@
"groups": [
{
"group": "Infisical PAM",
"pages": ["documentation/platform/pam/overview"]
"pages": [
"documentation/platform/pam/overview",
"documentation/platform/pam/session-recording"
]
}
]
}

View File

@@ -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

View File

@@ -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

View 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.
![PAM Sessions](/images/pam/session-recording/sessions-page.png)
The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more.
![PAM Individual Session](/images/pam/session-recording/individual-session-page.png)
### 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.
![PAM Sessions Search](/images/pam/session-recording/sessions-page-search.png)
![PAM Individual Session Search](/images/pam/session-recording/individual-session-page-search.png)
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

View File

@@ -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:

View File

@@ -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 = {

View File

@@ -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;
}
});
}
});
};

View File

@@ -109,6 +109,7 @@ export type SecretVersions = {
actorType?: string | null;
name?: string | null;
membershipId?: string | null;
groupId?: string | null;
} | null;
};

View File

@@ -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

View File

@@ -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}

View File

@@ -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} />

View File

@@ -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
})
}

View File

@@ -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}

View File

@@ -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} />}
>

View File

@@ -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(() => {

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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&apos;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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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],

View File

@@ -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) => {

View File

@@ -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: [

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>