Compare commits

...

62 Commits

Author SHA1 Message Date
4fef5c305d misc: added missing project cert endpoints to open api spec 2025-07-01 18:53:13 +08:00
30f3543850 Merge pull request #3876 from Infisical/ENG-2977
feat(secret-sync): Allow custom field label on 1pass sync
2025-06-30 23:36:22 -04:00
114915f913 Merge pull request #3891 from Infisical/change-request-page-improvements
improvement(secret-approval-request): Color/layout styling adjustments to change request page
2025-06-30 19:35:40 -07:00
b5801af9a8 improvements: address feedback 2025-06-30 18:32:36 -07:00
20366a8c07 improvement: address feedback 2025-06-30 18:09:50 -07:00
447e28511c improvement: update stale/conflict text 2025-06-30 16:44:29 -07:00
650ed656e3 improvement: color/layout styling adjustments to change request page 2025-06-30 16:30:37 -07:00
3871fa552c Merge pull request #3888 from Infisical/revert-3885-misc/add-indices-for-referencing-columns-in-identity-access-token
Revert "misc: add indices for referencing columns in identity access token"
2025-06-30 17:27:31 -04:00
9c72ee7f10 Revert "misc: add indices for referencing columns in identity access token" 2025-07-01 05:23:51 +08:00
22e8617661 Merge pull request #3885 from Infisical/misc/add-indices-for-referencing-columns-in-identity-access-token
misc: add indices for referencing columns in identity access token
2025-06-30 17:01:20 -04:00
2f29a513cc misc: make index creation concurrently 2025-07-01 03:36:55 +08:00
d3833c33b3 Merge pull request #3878 from Infisical/fix-approval-policy-bypassing
Fix bypassing approval policies
2025-06-30 13:37:28 -04:00
978a3e5828 misc: add indices for referencing columns in identity access token 2025-07-01 01:25:11 +08:00
27bf91e58f Merge pull request #3873 from Infisical/org-access-control-improvements
improvement(org-access-control): Standardize and improve org access control UI
2025-06-30 09:54:42 -07:00
f2c3c76c60 improvement: address feedback on remove rule policy edit 2025-06-30 09:21:00 -07:00
85023916e4 improvement: address feedback 2025-06-30 09:12:47 -07:00
02afd6a8e7 Merge pull request #3882 from Infisical/feat/fix-access-token-ips
feat: resolved inefficient join for ip restriction in access token
2025-06-30 21:22:28 +05:30
=
929eac4350 feat: resolved inefficient join for ip restriction in access token 2025-06-30 20:13:26 +05:30
c6074dd69a Merge pull request #3881 from Infisical/docs-update
update spend policy
2025-06-29 18:10:54 -07:00
a9b26755ba update spend policy 2025-06-29 17:43:05 -07:00
033e5d3f81 Merge pull request #3880 from Infisical/docs-update
update logos in docs
2025-06-28 16:38:05 -07:00
90634e1913 update logos in docs 2025-06-28 16:26:58 -07:00
58b61a861a Fix bypassing approval policies 2025-06-28 04:17:09 -04:00
3c8ec7d7fb Merge pull request #3869 from Infisical/sequence-approval-policy-ui-additions
improvement(access-policies): Revamp approval sequence table display and access request modal
2025-06-28 04:07:41 -04:00
26a59286c5 Merge pull request #3877 from Infisical/remove-datadog-logs
Remove debug logs for DataDog stream
2025-06-28 03:45:14 -04:00
392792bb1e Remove debug logs for DataDog stream 2025-06-28 03:37:32 -04:00
d79a6b8f25 Lint fixes 2025-06-28 03:35:52 -04:00
217a09c97b Docs 2025-06-28 03:14:45 -04:00
48f40ff938 improvement: address feedback 2025-06-27 21:00:48 -07:00
969896e431 Merge pull request #3874 from Infisical/remove-certauth-join
Remove cert auth left join
2025-06-27 20:41:58 -04:00
fd85da5739 set trusted ip to empty 2025-06-27 20:36:32 -04:00
2caf6ff94b remove cert auth left join 2025-06-27 20:21:28 -04:00
ed7d709a70 improvement: standardize and improve org access control 2025-06-27 15:15:12 -07:00
aff97374a9 Merge pull request #3868 from Infisical/misc/add-mention-of-service-usage-api-for-gcp
misc: add mention of service usage API for GCP
2025-06-28 04:26:21 +08:00
e8e90585ca Merge pull request #3871 from Infisical/project-role-type-col
improvement(project-roles): Add type col to project roles table and default sort
2025-06-27 11:42:47 -07:00
abd9dbf714 improvement: add type col to project roles table and default sort 2025-06-27 11:34:54 -07:00
89aed3640b Merge pull request #3852 from akhilmhdh/feat/tls-identity-auth
feat: TLS cert identity auth
2025-06-28 02:29:25 +08:00
5513ff7631 Merge pull request #3866 from Infisical/feat/posthogEventBatch
feat(telemetry): Add aggregated events and groups to posthog
2025-06-27 14:42:55 -03:00
9fb7676739 misc: reordered doc for mi auth 2025-06-28 01:35:46 +08:00
6ac734d6c4 removed unnecessary changes 2025-06-28 01:32:53 +08:00
8044999785 feat(telemetry): increase even redis key exp to 15 mins 2025-06-27 14:31:54 -03:00
be51e4372d feat(telemetry): addressed PR suggestions 2025-06-27 14:30:31 -03:00
460b545925 Merge branch 'feat/tls-identity-auth' of https://github.com/akhilmhdh/infisical into HEAD 2025-06-28 01:29:49 +08:00
2f26c1930b misc: doc updates 2025-06-28 01:26:24 +08:00
953cc3a850 improvements: revise approval sequence table display and access request modal 2025-06-27 09:30:11 -07:00
fc9ae05f89 misc: updated TLS acronym 2025-06-28 00:21:08 +08:00
de22a3c56b misc: updated casing of acronym 2025-06-28 00:17:42 +08:00
7c4baa6fd4 misc: added image for service usage API 2025-06-27 13:19:14 +00:00
f285648c95 misc: add mention of service usage API for GCP 2025-06-27 21:10:02 +08:00
0f04890d8f feat(telemetry): addressed PR suggestions 2025-06-26 21:18:07 -03:00
61274243e2 feat(telemetry): add batch events and groups logic 2025-06-26 20:58:01 -03:00
9af5a66bab feat(secret-sync): Allow custom field label on 1pass sync 2025-06-26 16:07:08 -04:00
=
e33f34ceb4 fix: corrected the doc key 2025-06-25 14:46:13 +05:30
=
af5805a5ca feat: resolved incorrect invalidation 2025-06-25 14:46:13 +05:30
bcf1c49a1b Update docs/documentation/platform/identities/tls-cert-auth.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-25 14:45:14 +05:30
84fedf8eda Update docs/documentation/platform/identities/tls-cert-auth.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-25 14:44:45 +05:30
97755981eb Update docs/documentation/platform/identities/tls-cert-auth.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-25 14:43:01 +05:30
8291663802 Update frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-25 14:42:24 +05:30
d9aed45504 Update frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-25 14:42:11 +05:30
=
8ada11edf3 feat: docs for tls cert auth 2025-06-25 14:27:04 +05:30
=
4bd62aa462 feat: updated frontend to have the tls cert auth login 2025-06-25 14:26:55 +05:30
=
b80b77ec36 feat: completed backend changes for tls auth 2025-06-24 16:46:46 +05:30
101 changed files with 3657 additions and 896 deletions

View File

@ -8,6 +8,9 @@ import { Lock } from "@app/lib/red-lock";
export const mockKeyStore = (): TKeyStoreFactory => {
const store: Record<string, string | number | Buffer> = {};
const getRegex = (pattern: string) =>
new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
return {
setItem: async (key, value) => {
store[key] = value;
@ -23,7 +26,7 @@ export const mockKeyStore = (): TKeyStoreFactory => {
return 1;
},
deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => {
const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
const regex = getRegex(pattern);
let totalDeleted = 0;
const keys = Object.keys(store);
@ -53,6 +56,27 @@ export const mockKeyStore = (): TKeyStoreFactory => {
incrementBy: async () => {
return 1;
},
getItems: async (keys) => {
const values = keys.map((key) => {
const value = store[key];
if (typeof value === "string") {
return value;
}
return null;
});
return values;
},
getKeysByPattern: async (pattern) => {
const regex = getRegex(pattern);
const keys = Object.keys(store);
return keys.filter((key) => regex.test(key));
},
deleteItemsByKeyIn: async (keys) => {
for (const key of keys) {
delete store[key];
}
return keys.length;
},
acquireLock: () => {
return Promise.resolve({
release: () => {}

View File

@ -74,6 +74,7 @@ import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-a
import { TIdentityOciAuthServiceFactory } from "@app/services/identity-oci-auth/identity-oci-auth-service";
import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service";
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types";
import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service";
import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service";
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
@ -218,6 +219,7 @@ declare module "fastify" {
identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory;
identityGcpAuth: TIdentityGcpAuthServiceFactory;
identityAliCloudAuth: TIdentityAliCloudAuthServiceFactory;
identityTlsCertAuth: TIdentityTlsCertAuthServiceFactory;
identityAwsAuth: TIdentityAwsAuthServiceFactory;
identityAzureAuth: TIdentityAzureAuthServiceFactory;
identityOciAuth: TIdentityOciAuthServiceFactory;

View File

@ -164,6 +164,9 @@ import {
TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate,
TIdentityTlsCertAuths,
TIdentityTlsCertAuthsInsert,
TIdentityTlsCertAuthsUpdate,
TIdentityTokenAuths,
TIdentityTokenAuthsInsert,
TIdentityTokenAuthsUpdate,
@ -794,6 +797,11 @@ declare module "knex/types/tables" {
TIdentityAlicloudAuthsInsert,
TIdentityAlicloudAuthsUpdate
>;
[TableName.IdentityTlsCertAuth]: KnexOriginal.CompositeTableType<
TIdentityTlsCertAuths,
TIdentityTlsCertAuthsInsert,
TIdentityTlsCertAuthsUpdate
>;
[TableName.IdentityAwsAuth]: KnexOriginal.CompositeTableType<
TIdentityAwsAuths,
TIdentityAwsAuthsInsert,

View File

@ -0,0 +1,28 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityTlsCertAuth))) {
await knex.schema.createTable(TableName.IdentityTlsCertAuth, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable();
t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable();
t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable();
t.jsonb("accessTokenTrustedIps").notNullable();
t.timestamps(true, true, true);
t.uuid("identityId").notNullable().unique();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.string("allowedCommonNames").nullable();
t.binary("encryptedCaCertificate").notNullable();
});
}
await createOnUpdateTrigger(knex, TableName.IdentityTlsCertAuth);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityTlsCertAuth);
await dropOnUpdateTrigger(knex, TableName.IdentityTlsCertAuth);
}

View File

@ -0,0 +1,27 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const IdentityTlsCertAuthsSchema = z.object({
id: z.string().uuid(),
accessTokenTTL: z.coerce.number().default(7200),
accessTokenMaxTTL: z.coerce.number().default(7200),
accessTokenNumUsesLimit: z.coerce.number().default(0),
accessTokenTrustedIps: z.unknown(),
createdAt: z.date(),
updatedAt: z.date(),
identityId: z.string().uuid(),
allowedCommonNames: z.string().nullable().optional(),
encryptedCaCertificate: zodBuffer
});
export type TIdentityTlsCertAuths = z.infer<typeof IdentityTlsCertAuthsSchema>;
export type TIdentityTlsCertAuthsInsert = Omit<z.input<typeof IdentityTlsCertAuthsSchema>, TImmutableDBKeys>;
export type TIdentityTlsCertAuthsUpdate = Partial<Omit<z.input<typeof IdentityTlsCertAuthsSchema>, TImmutableDBKeys>>;

View File

@ -52,6 +52,7 @@ export * from "./identity-org-memberships";
export * from "./identity-project-additional-privilege";
export * from "./identity-project-membership-role";
export * from "./identity-project-memberships";
export * from "./identity-tls-cert-auths";
export * from "./identity-token-auths";
export * from "./identity-ua-client-secrets";
export * from "./identity-universal-auths";

View File

@ -86,6 +86,7 @@ export enum TableName {
IdentityOidcAuth = "identity_oidc_auths",
IdentityJwtAuth = "identity_jwt_auths",
IdentityLdapAuth = "identity_ldap_auths",
IdentityTlsCertAuth = "identity_tls_cert_auths",
IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role",
@ -251,6 +252,7 @@ export enum IdentityAuthMethod {
ALICLOUD_AUTH = "alicloud-auth",
AWS_AUTH = "aws-auth",
AZURE_AUTH = "azure-auth",
TLS_CERT_AUTH = "tls-cert-auth",
OCI_AUTH = "oci-auth",
OIDC_AUTH = "oidc-auth",
JWT_AUTH = "jwt-auth",

View File

@ -80,6 +80,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SignSshKey,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
certificateTemplateId: req.body.certificateTemplateId,
principals: req.body.principals,
@ -171,6 +172,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueSshCreds,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
certificateTemplateId: req.body.certificateTemplateId,
principals: req.body.principals,

View File

@ -358,6 +358,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueSshHostUserCert,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
sshHostId: req.params.sshHostId,
hostname: host.hostname,
@ -427,6 +428,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueSshHostHostCert,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
sshHostId: req.params.sshHostId,

View File

@ -350,6 +350,12 @@ export const accessApprovalRequestServiceFactory = ({
const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId);
const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass);
// Calculate break glass attempt before sequence checks
const isBreakGlassApprovalAttempt =
policy.enforcementLevel === EnforcementLevel.Soft &&
actorId === accessApprovalRequest.requestedByUserId &&
status === ApprovalStatus.APPROVED;
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
// If user is (not an approver OR cant self approve) AND can't bypass policy
if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) {
@ -409,15 +415,14 @@ export const accessApprovalRequestServiceFactory = ({
const isApproverOfTheSequence = policy.approvers.find(
(el) => el.sequence === presentSequence.step && el.userId === actorId
);
if (!isApproverOfTheSequence) throw new BadRequestError({ message: "You are not reviewer in this step" });
// Only throw if actor is not the approver and not bypassing
if (!isApproverOfTheSequence && !isBreakGlassApprovalAttempt) {
throw new BadRequestError({ message: "You are not a reviewer in this step" });
}
}
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
const isBreakGlassApprovalAttempt =
policy.enforcementLevel === EnforcementLevel.Soft &&
actorId === accessApprovalRequest.requestedByUserId &&
status === ApprovalStatus.APPROVED;
let reviewForThisActorProcessing: {
id: string;
requestId: string;

View File

@ -131,7 +131,6 @@ export const auditLogQueueServiceFactory = async ({
});
try {
logger.info(`Streaming audit log [url=${url}] for org [orgId=${orgId}]`);
const response = await request.post(
url,
{ ...providerSpecificPayload(url), ...auditLog },
@ -143,9 +142,6 @@ export const auditLogQueueServiceFactory = async ({
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
);
logger.info(
`Successfully streamed audit log [url=${url}] for org [orgId=${orgId}] [response=${JSON.stringify(response.data)}]`
);
return response;
} catch (error) {
logger.error(
@ -237,7 +233,6 @@ export const auditLogQueueServiceFactory = async ({
});
try {
logger.info(`Streaming audit log [url=${url}] for org [orgId=${orgId}]`);
const response = await request.post(
url,
{ ...providerSpecificPayload(url), ...auditLog },
@ -249,9 +244,6 @@ export const auditLogQueueServiceFactory = async ({
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
);
logger.info(
`Successfully streamed audit log [url=${url}] for org [orgId=${orgId}] [response=${JSON.stringify(response.data)}]`
);
return response;
} catch (error) {
logger.error(

View File

@ -202,6 +202,12 @@ export enum EventType {
REVOKE_IDENTITY_ALICLOUD_AUTH = "revoke-identity-alicloud-auth",
GET_IDENTITY_ALICLOUD_AUTH = "get-identity-alicloud-auth",
LOGIN_IDENTITY_TLS_CERT_AUTH = "login-identity-tls-cert-auth",
ADD_IDENTITY_TLS_CERT_AUTH = "add-identity-tls-cert-auth",
UPDATE_IDENTITY_TLS_CERT_AUTH = "update-identity-tls-cert-auth",
REVOKE_IDENTITY_TLS_CERT_AUTH = "revoke-identity-tls-cert-auth",
GET_IDENTITY_TLS_CERT_AUTH = "get-identity-tls-cert-auth",
LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth",
ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth",
UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth",
@ -1141,6 +1147,53 @@ interface GetIdentityAliCloudAuthEvent {
};
}
interface LoginIdentityTlsCertAuthEvent {
type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH;
metadata: {
identityId: string;
identityTlsCertAuthId: string;
identityAccessTokenId: string;
};
}
interface AddIdentityTlsCertAuthEvent {
type: EventType.ADD_IDENTITY_TLS_CERT_AUTH;
metadata: {
identityId: string;
allowedCommonNames: string | null | undefined;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: Array<TIdentityTrustedIp>;
};
}
interface DeleteIdentityTlsCertAuthEvent {
type: EventType.REVOKE_IDENTITY_TLS_CERT_AUTH;
metadata: {
identityId: string;
};
}
interface UpdateIdentityTlsCertAuthEvent {
type: EventType.UPDATE_IDENTITY_TLS_CERT_AUTH;
metadata: {
identityId: string;
allowedCommonNames: string | null | undefined;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
};
}
interface GetIdentityTlsCertAuthEvent {
type: EventType.GET_IDENTITY_TLS_CERT_AUTH;
metadata: {
identityId: string;
};
}
interface LoginIdentityOciAuthEvent {
type: EventType.LOGIN_IDENTITY_OCI_AUTH;
metadata: {
@ -3358,6 +3411,11 @@ export type Event =
| UpdateIdentityAliCloudAuthEvent
| GetIdentityAliCloudAuthEvent
| DeleteIdentityAliCloudAuthEvent
| LoginIdentityTlsCertAuthEvent
| AddIdentityTlsCertAuthEvent
| UpdateIdentityTlsCertAuthEvent
| GetIdentityTlsCertAuthEvent
| DeleteIdentityTlsCertAuthEvent
| LoginIdentityOciAuthEvent
| AddIdentityOciAuthEvent
| UpdateIdentityOciAuthEvent

View File

@ -73,6 +73,7 @@ type TWaitTillReady = {
export type TKeyStoreFactory = {
setItem: (key: string, value: string | number | Buffer, prefix?: string) => Promise<"OK">;
getItem: (key: string, prefix?: string) => Promise<string | null>;
getItems: (keys: string[], prefix?: string) => Promise<(string | null)[]>;
setExpiry: (key: string, expiryInSeconds: number) => Promise<number>;
setItemWithExpiry: (
key: string,
@ -81,6 +82,7 @@ export type TKeyStoreFactory = {
prefix?: string
) => Promise<"OK">;
deleteItem: (key: string) => Promise<number>;
deleteItemsByKeyIn: (keys: string[]) => Promise<number>;
deleteItems: (arg: TDeleteItems) => Promise<number>;
incrementBy: (key: string, value: number) => Promise<number>;
acquireLock(
@ -89,6 +91,7 @@ export type TKeyStoreFactory = {
settings?: Partial<Settings>
): Promise<{ release: () => Promise<ExecutionResult> }>;
waitTillReady: ({ key, waitingCb, keyCheckCb, waitIteration, delay, jitter }: TWaitTillReady) => Promise<void>;
getKeysByPattern: (pattern: string, limit?: number) => Promise<string[]>;
};
export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => {
@ -100,6 +103,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key);
const getItems = async (keys: string[], prefix?: string) =>
redis.mget(keys.map((key) => (prefix ? `${prefix}:${key}` : key)));
const setItemWithExpiry = async (
key: string,
expiryInSeconds: number | string,
@ -109,6 +115,11 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
const deleteItem = async (key: string) => redis.del(key);
const deleteItemsByKeyIn = async (keys: string[]) => {
if (keys.length === 0) return 0;
return redis.del(keys);
};
const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => {
let cursor = "0";
let totalDeleted = 0;
@ -164,6 +175,24 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
}
};
const getKeysByPattern = async (pattern: string, limit?: number) => {
let cursor = "0";
const allKeys: string[] = [];
do {
// eslint-disable-next-line no-await-in-loop
const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000);
cursor = nextCursor;
allKeys.push(...keys);
if (limit && allKeys.length >= limit) {
return allKeys.slice(0, limit);
}
} while (cursor !== "0");
return allKeys;
};
return {
setItem,
getItem,
@ -175,6 +204,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
acquireLock(resources: string[], duration: number, settings?: Partial<Settings>) {
return redisLock.acquire(resources, duration, settings);
},
waitTillReady
waitTillReady,
getKeysByPattern,
deleteItemsByKeyIn,
getItems
};
};

View File

@ -8,6 +8,8 @@ import { TKeyStoreFactory } from "./keystore";
export const inMemoryKeyStore = (): TKeyStoreFactory => {
const store: Record<string, string | number | Buffer> = {};
const getRegex = (pattern: string) =>
new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
return {
setItem: async (key, value) => {
@ -24,7 +26,7 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => {
return 1;
},
deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => {
const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
const regex = getRegex(pattern);
let totalDeleted = 0;
const keys = Object.keys(store);
@ -59,6 +61,27 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => {
release: () => {}
}) as Promise<Lock>;
},
waitTillReady: async () => {}
waitTillReady: async () => {},
getKeysByPattern: async (pattern) => {
const regex = getRegex(pattern);
const keys = Object.keys(store);
return keys.filter((key) => regex.test(key));
},
deleteItemsByKeyIn: async (keys) => {
for (const key of keys) {
delete store[key];
}
return keys.length;
},
getItems: async (keys) => {
const values = keys.map((key) => {
const value = store[key];
if (typeof value === "string") {
return value;
}
return null;
});
return values;
}
};
};

View File

@ -22,6 +22,7 @@ export enum ApiDocsTags {
UniversalAuth = "Universal Auth",
GcpAuth = "GCP Auth",
AliCloudAuth = "Alibaba Cloud Auth",
TlsCertAuth = "TLS Certificate Auth",
AwsAuth = "AWS Auth",
OciAuth = "OCI Auth",
AzureAuth = "Azure Auth",
@ -283,6 +284,38 @@ export const ALICLOUD_AUTH = {
}
} as const;
export const TLS_CERT_AUTH = {
LOGIN: {
identityId: "The ID of the identity to login."
},
ATTACH: {
identityId: "The ID of the identity to attach the configuration onto.",
allowedCommonNames:
"The comma-separated list of trusted common names that are allowed to authenticate with Infisical.",
caCertificate: "The PEM-encoded CA certificate to validate client certificates.",
accessTokenTTL: "The lifetime for an access token in seconds.",
accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.",
accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.",
accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from."
},
UPDATE: {
identityId: "The ID of the identity to update the auth method for.",
allowedCommonNames:
"The comma-separated list of trusted common names that are allowed to authenticate with Infisical.",
caCertificate: "The PEM-encoded CA certificate to validate client certificates.",
accessTokenTTL: "The new lifetime for an access token in seconds.",
accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.",
accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from."
},
RETRIEVE: {
identityId: "The ID of the identity to retrieve the auth method for."
},
REVOKE: {
identityId: "The ID of the identity to revoke the auth method for."
}
} as const;
export const AWS_AUTH = {
LOGIN: {
identityId: "The ID of the identity to login.",
@ -2394,7 +2427,8 @@ export const SecretSyncs = {
keyOcid: "The OCID (Oracle Cloud Identifier) of the encryption key to use when creating secrets in the vault."
},
ONEPASS: {
vaultId: "The ID of the 1Password vault to sync secrets to."
vaultId: "The ID of the 1Password vault to sync secrets to.",
valueLabel: "The label of the entry that holds the secret value."
},
HEROKU: {
app: "The ID of the Heroku app to sync secrets to.",

View File

@ -193,6 +193,9 @@ const envSchema = z
PYLON_API_KEY: zpStr(z.string().optional()),
DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"),
SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"),
IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default(
"x-identity-tls-cert-auth-client-cert"
),
WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()),
WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional()),
ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT: zodStrBool.default("true"),

View File

@ -62,7 +62,8 @@ export enum QueueName {
SecretRotationV2 = "secret-rotation-v2",
FolderTreeCheckpoint = "folder-tree-checkpoint",
InvalidateCache = "invalidate-cache",
SecretScanningV2 = "secret-scanning-v2"
SecretScanningV2 = "secret-scanning-v2",
TelemetryAggregatedEvents = "telemetry-aggregated-events"
}
export enum QueueJobs {
@ -101,7 +102,8 @@ export enum QueueJobs {
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal"
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
TelemetryAggregatedEvents = "telemetry-aggregated-events"
}
export type TQueueJobTypes = {
@ -292,6 +294,10 @@ export type TQueueJobTypes = {
name: QueueJobs.PkiSubscriberDailyAutoRenewal;
payload: undefined;
};
[QueueName.TelemetryAggregatedEvents]: {
name: QueueJobs.TelemetryAggregatedEvents;
payload: undefined;
};
};
const SECRET_SCANNING_JOBS = [

View File

@ -193,6 +193,8 @@ import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth
import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal";
import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal";
import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service";
import { identityTokenAuthDALFactory } from "@app/services/identity-token-auth/identity-token-auth-dal";
import { identityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service";
import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal";
@ -384,6 +386,7 @@ export const registerRoutes = async (
const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db);
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
const identityAliCloudAuthDAL = identityAliCloudAuthDALFactory(db);
const identityTlsCertAuthDAL = identityTlsCertAuthDALFactory(db);
const identityAwsAuthDAL = identityAwsAuthDALFactory(db);
const identityGcpAuthDAL = identityGcpAuthDALFactory(db);
const identityOciAuthDAL = identityOciAuthDALFactory(db);
@ -684,7 +687,8 @@ export const registerRoutes = async (
const telemetryQueue = telemetryQueueServiceFactory({
keyStore,
telemetryDAL,
queueService
queueService,
telemetryService
});
const invalidateCacheQueue = invalidateCacheQueueFactory({
@ -1415,7 +1419,8 @@ export const registerRoutes = async (
const identityAccessTokenService = identityAccessTokenServiceFactory({
identityAccessTokenDAL,
identityOrgMembershipDAL,
accessTokenQueue
accessTokenQueue,
identityDAL
});
const identityProjectService = identityProjectServiceFactory({
@ -1491,6 +1496,15 @@ export const registerRoutes = async (
permissionService
});
const identityTlsCertAuthService = identityTlsCertAuthServiceFactory({
identityAccessTokenDAL,
identityTlsCertAuthDAL,
identityOrgMembershipDAL,
licenseService,
permissionService,
kmsService
});
const identityAwsAuthService = identityAwsAuthServiceFactory({
identityAccessTokenDAL,
identityAwsAuthDAL,
@ -1945,6 +1959,7 @@ export const registerRoutes = async (
identityAwsAuth: identityAwsAuthService,
identityAzureAuth: identityAzureAuthService,
identityOciAuth: identityOciAuthService,
identityTlsCertAuth: identityTlsCertAuthService,
identityOidcAuth: identityOidcAuthService,
identityJwtAuth: identityJwtAuthService,
identityLdapAuth: identityLdapAuthService,

View File

@ -722,6 +722,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.InvalidateCache,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
...req.auditLogInfo

View File

@ -692,6 +692,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
caId: ca.id,
@ -786,6 +787,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SignCert,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
caId: ca.id,

View File

@ -266,6 +266,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
caId: req.body.caId,
certificateTemplateId: req.body.certificateTemplateId,
@ -442,6 +443,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SignCert,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
caId: req.body.caId,
certificateTemplateId: req.body.certificateTemplateId,

View File

@ -475,6 +475,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secretCountFromEnv,
workspaceId: projectId,
@ -979,6 +980,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secretCount,
workspaceId: projectId,
@ -1144,6 +1146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secretCountForEnv,
workspaceId: projectId,
@ -1336,6 +1339,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: projectId,

View File

@ -85,6 +85,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.MachineIdentityCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
orgId: req.body.organizationId,
name: identity.name,

View File

@ -0,0 +1,396 @@
import crypto from "node:crypto";
import { z } from "zod";
import { IdentityTlsCertAuthsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, TLS_CERT_AUTH } from "@app/lib/api-docs";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns";
const validateCommonNames = z
.string()
.min(1)
.trim()
.transform((el) =>
el
.split(",")
.map((i) => i.trim())
.join(",")
);
const validateCaCertificate = (caCert: string) => {
if (!caCert) return true;
try {
// eslint-disable-next-line no-new
new crypto.X509Certificate(caCert);
return true;
} catch (err) {
return false;
}
};
export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/login",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.TlsCertAuth],
description: "Login with TLS Certificate Auth",
body: z.object({
identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId)
}),
response: {
200: z.object({
accessToken: z.string(),
expiresIn: z.coerce.number(),
accessTokenMaxTTL: z.coerce.number(),
tokenType: z.literal("Bearer")
})
}
},
handler: async (req) => {
const appCfg = getConfig();
const clientCertificate = req.headers[appCfg.IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY];
if (!clientCertificate) {
throw new BadRequestError({ message: "Missing TLS certificate in header" });
}
const { identityTlsCertAuth, accessToken, identityAccessToken, identityMembershipOrg } =
await server.services.identityTlsCertAuth.login({
identityId: req.body.identityId,
clientCertificate: clientCertificate as string
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: identityMembershipOrg?.orgId,
event: {
type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH,
metadata: {
identityId: identityTlsCertAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
identityTlsCertAuthId: identityTlsCertAuth.id
}
}
});
return {
accessToken,
tokenType: "Bearer" as const,
expiresIn: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL
};
}
});
server.route({
method: "POST",
url: "/identities/:identityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.TlsCertAuth],
description: "Attach TLS Certificate Auth configuration onto identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string().trim().describe(TLS_CERT_AUTH.ATTACH.identityId)
}),
body: z
.object({
allowedCommonNames: validateCommonNames
.optional()
.nullable()
.describe(TLS_CERT_AUTH.ATTACH.allowedCommonNames),
caCertificate: z
.string()
.min(1)
.max(10240)
.refine(validateCaCertificate, "Invalid CA Certificate.")
.describe(TLS_CERT_AUTH.ATTACH.caCertificate),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
.describe(TLS_CERT_AUTH.ATTACH.accessTokenTrustedIps),
accessTokenTTL: z
.number()
.int()
.min(0)
.max(315360000)
.default(2592000)
.describe(TLS_CERT_AUTH.ATTACH.accessTokenTTL),
accessTokenMaxTTL: z
.number()
.int()
.min(1)
.max(315360000)
.default(2592000)
.describe(TLS_CERT_AUTH.ATTACH.accessTokenMaxTTL),
accessTokenNumUsesLimit: z
.number()
.int()
.min(0)
.default(0)
.describe(TLS_CERT_AUTH.ATTACH.accessTokenNumUsesLimit)
})
.refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
"Access Token TTL cannot be greater than Access Token Max TTL."
),
response: {
200: z.object({
identityTlsCertAuth: IdentityTlsCertAuthsSchema
})
}
},
handler: async (req) => {
const identityTlsCertAuth = await server.services.identityTlsCertAuth.attachTlsCertAuth({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body,
identityId: req.params.identityId,
isActorSuperAdmin: isSuperAdmin(req.auth)
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.ADD_IDENTITY_TLS_CERT_AUTH,
metadata: {
identityId: identityTlsCertAuth.identityId,
allowedCommonNames: identityTlsCertAuth.allowedCommonNames,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit
}
}
});
return { identityTlsCertAuth };
}
});
server.route({
method: "PATCH",
url: "/identities/:identityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.TlsCertAuth],
description: "Update TLS Certificate Auth configuration on identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string().describe(TLS_CERT_AUTH.UPDATE.identityId)
}),
body: z
.object({
caCertificate: z
.string()
.min(1)
.max(10240)
.refine(validateCaCertificate, "Invalid CA Certificate.")
.optional()
.describe(TLS_CERT_AUTH.UPDATE.caCertificate),
allowedCommonNames: validateCommonNames
.optional()
.nullable()
.describe(TLS_CERT_AUTH.UPDATE.allowedCommonNames),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
})
.array()
.min(1)
.optional()
.describe(TLS_CERT_AUTH.UPDATE.accessTokenTrustedIps),
accessTokenTTL: z
.number()
.int()
.min(0)
.max(315360000)
.optional()
.describe(TLS_CERT_AUTH.UPDATE.accessTokenTTL),
accessTokenNumUsesLimit: z
.number()
.int()
.min(0)
.optional()
.describe(TLS_CERT_AUTH.UPDATE.accessTokenNumUsesLimit),
accessTokenMaxTTL: z
.number()
.int()
.max(315360000)
.min(0)
.optional()
.describe(TLS_CERT_AUTH.UPDATE.accessTokenMaxTTL)
})
.refine(
(val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
"Access Token TTL cannot be greater than Access Token Max TTL."
),
response: {
200: z.object({
identityTlsCertAuth: IdentityTlsCertAuthsSchema
})
}
},
handler: async (req) => {
const identityTlsCertAuth = await server.services.identityTlsCertAuth.updateTlsCertAuth({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body,
identityId: req.params.identityId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.UPDATE_IDENTITY_TLS_CERT_AUTH,
metadata: {
identityId: identityTlsCertAuth.identityId,
allowedCommonNames: identityTlsCertAuth.allowedCommonNames,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit
}
}
});
return { identityTlsCertAuth };
}
});
server.route({
method: "GET",
url: "/identities/:identityId",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.TlsCertAuth],
description: "Retrieve TLS Certificate Auth configuration on identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string().describe(TLS_CERT_AUTH.RETRIEVE.identityId)
}),
response: {
200: z.object({
identityTlsCertAuth: IdentityTlsCertAuthsSchema.extend({
caCertificate: z.string()
})
})
}
},
handler: async (req) => {
const identityTlsCertAuth = await server.services.identityTlsCertAuth.getTlsCertAuth({
identityId: req.params.identityId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.GET_IDENTITY_TLS_CERT_AUTH,
metadata: {
identityId: identityTlsCertAuth.identityId
}
}
});
return { identityTlsCertAuth };
}
});
server.route({
method: "DELETE",
url: "/identities/:identityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.TlsCertAuth],
description: "Delete TLS Certificate Auth configuration on identity",
security: [
{
bearerAuth: []
}
],
params: z.object({
identityId: z.string().describe(TLS_CERT_AUTH.REVOKE.identityId)
}),
response: {
200: z.object({
identityTlsCertAuth: IdentityTlsCertAuthsSchema
})
}
},
handler: async (req) => {
const identityTlsCertAuth = await server.services.identityTlsCertAuth.revokeTlsCertAuth({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
identityId: req.params.identityId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.REVOKE_IDENTITY_TLS_CERT_AUTH,
metadata: {
identityId: identityTlsCertAuth.identityId
}
}
});
return { identityTlsCertAuth };
}
});
};

View File

@ -25,6 +25,7 @@ import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router";
import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router";
import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router";
import { registerIdentityRouter } from "./identity-router";
import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router";
import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router";
import { registerIdentityUaRouter } from "./identity-universal-auth-router";
import { registerIntegrationAuthRouter } from "./integration-auth-router";
@ -66,6 +67,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await authRouter.register(registerIdentityAccessTokenRouter);
await authRouter.register(registerIdentityAliCloudAuthRouter);
await authRouter.register(registerIdentityAwsAuthRouter);
await authRouter.register(registerIdentityTlsCertAuthRouter, { prefix: "/tls-cert-auth" });
await authRouter.register(registerIdentityAzureAuthRouter);
await authRouter.register(registerIdentityOciAuthRouter);
await authRouter.register(registerIdentityOidcAuthRouter);

View File

@ -103,6 +103,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IntegrationCreated,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
...createIntegrationEventProperty,

View File

@ -64,6 +64,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.UserOrgInvitation,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
inviteeEmails: req.body.inviteeEmails,
organizationRoleSlug: req.body.organizationRoleSlug,

View File

@ -331,6 +331,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
subscriberId: subscriber.id,
@ -399,6 +400,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
subscriberId: subscriber.id,
commonName: subscriber.commonName,
@ -471,6 +473,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SignCert,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
subscriberId: subscriber.id,
commonName: subscriber.commonName,

View File

@ -165,6 +165,7 @@ export const registerSecretRequestsRouter = async (server: FastifyZodProvider) =
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretRequestDeleted,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
secretRequestId: req.params.id,
organizationId: req.permission.orgId,
@ -256,6 +257,7 @@ export const registerSecretRequestsRouter = async (server: FastifyZodProvider) =
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretRequestCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
secretRequestId: shareRequest.id,
organizationId: req.permission.orgId,

View File

@ -198,6 +198,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.ProjectCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
orgId: project.orgId,
name: project.name,
@ -456,6 +457,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAlerting],
params: z.object({
projectId: z.string().trim()
}),
@ -486,6 +489,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateCollections],
params: z.object({
projectId: z.string().trim()
}),
@ -548,6 +553,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
projectId: z.string().trim()
}),

View File

@ -333,6 +333,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId,
@ -489,6 +490,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: 1,
@ -615,6 +617,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: projectId,
@ -750,6 +753,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: projectId,
@ -850,6 +854,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: projectId,
@ -957,6 +962,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: req.query.workspaceId,
@ -1036,6 +1042,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: req.query.workspaceId,
@ -1207,6 +1214,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: req.body.workspaceId,
@ -1396,6 +1404,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: req.body.workspaceId,
@ -1519,6 +1528,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: 1,
workspaceId: req.body.workspaceId,
@ -1702,6 +1712,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: req.body.workspaceId,
@ -1828,6 +1839,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: req.body.workspaceId,
@ -1946,6 +1958,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: req.body.workspaceId,
@ -2054,6 +2067,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: secrets[0].workspace,
@ -2209,6 +2223,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: secrets[0].workspace,
@ -2307,6 +2322,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: {
numberOfSecrets: secrets.length,
workspaceId: secrets[0].workspace,

View File

@ -1,3 +1,4 @@
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
@ -19,6 +20,7 @@ export const gcpConnectionService = (getAppConnection: TGetAppConnectionFunc) =>
return projects;
} catch (error) {
logger.error(error, "Error listing GCP secret manager projects");
return [];
}
};

View File

@ -17,70 +17,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
const doc = await (tx || db.replicaNode())(TableName.IdentityAccessToken)
.where(filter)
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`)
.leftJoin(
TableName.IdentityUaClientSecret,
`${TableName.IdentityAccessToken}.identityUAClientSecretId`,
`${TableName.IdentityUaClientSecret}.id`
)
.leftJoin(
TableName.IdentityUniversalAuth,
`${TableName.IdentityUaClientSecret}.identityUAId`,
`${TableName.IdentityUniversalAuth}.id`
)
.leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`)
.leftJoin(
TableName.IdentityAliCloudAuth,
`${TableName.Identity}.id`,
`${TableName.IdentityAliCloudAuth}.identityId`
)
.leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`)
.leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`)
.leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`)
.leftJoin(
TableName.IdentityKubernetesAuth,
`${TableName.Identity}.id`,
`${TableName.IdentityKubernetesAuth}.identityId`
)
.leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`)
.leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`)
.leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`)
.leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`)
.select(selectAllTableCols(TableName.IdentityAccessToken))
.select(
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"),
db
.ref("accessTokenTrustedIps")
.withSchema(TableName.IdentityAliCloudAuth)
.as("accessTokenTrustedIpsAliCloud"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOciAuth).as("accessTokenTrustedIpsOci"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityLdapAuth).as("accessTokenTrustedIpsLdap"),
db.ref("name").withSchema(TableName.Identity)
)
.select(db.ref("name").withSchema(TableName.Identity))
.first();
if (!doc) return;
return {
...doc,
trustedIpsUniversalAuth: doc.accessTokenTrustedIpsUa,
trustedIpsGcpAuth: doc.accessTokenTrustedIpsGcp,
trustedIpsAliCloudAuth: doc.accessTokenTrustedIpsAliCloud,
trustedIpsAwsAuth: doc.accessTokenTrustedIpsAws,
trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure,
trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s,
trustedIpsOciAuth: doc.accessTokenTrustedIpsOci,
trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc,
trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken,
trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt,
trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap
};
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "IdAccessTokenFindOne" });
}

View File

@ -7,12 +7,14 @@ import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip";
import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue";
import { AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal";
import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types";
type TIdentityAccessTokenServiceFactoryDep = {
identityAccessTokenDAL: TIdentityAccessTokenDALFactory;
identityDAL: Pick<TIdentityDALFactory, "getTrustedIpsByAuthMethod">;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
accessTokenQueue: Pick<
TAccessTokenQueueServiceFactory,
@ -25,7 +27,8 @@ export type TIdentityAccessTokenServiceFactory = ReturnType<typeof identityAcces
export const identityAccessTokenServiceFactory = ({
identityAccessTokenDAL,
identityOrgMembershipDAL,
accessTokenQueue
accessTokenQueue,
identityDAL
}: TIdentityAccessTokenServiceFactoryDep) => {
const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => {
const {
@ -190,23 +193,11 @@ export const identityAccessTokenServiceFactory = ({
message: "Failed to authorize revoked access token, access token is revoked"
});
const trustedIpsMap: Record<IdentityAuthMethod, unknown> = {
[IdentityAuthMethod.UNIVERSAL_AUTH]: identityAccessToken.trustedIpsUniversalAuth,
[IdentityAuthMethod.GCP_AUTH]: identityAccessToken.trustedIpsGcpAuth,
[IdentityAuthMethod.ALICLOUD_AUTH]: identityAccessToken.trustedIpsAliCloudAuth,
[IdentityAuthMethod.AWS_AUTH]: identityAccessToken.trustedIpsAwsAuth,
[IdentityAuthMethod.OCI_AUTH]: identityAccessToken.trustedIpsOciAuth,
[IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth,
[IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth,
[IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth,
[IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth,
[IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth,
[IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth
};
const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod];
if (ipAddress) {
const trustedIps = await identityDAL.getTrustedIpsByAuthMethod(
identityAccessToken.identityId,
identityAccessToken.authMethod as IdentityAuthMethod
);
if (ipAddress && trustedIps) {
checkIPAgainstBlocklist({
ipAddress,
trustedIps: trustedIps as TIp[]

View File

@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify, TOrmify } from "@app/lib/knex";
export type TIdentityTlsCertAuthDALFactory = TOrmify<TableName.IdentityTlsCertAuth>;
export const identityTlsCertAuthDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.IdentityTlsCertAuth);
return orm;
};

View File

@ -0,0 +1,423 @@
import crypto from "node:crypto";
import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken";
import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import {
constructPermissionErrorMessage,
validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns";
import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal";
import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types";
type TIdentityTlsCertAuthServiceFactoryDep = {
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "create" | "delete">;
identityTlsCertAuthDAL: Pick<
TIdentityTlsCertAuthDALFactory,
"findOne" | "transaction" | "create" | "updateById" | "delete"
>;
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
const parseSubjectDetails = (data: string) => {
const values: Record<string, string> = {};
data.split("\n").forEach((el) => {
const [key, value] = el.split("=");
values[key.trim()] = value.trim();
});
return values;
};
export const identityTlsCertAuthServiceFactory = ({
identityAccessTokenDAL,
identityTlsCertAuthDAL,
identityOrgMembershipDAL,
licenseService,
permissionService,
kmsService
}: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => {
const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => {
const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId });
if (!identityTlsCertAuth) {
throw new NotFoundError({
message: "TLS Certificate auth method not found for identity, did you configure TLS Certificate auth?"
});
}
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({
identityId: identityTlsCertAuth.identityId
});
if (!identityMembershipOrg) {
throw new NotFoundError({
message: `Identity organization membership for identity with ID '${identityTlsCertAuth.identityId}' not found`
});
}
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: identityMembershipOrg.orgId
});
const caCertificate = decryptor({
cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate
}).toString();
const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0];
if (!leafCertificate) {
throw new BadRequestError({ message: "Missing client certificate" });
}
const clientCertificateX509 = new crypto.X509Certificate(leafCertificate);
const caCertificateX509 = new crypto.X509Certificate(caCertificate);
const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
if (!isValidCertificate)
throw new UnauthorizedError({
message: "Access denied: Certificate not issued by the provided CA."
});
if (new Date(clientCertificateX509.validTo) < new Date()) {
throw new UnauthorizedError({
message: "Access denied: Certificate has expired."
});
}
if (new Date(clientCertificateX509.validFrom) > new Date()) {
throw new UnauthorizedError({
message: "Access denied: Certificate not yet valid."
});
}
const subjectDetails = parseSubjectDetails(clientCertificateX509.subject);
if (identityTlsCertAuth.allowedCommonNames) {
const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN);
if (!isValidCommonName) {
throw new UnauthorizedError({
message: "Access denied: TLS Certificate Auth common name not allowed."
});
}
}
// Generate the token
const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => {
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityTlsCertAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.TLS_CERT_AUTH
},
tx
);
return newToken;
});
const appCfg = getConfig();
const accessToken = jwt.sign(
{
identityId: identityTlsCertAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return {
identityTlsCertAuth,
accessToken,
identityAccessToken,
identityMembershipOrg
};
};
const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({
identityId,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
actorId,
actorAuthMethod,
actor,
actorOrgId,
isActorSuperAdmin,
caCertificate,
allowedCommonNames
}) => {
await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
throw new BadRequestError({
message: "Failed to add TLS Certificate Auth to already configured identity"
});
}
if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) {
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
if (
!plan.ipAllowlisting &&
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
accessTokenTrustedIp.ipAddress !== "::/0"
)
throw new BadRequestError({
message:
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: identityMembershipOrg.orgId
});
const identityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => {
const doc = await identityTlsCertAuthDAL.create(
{
identityId: identityMembershipOrg.identityId,
accessTokenMaxTTL,
allowedCommonNames,
accessTokenTTL,
encryptedCaCertificate: encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob,
accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
},
tx
);
return doc;
});
return { ...identityTlsCertAuth, orgId: identityMembershipOrg.orgId };
};
const updateTlsCertAuth: TIdentityTlsCertAuthServiceFactory["updateTlsCertAuth"] = async ({
identityId,
caCertificate,
allowedCommonNames,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
actorId,
actorAuthMethod,
actor,
actorOrgId
}) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
throw new NotFoundError({
message: "The identity does not have TLS Certificate Auth attached"
});
}
const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId });
if (
(accessTokenMaxTTL || identityTlsCertAuth.accessTokenMaxTTL) > 0 &&
(accessTokenTTL || identityTlsCertAuth.accessTokenTTL) >
(accessTokenMaxTTL || identityTlsCertAuth.accessTokenMaxTTL)
) {
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
if (
!plan.ipAllowlisting &&
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
accessTokenTrustedIp.ipAddress !== "::/0"
)
throw new BadRequestError({
message:
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: identityMembershipOrg.orgId
});
const updatedTlsCertAuth = await identityTlsCertAuthDAL.updateById(identityTlsCertAuth.id, {
allowedCommonNames,
encryptedCaCertificate: caCertificate
? encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob
: undefined,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined
});
return { ...updatedTlsCertAuth, orgId: identityMembershipOrg.orgId };
};
const getTlsCertAuth: TIdentityTlsCertAuthServiceFactory["getTlsCertAuth"] = async ({
identityId,
actorId,
actor,
actorAuthMethod,
actorOrgId
}) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
throw new BadRequestError({
message: "The identity does not have TLS Certificate Auth attached"
});
}
const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: identityMembershipOrg.orgId
});
let caCertificate = "";
if (identityAuth.encryptedCaCertificate) {
caCertificate = decryptor({ cipherTextBlob: identityAuth.encryptedCaCertificate }).toString();
}
return { ...identityAuth, caCertificate, orgId: identityMembershipOrg.orgId };
};
const revokeTlsCertAuth: TIdentityTlsCertAuthServiceFactory["revokeTlsCertAuth"] = async ({
identityId,
actorId,
actor,
actorAuthMethod,
actorOrgId
}) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
throw new BadRequestError({
message: "The identity does not have TLS Certificate auth"
});
}
const { permission, membership } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
const { permission: rolePermission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
identityMembershipOrg.identityId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
OrgPermissionIdentityActions.RevokeAuth,
OrgPermissionSubjects.Identity,
permission,
rolePermission
);
if (!permissionBoundary.isValid)
throw new PermissionBoundaryError({
message: constructPermissionErrorMessage(
"Failed to revoke TLS Certificate auth of identity with more privileged role",
membership.shouldUseNewPrivilegeSystem,
OrgPermissionIdentityActions.RevokeAuth,
OrgPermissionSubjects.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const revokedIdentityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => {
const deletedTlsCertAuth = await identityTlsCertAuthDAL.delete({ identityId }, tx);
await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.TLS_CERT_AUTH }, tx);
return { ...deletedTlsCertAuth?.[0], orgId: identityMembershipOrg.orgId };
});
return revokedIdentityTlsCertAuth;
};
return {
login,
attachTlsCertAuth,
updateTlsCertAuth,
getTlsCertAuth,
revokeTlsCertAuth
};
};

View File

@ -0,0 +1,49 @@
import { TIdentityAccessTokens, TIdentityOrgMemberships, TIdentityTlsCertAuths } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
export type TLoginTlsCertAuthDTO = {
identityId: string;
clientCertificate: string;
};
export type TAttachTlsCertAuthDTO = {
identityId: string;
caCertificate: string;
allowedCommonNames?: string | null;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: { ipAddress: string }[];
isActorSuperAdmin?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateTlsCertAuthDTO = {
identityId: string;
caCertificate?: string;
allowedCommonNames?: string | null;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TGetTlsCertAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRevokeTlsCertAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TIdentityTlsCertAuthServiceFactory = {
login: (dto: TLoginTlsCertAuthDTO) => Promise<{
identityTlsCertAuth: TIdentityTlsCertAuths;
accessToken: string;
identityAccessToken: TIdentityAccessTokens;
identityMembershipOrg: TIdentityOrgMemberships;
}>;
attachTlsCertAuth: (dto: TAttachTlsCertAuthDTO) => Promise<TIdentityTlsCertAuths>;
updateTlsCertAuth: (dto: TUpdateTlsCertAuthDTO) => Promise<TIdentityTlsCertAuths>;
revokeTlsCertAuth: (dto: TRevokeTlsCertAuthDTO) => Promise<TIdentityTlsCertAuths>;
getTlsCertAuth: (dto: TGetTlsCertAuthDTO) => Promise<TIdentityTlsCertAuths & { caCertificate: string }>;
};

View File

@ -1,5 +1,5 @@
import { TDbClient } from "@app/db";
import { TableName, TIdentities } from "@app/db/schemas";
import { IdentityAuthMethod, TableName, TIdentities } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
@ -8,6 +8,28 @@ export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>;
export const identityDALFactory = (db: TDbClient) => {
const identityOrm = ormify(db, TableName.Identity);
const getTrustedIpsByAuthMethod = async (identityId: string, authMethod: IdentityAuthMethod) => {
const authMethodToTableName = {
[IdentityAuthMethod.TOKEN_AUTH]: TableName.IdentityTokenAuth,
[IdentityAuthMethod.UNIVERSAL_AUTH]: TableName.IdentityUniversalAuth,
[IdentityAuthMethod.KUBERNETES_AUTH]: TableName.IdentityKubernetesAuth,
[IdentityAuthMethod.GCP_AUTH]: TableName.IdentityGcpAuth,
[IdentityAuthMethod.ALICLOUD_AUTH]: TableName.IdentityAliCloudAuth,
[IdentityAuthMethod.AWS_AUTH]: TableName.IdentityAwsAuth,
[IdentityAuthMethod.AZURE_AUTH]: TableName.IdentityAzureAuth,
[IdentityAuthMethod.TLS_CERT_AUTH]: TableName.IdentityTlsCertAuth,
[IdentityAuthMethod.OCI_AUTH]: TableName.IdentityOciAuth,
[IdentityAuthMethod.OIDC_AUTH]: TableName.IdentityOidcAuth,
[IdentityAuthMethod.JWT_AUTH]: TableName.IdentityJwtAuth,
[IdentityAuthMethod.LDAP_AUTH]: TableName.IdentityLdapAuth
} as const;
const tableName = authMethodToTableName[authMethod];
if (!tableName) return;
const data = await db(tableName).where({ identityId }).first();
if (!data) return;
return data.accessTokenTrustedIps;
};
const getIdentitiesByFilter = async ({
limit,
offset,
@ -38,5 +60,5 @@ export const identityDALFactory = (db: TDbClient) => {
}
};
return { ...identityOrm, getIdentitiesByFilter };
return { ...identityOrm, getTrustedIpsByAuthMethod, getIdentitiesByFilter };
};

View File

@ -11,7 +11,8 @@ export const buildAuthMethods = ({
azureId,
tokenId,
jwtId,
ldapId
ldapId,
tlsCertId
}: {
uaId?: string;
gcpId?: string;
@ -24,6 +25,7 @@ export const buildAuthMethods = ({
tokenId?: string;
jwtId?: string;
ldapId?: string;
tlsCertId?: string;
}) => {
return [
...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null],
@ -36,6 +38,7 @@ export const buildAuthMethods = ({
...[azureId ? IdentityAuthMethod.AZURE_AUTH : null],
...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null],
...[jwtId ? IdentityAuthMethod.JWT_AUTH : null],
...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null]
...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null],
...[tlsCertId ? IdentityAuthMethod.TLS_CERT_AUTH : null]
].filter((authMethod) => authMethod) as IdentityAuthMethod[];
};

View File

@ -12,6 +12,7 @@ import {
TIdentityOciAuths,
TIdentityOidcAuths,
TIdentityOrgMemberships,
TIdentityTlsCertAuths,
TIdentityTokenAuths,
TIdentityUniversalAuths,
TOrgRoles
@ -99,7 +100,11 @@ export const identityOrgDALFactory = (db: TDbClient) => {
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityLdapAuth}.identityId`
)
.leftJoin<TIdentityTlsCertAuths>(
TableName.IdentityTlsCertAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityTlsCertAuth}.identityId`
)
.select(
selectAllTableCols(TableName.IdentityOrgMembership),
@ -114,6 +119,7 @@ export const identityOrgDALFactory = (db: TDbClient) => {
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth),
db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth),
db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth),
db.ref("name").withSchema(TableName.Identity),
db.ref("hasDeleteProtection").withSchema(TableName.Identity)
);
@ -238,7 +244,11 @@ export const identityOrgDALFactory = (db: TDbClient) => {
"paginatedIdentity.identityId",
`${TableName.IdentityLdapAuth}.identityId`
)
.leftJoin<TIdentityTlsCertAuths>(
TableName.IdentityTlsCertAuth,
"paginatedIdentity.identityId",
`${TableName.IdentityTlsCertAuth}.identityId`
)
.select(
db.ref("id").withSchema("paginatedIdentity"),
db.ref("role").withSchema("paginatedIdentity"),
@ -260,7 +270,8 @@ export const identityOrgDALFactory = (db: TDbClient) => {
db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth),
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth),
db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth)
db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth),
db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth)
)
// cr stands for custom role
.select(db.ref("id").as("crId").withSchema(TableName.OrgRoles))
@ -306,6 +317,7 @@ export const identityOrgDALFactory = (db: TDbClient) => {
azureId,
tokenId,
ldapId,
tlsCertId,
createdAt,
updatedAt
}) => ({
@ -313,7 +325,6 @@ export const identityOrgDALFactory = (db: TDbClient) => {
roleId,
identityId,
id,
orgId,
createdAt,
updatedAt,
@ -341,7 +352,8 @@ export const identityOrgDALFactory = (db: TDbClient) => {
azureId,
tokenId,
jwtId,
ldapId
ldapId,
tlsCertId
})
}
}),
@ -380,7 +392,12 @@ export const identityOrgDALFactory = (db: TDbClient) => {
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`)
.where(`${TableName.IdentityOrgMembership}.orgId`, orgId)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.orderBy(`${TableName.Identity}.${orderBy}`, orderDirection)
.orderBy(
orderBy === OrgIdentityOrderBy.Role
? `${TableName.IdentityOrgMembership}.${orderBy}`
: `${TableName.Identity}.${orderBy}`,
orderDirection
)
.select(`${TableName.IdentityOrgMembership}.id`)
.select<{ id: string; total_count: string }>(
db.raw(
@ -511,6 +528,23 @@ export const identityOrgDALFactory = (db: TDbClient) => {
if (orderBy === OrgIdentityOrderBy.Name) {
void query.orderBy("identityName", orderDirection);
} else if (orderBy === OrgIdentityOrderBy.Role) {
void query.orderByRaw(
`
CASE
WHEN ??.role = ?
THEN ??.slug
ELSE ??.role
END ?
`,
[
TableName.IdentityOrgMembership,
"custom",
TableName.OrgRoles,
TableName.IdentityOrgMembership,
db.raw(orderDirection)
]
);
}
const docs = await query;

View File

@ -46,8 +46,8 @@ export type TListOrgIdentitiesByOrgIdDTO = {
} & TOrgPermission;
export enum OrgIdentityOrderBy {
Name = "name"
// Role = "role"
Name = "name",
Role = "role"
}
export type TSearchOrgIdentitiesByOrgIdDAL = {

View File

@ -6,7 +6,6 @@ import {
TOnePassListVariablesResponse,
TOnePassSyncWithCredentials,
TOnePassVariable,
TOnePassVariableDetails,
TPostOnePassVariable,
TPutOnePassVariable
} from "@app/services/secret-sync/1password/1password-sync-types";
@ -14,7 +13,10 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassListVariables) => {
// This should not be changed or it may break existing logic
const VALUE_LABEL_DEFAULT = "value";
const listOnePassItems = async ({ instanceUrl, apiToken, vaultId, valueLabel }: TOnePassListVariables) => {
const { data } = await request.get<TOnePassListVariablesResponse>(`${instanceUrl}/v1/vaults/${vaultId}/items`, {
headers: {
Authorization: `Bearer ${apiToken}`,
@ -22,36 +24,49 @@ const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassList
}
});
const result: Record<string, TOnePassVariable & { value: string; fieldId: string }> = {};
const items: Record<string, TOnePassVariable & { value: string; fieldId: string }> = {};
const duplicates: Record<string, string> = {};
for await (const s of data) {
const { data: secret } = await request.get<TOnePassVariableDetails>(
`${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: "application/json"
}
}
);
// eslint-disable-next-line no-continue
if (s.category !== "API_CREDENTIAL") continue;
const value = secret.fields.find((f) => f.label === "value")?.value;
const fieldId = secret.fields.find((f) => f.label === "value")?.id;
if (items[s.title]) {
duplicates[s.id] = s.title;
// eslint-disable-next-line no-continue
continue;
}
const { data: secret } = await request.get<TOnePassVariable>(`${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`, {
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: "application/json"
}
});
const valueField = secret.fields.find((f) => f.label === valueLabel);
// eslint-disable-next-line no-continue
if (!value || !fieldId) continue;
if (!valueField || !valueField.value || !valueField.id) continue;
result[s.title] = {
items[s.title] = {
...secret,
value,
fieldId
value: valueField.value,
fieldId: valueField.id
};
}
return result;
return { items, duplicates };
};
const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, itemValue }: TPostOnePassVariable) => {
const createOnePassItem = async ({
instanceUrl,
apiToken,
vaultId,
itemTitle,
itemValue,
valueLabel
}: TPostOnePassVariable) => {
return request.post(
`${instanceUrl}/v1/vaults/${vaultId}/items`,
{
@ -63,7 +78,7 @@ const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, it
tags: ["synced-from-infisical"],
fields: [
{
label: "value",
label: valueLabel,
value: itemValue,
type: "CONCEALED"
}
@ -85,7 +100,9 @@ const updateOnePassItem = async ({
itemId,
fieldId,
itemTitle,
itemValue
itemValue,
valueLabel,
otherFields
}: TPutOnePassVariable) => {
return request.put(
`${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`,
@ -98,9 +115,10 @@ const updateOnePassItem = async ({
},
tags: ["synced-from-infisical"],
fields: [
...otherFields,
{
id: fieldId,
label: "value",
label: valueLabel,
value: itemValue,
type: "CONCEALED"
}
@ -128,13 +146,18 @@ export const OnePassSyncFns = {
const {
connection,
environment,
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
const instanceUrl = await getOnePassInstanceUrl(connection);
const { apiToken } = connection.credentials;
const items = await listOnePassItems({ instanceUrl, apiToken, vaultId });
const { items, duplicates } = await listOnePassItems({
instanceUrl,
apiToken,
vaultId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
@ -148,10 +171,19 @@ export const OnePassSyncFns = {
itemTitle: key,
itemValue: value,
itemId: items[key].id,
fieldId: items[key].fieldId
fieldId: items[key].fieldId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT,
otherFields: items[key].fields.filter((field) => field.label !== (valueLabel || VALUE_LABEL_DEFAULT))
});
} else {
await createOnePassItem({ instanceUrl, apiToken, vaultId, itemTitle: key, itemValue: value });
await createOnePassItem({
instanceUrl,
apiToken,
vaultId,
itemTitle: key,
itemValue: value,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
}
} catch (error) {
throw new SecretSyncError({
@ -163,7 +195,28 @@ export const OnePassSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const [key, variable] of Object.entries(items)) {
// Delete duplicate item entries
for await (const [itemId, key] of Object.entries(duplicates)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
try {
await deleteOnePassItem({
instanceUrl,
apiToken,
vaultId,
itemId
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
// Delete item entries that are not in secretMap
for await (const [key, item] of Object.entries(items)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
@ -173,7 +226,7 @@ export const OnePassSyncFns = {
instanceUrl,
apiToken,
vaultId,
itemId: variable.id
itemId: item.id
});
} catch (error) {
throw new SecretSyncError({
@ -187,13 +240,18 @@ export const OnePassSyncFns = {
removeSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => {
const {
connection,
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
const instanceUrl = await getOnePassInstanceUrl(connection);
const { apiToken } = connection.credentials;
const items = await listOnePassItems({ instanceUrl, apiToken, vaultId });
const { items } = await listOnePassItems({
instanceUrl,
apiToken,
vaultId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
for await (const [key, item] of Object.entries(items)) {
if (key in secretMap) {
@ -216,12 +274,19 @@ export const OnePassSyncFns = {
getSecrets: async (secretSync: TOnePassSyncWithCredentials) => {
const {
connection,
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
const instanceUrl = await getOnePassInstanceUrl(connection);
const { apiToken } = connection.credentials;
return listOnePassItems({ instanceUrl, apiToken, vaultId });
const res = await listOnePassItems({
instanceUrl,
apiToken,
vaultId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
return Object.fromEntries(Object.entries(res.items).map(([key, item]) => [key, { value: item.value }]));
}
};

View File

@ -11,7 +11,8 @@ import {
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const OnePassSyncDestinationConfigSchema = z.object({
vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId)
vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId),
valueLabel: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.valueLabel)
});
const OnePassSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };

View File

@ -14,29 +14,32 @@ export type TOnePassSyncWithCredentials = TOnePassSync & {
connection: TOnePassConnection;
};
type Field = {
id: string;
type: string; // CONCEALED, STRING
label: string;
value: string;
};
export type TOnePassVariable = {
id: string;
title: string;
category: string; // API_CREDENTIAL, SECURE_NOTE, LOGIN, etc
};
export type TOnePassVariableDetails = TOnePassVariable & {
fields: {
id: string;
type: string; // CONCEALED, STRING
label: string;
value: string;
}[];
fields: Field[];
};
export type TOnePassListVariablesResponse = TOnePassVariable[];
export type TOnePassListVariables = {
type TOnePassBase = {
apiToken: string;
instanceUrl: string;
vaultId: string;
};
export type TOnePassListVariables = TOnePassBase & {
valueLabel: string;
};
export type TPostOnePassVariable = TOnePassListVariables & {
itemTitle: string;
itemValue: string;
@ -47,8 +50,9 @@ export type TPutOnePassVariable = TOnePassListVariables & {
fieldId: string;
itemTitle: string;
itemValue: string;
otherFields: Field[];
};
export type TDeleteOnePassVariable = TOnePassListVariables & {
export type TDeleteOnePassVariable = TOnePassBase & {
itemId: string;
};

View File

@ -7,13 +7,18 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { getServerCfg } from "../super-admin/super-admin-service";
import { TTelemetryDALFactory } from "./telemetry-dal";
import { TELEMETRY_SECRET_OPERATIONS_KEY, TELEMETRY_SECRET_PROCESSED_KEY } from "./telemetry-service";
import {
TELEMETRY_SECRET_OPERATIONS_KEY,
TELEMETRY_SECRET_PROCESSED_KEY,
TTelemetryServiceFactory
} from "./telemetry-service";
import { PostHogEventTypes } from "./telemetry-types";
type TTelemetryQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
keyStore: Pick<TKeyStoreFactory, "getItem" | "deleteItem">;
telemetryDAL: TTelemetryDALFactory;
telemetryService: TTelemetryServiceFactory;
};
export type TTelemetryQueueServiceFactory = ReturnType<typeof telemetryQueueServiceFactory>;
@ -21,7 +26,8 @@ export type TTelemetryQueueServiceFactory = ReturnType<typeof telemetryQueueServ
export const telemetryQueueServiceFactory = ({
queueService,
keyStore,
telemetryDAL
telemetryDAL,
telemetryService
}: TTelemetryQueueServiceFactoryDep) => {
const appCfg = getConfig();
const postHog =
@ -48,6 +54,10 @@ export const telemetryQueueServiceFactory = ({
await keyStore.deleteItem(TELEMETRY_SECRET_OPERATIONS_KEY);
});
queueService.start(QueueName.TelemetryAggregatedEvents, async () => {
await telemetryService.processAggregatedEvents();
});
// every day at midnight a telemetry job executes on self-hosted instances
// this sends some telemetry information like instance id secrets operated etc
const startTelemetryCheck = async () => {
@ -60,11 +70,26 @@ export const telemetryQueueServiceFactory = ({
{ pattern: "0 0 * * *", utc: true },
QueueName.TelemetryInstanceStats // just a job id
);
// clear previous aggregated events job
await queueService.stopRepeatableJob(
QueueName.TelemetryAggregatedEvents,
QueueJobs.TelemetryAggregatedEvents,
{ pattern: "*/5 * * * *", utc: true },
QueueName.TelemetryAggregatedEvents // just a job id
);
if (postHog) {
await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, {
jobId: QueueName.TelemetryInstanceStats,
repeat: { pattern: "0 0 * * *", utc: true }
});
// Start aggregated events job (runs every five minutes)
await queueService.queue(QueueName.TelemetryAggregatedEvents, QueueJobs.TelemetryAggregatedEvents, undefined, {
jobId: QueueName.TelemetryAggregatedEvents,
repeat: { pattern: "*/5 * * * *", utc: true }
});
}
};
@ -72,6 +97,10 @@ export const telemetryQueueServiceFactory = ({
logger.error(err?.failedReason, `${QueueName.TelemetryInstanceStats}: failed`);
});
queueService.listen(QueueName.TelemetryAggregatedEvents, "failed", (err) => {
logger.error(err?.failedReason, `${QueueName.TelemetryAggregatedEvents}: failed`);
});
return {
startTelemetryCheck
};

View File

@ -1,3 +1,4 @@
import { createHash, randomUUID } from "crypto";
import { PostHog } from "posthog-node";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@ -12,12 +13,49 @@ import { PostHogEventTypes, TPostHogEvent, TSecretModifiedEvent } from "./teleme
export const TELEMETRY_SECRET_PROCESSED_KEY = "telemetry-secret-processed";
export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations";
export const POSTHOG_AGGREGATED_EVENTS = [PostHogEventTypes.SecretPulled];
const TELEMETRY_AGGREGATED_KEY_EXP = 900; // 15mins
// Bucket configuration
const TELEMETRY_BUCKET_COUNT = 30;
const TELEMETRY_BUCKET_NAMES = Array.from(
{ length: TELEMETRY_BUCKET_COUNT },
(_, i) => `bucket-${i.toString().padStart(2, "0")}`
);
type AggregatedEventData = Record<string, unknown>;
type SingleEventData = {
distinctId: string;
event: string;
properties: unknown;
organizationId: string;
};
export type TTelemetryServiceFactory = ReturnType<typeof telemetryServiceFactory>;
export type TTelemetryServiceFactoryDep = {
keyStore: Pick<TKeyStoreFactory, "getItem" | "incrementBy">;
keyStore: Pick<
TKeyStoreFactory,
"incrementBy" | "deleteItemsByKeyIn" | "setItemWithExpiry" | "getKeysByPattern" | "getItems"
>;
licenseService: Pick<TLicenseServiceFactory, "getInstanceType">;
};
const getBucketForDistinctId = (distinctId: string): string => {
// Use SHA-256 hash for consistent distribution
const hash = createHash("sha256").update(distinctId).digest("hex");
// Take first 8 characters and convert to number for better distribution
const hashNumber = parseInt(hash.substring(0, 8), 16);
const bucketIndex = hashNumber % TELEMETRY_BUCKET_COUNT;
return TELEMETRY_BUCKET_NAMES[bucketIndex];
};
export const createTelemetryEventKey = (event: string, distinctId: string): string => {
const bucketId = getBucketForDistinctId(distinctId);
return `telemetry-event-${event}-${bucketId}-${distinctId}-${randomUUID()}`;
};
export const telemetryServiceFactory = ({ keyStore, licenseService }: TTelemetryServiceFactoryDep) => {
const appCfg = getConfig();
@ -64,11 +102,33 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
const instanceType = licenseService.getInstanceType();
// capture posthog only when its cloud or signup event happens in self-hosted
if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) {
postHog.capture({
event: event.event,
distinctId: event.distinctId,
properties: event.properties
});
if (event.organizationId) {
try {
postHog.groupIdentify({ groupType: "organization", groupKey: event.organizationId });
} catch (error) {
logger.error(error, "Failed to identify PostHog organization");
}
}
if (POSTHOG_AGGREGATED_EVENTS.includes(event.event)) {
const eventKey = createTelemetryEventKey(event.event, event.distinctId);
await keyStore.setItemWithExpiry(
eventKey,
TELEMETRY_AGGREGATED_KEY_EXP,
JSON.stringify({
distinctId: event.distinctId,
event: event.event,
properties: event.properties,
organizationId: event.organizationId
})
);
} else {
postHog.capture({
event: event.event,
distinctId: event.distinctId,
properties: event.properties,
...(event.organizationId ? { groups: { organization: event.organizationId } } : {})
});
}
return;
}
@ -89,6 +149,160 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
}
};
const aggregateGroupProperties = (events: SingleEventData[]): AggregatedEventData => {
const aggregatedData: AggregatedEventData = {};
// Set the total count
aggregatedData.count = events.length;
events.forEach((event) => {
if (!event.properties) return;
Object.entries(event.properties as Record<string, unknown>).forEach(([key, value]: [string, unknown]) => {
if (Array.isArray(value)) {
// For arrays, count occurrences of each item
const existingCounts =
aggregatedData[key] &&
typeof aggregatedData[key] === "object" &&
aggregatedData[key]?.constructor === Object
? (aggregatedData[key] as Record<string, number>)
: {};
value.forEach((item) => {
const itemKey = typeof item === "object" ? JSON.stringify(item) : String(item);
existingCounts[itemKey] = (existingCounts[itemKey] || 0) + 1;
});
aggregatedData[key] = existingCounts;
} else if (typeof value === "object" && value?.constructor === Object) {
// For objects, count occurrences of each field value
const existingCounts =
aggregatedData[key] &&
typeof aggregatedData[key] === "object" &&
aggregatedData[key]?.constructor === Object
? (aggregatedData[key] as Record<string, number>)
: {};
if (value) {
Object.values(value).forEach((fieldValue) => {
const valueKey = typeof fieldValue === "object" ? JSON.stringify(fieldValue) : String(fieldValue);
existingCounts[valueKey] = (existingCounts[valueKey] || 0) + 1;
});
}
aggregatedData[key] = existingCounts;
} else if (typeof value === "number") {
// For numbers, add to existing sum
aggregatedData[key] = ((aggregatedData[key] as number) || 0) + value;
} else if (value !== undefined && value !== null) {
// For other types (strings, booleans, etc.), count occurrences
const stringValue = String(value);
const existingValue = aggregatedData[key];
if (!existingValue) {
aggregatedData[key] = { [stringValue]: 1 };
} else if (existingValue && typeof existingValue === "object" && existingValue.constructor === Object) {
const countObject = existingValue as Record<string, number>;
countObject[stringValue] = (countObject[stringValue] || 0) + 1;
} else {
const oldValue = String(existingValue);
aggregatedData[key] = {
[oldValue]: 1,
[stringValue]: 1
};
}
}
});
});
return aggregatedData;
};
const processBucketEvents = async (eventType: string, bucketId: string) => {
if (!postHog) return 0;
try {
const bucketPattern = `telemetry-event-${eventType}-${bucketId}-*`;
const bucketKeys = await keyStore.getKeysByPattern(bucketPattern);
if (bucketKeys.length === 0) return 0;
const bucketEvents = await keyStore.getItems(bucketKeys);
let bucketEventsParsed: SingleEventData[] = [];
try {
bucketEventsParsed = bucketEvents
.filter((event) => event !== null)
.map((event) => JSON.parse(event as string) as SingleEventData);
} catch (error) {
logger.error(error, `Failed to parse bucket events for ${eventType} in ${bucketId}`);
return 0;
}
const eventsGrouped = new Map<string, SingleEventData[]>();
bucketEventsParsed.forEach((event) => {
const key = JSON.stringify({ id: event.distinctId, org: event.organizationId });
if (!eventsGrouped.has(key)) {
eventsGrouped.set(key, []);
}
eventsGrouped.get(key)!.push(event);
});
if (eventsGrouped.size === 0) return 0;
for (const [eventsKey, events] of eventsGrouped) {
const key = JSON.parse(eventsKey) as { id: string; org?: string };
if (key.org) {
try {
postHog.groupIdentify({ groupType: "organization", groupKey: key.org });
} catch (error) {
logger.error(error, "Failed to identify PostHog organization");
}
}
const properties = aggregateGroupProperties(events);
postHog.capture({
event: `${eventType} aggregated`,
distinctId: key.id,
properties,
...(key.org ? { groups: { organization: key.org } } : {})
});
}
// Clean up processed data for this bucket
await keyStore.deleteItemsByKeyIn(bucketKeys);
logger.info(`Processed ${bucketEventsParsed.length} events from bucket ${bucketId} for ${eventType}`);
return bucketEventsParsed.length;
} catch (error) {
logger.error(error, `Failed to process bucket ${bucketId} for ${eventType}`);
return 0;
}
};
const processAggregatedEvents = async () => {
if (!postHog) return;
for (const eventType of POSTHOG_AGGREGATED_EVENTS) {
let totalProcessed = 0;
logger.info(`Starting bucket processing for ${eventType}`);
// Process each bucket sequentially to control memory usage
for (const bucketId of TELEMETRY_BUCKET_NAMES) {
try {
// eslint-disable-next-line no-await-in-loop
const processed = await processBucketEvents(eventType, bucketId);
totalProcessed += processed;
} catch (error) {
logger.error(error, `Failed to process bucket ${bucketId} for ${eventType}`);
}
}
logger.info(`Completed processing ${totalProcessed} total events for ${eventType}`);
}
};
const flushAll = async () => {
if (postHog) {
await postHog.shutdownAsync();
@ -98,6 +312,8 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
return {
sendLoopsEvent,
sendPostHogEvents,
flushAll
processAggregatedEvents,
flushAll,
getBucketForDistinctId
};
};

View File

@ -1,3 +1,13 @@
import {
IdentityActor,
KmipClientActor,
PlatformActor,
ScimClientActor,
ServiceActor,
UnknownUserActor,
UserActor
} from "@app/ee/services/audit-log/audit-log-types";
export enum PostHogEventTypes {
SecretPush = "secrets pushed",
SecretPulled = "secrets pulled",
@ -40,6 +50,14 @@ export type TSecretModifiedEvent = {
secretPath: string;
channel?: string;
userAgent?: string;
actor?:
| UserActor
| IdentityActor
| ServiceActor
| ScimClientActor
| PlatformActor
| UnknownUserActor
| KmipClientActor;
};
};
@ -214,7 +232,7 @@ export type TInvalidateCacheEvent = {
};
};
export type TPostHogEvent = { distinctId: string } & (
export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
| TSecretModifiedEvent
| TAdminInitEvent
| TUserSignedUpEvent

View File

@ -11,9 +11,9 @@ Fairly frequently, you might run into situations when you need to spend company
As a perk of working at Infisical, we cover some of your meal expenses.
HQ team members: meals and unlimited snacks are provided on-site at no cost.
**HQ team members**: meals and unlimited snacks are provided **on-site** at no cost.
Remote team members: a food stipend is allocated based on location.
**Remote team members**: a food stipend is allocated based on location.
# Trivial expenses
@ -27,21 +27,28 @@ This means expenses that are:
Please spend money in a way that you think is in the best interest of the company.
</Note>
## Saving receipts
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.
# Travel
You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually.
If you need to travel on Infisicals behalf for in-person onboarding, meeting customers, and offsites, again please spend money in the best interests of the company.
## Training
We do not pre-approve your travel expenses, and trust team members to make the right decisions here. Some guidance:
- Please find a flight ticket that is reasonably priced. We all travel economy by default we cannot afford for folks to fly premium or business class. Feel free to upgrade using your personal money/airmiles if youd like to.
- Feel free to pay for the Uber/subway/bus to and from the airport with your Brex card.
- For business travel, Infisical will cover reasonable expenses for breakfast, lunch, and dinner.
- When traveling internationally, Infisical does not cover roaming charges for your phone. You can expense a reasonable eSIM, which usually is no more than $20.
<Note>
Note that this only applies to business travel. It is not applicable for personal travel or day-to-day commuting.
</Note>
For engineers, youre welcome to take an approved Udemy course. Please reach out to Maidul. For the GTM team, you may buy a book a month if its relevant to your work.
# Equipment
Infisical is a remote first company so we understand the importance of having a comfortable work setup. To support this, we provide allowances for essential office equipment.
### Desk & Chair
### 1. Desk & Chair
Most people already have a comfortable desk and chair, but if you need an upgrade, we offer the following allowances.
While we're not yet able to provide the latest and greatest, we strive to be reasonable given the stage of our company.
@ -50,10 +57,10 @@ While we're not yet able to provide the latest and greatest, we strive to be rea
**Chair**: $150 USD
### Laptop
### 2. Laptop
Each team member will receive a company-issued Macbook Pro before they start their first day.
### Notes
### 3. Notes
1. All equipment purchased using company allowances remains the property of Infisical.
2. Keep all receipts for equipment purchases and submit them for reimbursement.
@ -65,6 +72,28 @@ This is because we don't yet have a formal HR department to handle such logistic
For any equipment related questions, please reach out to Maidul.
## Brex
# Brex
We use Brex as our primary credit card provider. Don't have a company card yet? Reach out to Maidul.
### Budgets
You will generally have multiple budgets assigned to you. "General Company Expenses" primarily covers quick SaaS purchases (not food). Remote team members should have a "Lunch Stipend" budget that applies to food.
If your position involves a lot of travel, you may also have a "Travel" budget that applies to expenses related to business travel (e.g., you can not use it for transportation or food during personal travel).
### Saving receipts
Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay.
You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually.
### Need a one-off budget increase?
You can do this directly within Brex - just request the amount and duration for the relevant budget in the app, and your hiring manager will automatically be notified for approval.
# Training
For engineers, youre welcome to take an approved Udemy course. Please reach out to Maidul. For the GTM team, you may buy a book a month if its relevant to your work.

View File

@ -4,7 +4,7 @@ services:
nginx:
container_name: infisical-dev-nginx
image: nginx
restart: always
restart: "always"
ports:
- 8080:80
- 8443:443

View File

@ -0,0 +1,4 @@
---
title: "Attach"
openapi: "POST /api/v1/auth/tls-cert-auth/identities/{identityId}"
---

View File

@ -0,0 +1,4 @@
---
title: "Login"
openapi: "POST /api/v1/auth/tls-cert-auth/login"
---

View File

@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/auth/tls-cert-auth/identities/{identityId}"
---

View File

@ -0,0 +1,4 @@
---
title: "Revoke"
openapi: "DELETE /api/v1/auth/tls-cert-auth/identities/{identityId}"
---

View File

@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/auth/tls-cert-auth/identities/{identityId}"
---

View File

@ -288,6 +288,7 @@
"documentation/platform/identities/kubernetes-auth",
"documentation/platform/identities/oci-auth",
"documentation/platform/identities/token-auth",
"documentation/platform/identities/tls-cert-auth",
"documentation/platform/identities/universal-auth",
{
"group": "OIDC Auth",
@ -752,6 +753,16 @@
"api-reference/endpoints/alicloud-auth/revoke"
]
},
{
"group": "TLS Certificate Auth",
"pages": [
"api-reference/endpoints/tls-cert-auth/login",
"api-reference/endpoints/tls-cert-auth/attach",
"api-reference/endpoints/tls-cert-auth/retrieve",
"api-reference/endpoints/tls-cert-auth/update",
"api-reference/endpoints/tls-cert-auth/revoke"
]
},
{
"group": "AWS Auth",
"pages": [

View File

@ -0,0 +1,176 @@
---
title: TLS Certificate Auth
description: "Learn how to authenticate with Infisical using TLS Certificate."
---
**TLS Certificate Auth** is an authentication method that verifies a user's TLS Client certificate using the provided CA Certificate, allowing secure access to Infisical resources.
## Diagram
The following sequence diagram illustrates the TLS Certificate Auth workflow for authenticating users with Infisical.
```mermaid
sequenceDiagram
participant Client
participant Infisical
Note over Client,Client: Step 1: Setup your TLS request with the client certificate
Note over Client,Infisical: Step 2: Login Operation
Client->>Infisical: Send request to /api/v1/auth/tls-cert-auth/login
Note over Infisical: Step 3: Request verification using CA Certificate
Infisical->>Client: Return short-lived access token
Note over Client,Infisical: Step 5: Access Infisical API with token
Client->>Infisical: Make authenticated requests using the short-lived access token
```
## Concept
At a high level, Infisical authenticates the client's TLS Certificate by verifying its identity and checking that it meets specific requirements (e.g., it is bound to the allowed common names) at the `/api/v1/auth/tls-cert-auth/login` endpoint. If successful, Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API.
To be more specific:
1. The client sends a TLS request with the client certificate to Infisical at the `/api/v1/auth/tls-cert-auth/login` endpoint.
2. Infisical verifies the incoming request using the provided CA certificate.
3. Infisical checks the user's properties against set criteria such as Allowed Common Names.
4. If all checks pass, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API.
<Accordion title="TLS with Load Balancer/Proxy">
Most of the time, the Infisical server will be behind a load balancer or
proxy. To propagate the TLS certificate from the load balancer to the
instance, you can configure the TLS to send the client certificate as a header
that is set as an [environment
variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key).
</Accordion>
## Guide
In the following steps, we explore how to create and use identities for your workloads and applications on TLS Certificate to
access the Infisical API using request signing.
<Warning>
**Self-Hosted Users:** Before using TLS Certificate Auth, please review the
[Security Requirements for Self-Hosted
Deployments](#security-requirements-for-self-hosted-deployments) section below
to ensure proper configuration and avoid security vulnerabilities.
</Warning>
### Creating an identity
To create an identity, head to your Organization Settings > Access Control > [Identities](https://app.infisical.com/organization/access-management?selectedTab=identities) and press **Create identity**.
![identities organization](/images/platform/identities/identities-org.png)
When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles).
![identities organization create](/images/platform/identities/identities-org-create.png)
Input some details for your new identity:
- **Name (required):** A friendly name for the identity.
- **Role (required):** A role from the [**Organization Roles**](https://app.infisical.com/organization/access-management?selectedTab=roles) tab for the identity to assume. The organization role assigned will determine what organization-level resources this identity can have access to.
Once you've created an identity, you'll be redirected to a page where you can manage the identity.
![identities page](/images/platform/identities/identities-page.png)
Since the identity has been configured with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) by default, you should reconfigure it to use TLS Certificate Auth instead. To do this, click the cog next to **Universal Auth** and then select **Delete** in the options dropdown.
![identities press cog](/images/platform/identities/identities-press-cog.png)
![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png)
Now create a new TLS Certificate Auth Method.
![identities create tls cert auth method](/images/platform/identities/identities-tls-cert-auth-create-auth.png)
Here's some information about each field:
- **CA Certificate:** A PEM encoded CA Certificate used to validate incoming TLS request client certificate.
- **Allowed Common Names:** A comma separated list of client certificate common names allowed.
- **Access Token TTL (default is `2592000` equivalent to 30 days):** The lifetime for an access token in seconds. This value will be referenced at renewal time.
- **Access Token Max TTL (default is `2592000` equivalent to 30 days):** The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.
- **Access Token Max Number of Uses (default is `0`):** The maximum number of times that an access token can be used; a value of `0` implies an infinite number of uses.
- **Access Token Trusted IPs:** The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address.
### Adding an identity to a project
In order to allow an identity to access project-level resources such as secrets, you must add it to the relevant projects.
To do this, head over to the project you want to add the identity to and navigate to Project Settings > Access Control > Machine Identities and press **Add Identity**.
![identities project](/images/platform/identities/identities-project.png)
Select the identity you want to add to the project and the project-level role you want it to assume. The project role given to the identity will determine what project-level resources this identity can access.
![identities project create](/images/platform/identities/identities-project-create.png)
### Accessing the Infisical API with the identity
To access the Infisical API as the identity, you need to send a TLS request to `/api/v1/auth/tls-cert-auth/login` endpoint.
Below is an example of how you can authenticate with Infisical using NodeJS.
```javascript
const fs = require("fs");
const https = require("https");
const axios = require("axios");
try {
const clientCertificate = fs.readFileSync("client-cert.pem", "utf8");
const clientKeyCertificate = fs.readFileSync("client-key.pem", "utf8");
const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL
const identityId = "<your-identity-id>";
// Create HTTPS agent with client certificate and key
const httpsAgent = new https.Agent({
cert: clientCertificate,
key: clientKeyCertificate,
});
const { data } = await axios.post(
`${infisicalUrl}/api/v1/auth/tls-cert-auth/login`,
{
identityId,
},
{
httpsAgent: httpsAgent, // Pass the HTTPS agent with client cert
}
);
console.log("result data: ", data); // access token here
} catch (err) {
console.error(err);
}
```
<Note>
Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds, which can be adjusted.
If an identity access token expires, it can no longer access the Infisical API. A new access token should be obtained by performing another login operation.
</Note>
## Security Requirements for Self-Hosted Deployments
ALL TLS cert [login](/api-reference/endpoints/tls-cert-auth/login) requests **MUST** go through a load balancer/proxy that verifies certificate ownership:
- **REQUIRED:** Configure your load balancer/proxy to **require a proper TLS handshake with client certificate presentation**
- **REQUIRED:** Ensure the load balancer **verifies the client possesses the private key** corresponding to the certificate (standard TLS behavior)
- **NEVER** allow direct connections to Infisical for TLS cert auth - this enables header injection attacks
- **NEVER** forward certificate headers without requiring proper TLS certificate presentation
### Load Balancer Configuration Examples
- **AWS ALB:** Use mTLS listeners which require client certificate presentation during the TLS handshake
- **NGINX/HAProxy:** Configure SSL client certificate requirement with proper TLS handshake verification
<Note>
Infisical will handle the actual certificate validation against the configured
CA certificate and determine authentication permissions. The load balancer's
role is to ensure certificate ownership, not certificate trust validation.
</Note>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 KiB

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

View File

@ -36,6 +36,7 @@ description: "Learn how to configure a 1Password Sync for Infisical."
- **1Password Connection**: The 1Password Connection to authenticate with.
- **Vault**: The 1Password vault to sync secrets to.
- **Value Label**: The label of the 1Password item field that will hold your secret value.
</Step>
<Step title="Configure sync options">
Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
@ -94,7 +95,8 @@ description: "Learn how to configure a 1Password Sync for Infisical."
"initialSyncBehavior": "overwrite-destination"
},
"destinationConfig": {
"vaultId": "..."
"vaultId": "...",
"valueLabel": "value"
}
}'
```
@ -145,7 +147,8 @@ description: "Learn how to configure a 1Password Sync for Infisical."
},
"destination": "1password",
"destinationConfig": {
"vaultId": "..."
"vaultId": "...",
"valueLabel": "value"
}
}
}
@ -160,4 +163,7 @@ description: "Learn how to configure a 1Password Sync for Infisical."
Infisical can only perform CRUD operations on the following item types:
- API Credentials
</Accordion>
<Accordion title="What is a 'Value Label'?">
It's the label of the 1Password item field which will hold your secret value. For example, if you were to sync Infisical secret 'foo: bar', the 1Password item equivalent would have an item title of 'foo', and a field on that item 'value: bar'. The field label 'value' is what gets changed by this option.
</Accordion>
</AccordionGroup>

View File

@ -7,9 +7,10 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical."
- Set up and add secrets to [Infisical Cloud](https://app.infisical.com)
- Create a [GCP Connection](/integrations/app-connections/gcp) with the required **Secret Sync** permissions
- Enable **Cloud Resource Manager API** and **Secret Manager API** on your GCP project
- Enable **Cloud Resource Manager API**, **Secret Manager API**, and **Service Usage API** on your GCP project
![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-resource-manager-api.png)
![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-secret-manager-api.png)
![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-service-usage-api.png)
<Tabs>
<Tab title="Infisical UI">

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -32,7 +32,7 @@ Used to configure platform-specific security and operational settings
<ParamField query="HOST" type="string" default="localhost" optional>
Specifies the network interface Infisical will bind to when accepting incoming connections.
By default, Infisical binds to `localhost`, which restricts access to connections from the same machine.
By default, Infisical binds to `localhost`, which restricts access to connections from the same machine.
To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces.
@ -122,6 +122,7 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}]
</ParamField>
### Redis
Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup.
<Tabs>
@ -199,8 +200,17 @@ Without email configuration, Infisical's core functions like sign-up/login and s
connection can not be encrypted then message is not sent.
</ParamField>
<ParamField query="SMTP_TLS_REJECT_UNAUTHORIZED" type="bool" default="true" optional>
If this is `true`, Infisical will validate the server's SSL/TLS certificate and reject the connection if the certificate is invalid or not trusted. If set to `false`, the client will accept the server's certificate regardless of its validity, which can be useful in development or testing environments but is not recommended for production use.
<ParamField
query="SMTP_TLS_REJECT_UNAUTHORIZED"
type="bool"
default="true"
optional
>
If this is `true`, Infisical will validate the server's SSL/TLS certificate
and reject the connection if the certificate is invalid or not trusted. If set
to `false`, the client will accept the server's certificate regardless of its
validity, which can be useful in development or testing environments but is
not recommended for production use.
</ParamField>
<ParamField query="SMTP_CUSTOM_CA_CERT" type="string" default="none" optional>
@ -211,6 +221,7 @@ Without email configuration, Infisical's core functions like sign-up/login and s
Infisical highly encourages the following variables be used alongside this one for maximum security:
- `SMTP_REQUIRE_TLS=true`
- `SMTP_TLS_REJECT_UNAUTHORIZED=true`
</ParamField>
</Accordion>
@ -577,6 +588,7 @@ You can configure third-party app connections for re-use across Infisical Projec
<ParamField query="INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET" type="string" default="none" optional>
The webhook secret configured for payload verification in the GitHub Radar App
</ParamField>
</Accordion>
<Accordion title="GitHub OAuth Connection">
@ -771,3 +783,14 @@ If export type is set to `otlp`, you will have to configure a value for `OTEL_EX
<ParamField query="OTEL_COLLECTOR_BASIC_AUTH_PASSWORD" type="string">
The password for authenticating with the telemetry collector.
</ParamField>
## Identity Auth Method
<ParamField
query="IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY"
type="string"
default="x-identity-tls-cert-auth-client-cert"
>
The TLS header used to propagate the client certificate from the load balancer
to the server.
</ParamField>

View File

@ -4,7 +4,7 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
import { FilterableSelect, FormControl, Input, Tooltip } from "@app/components/v2";
import {
TOnePassVault,
useOnePassConnectionListVaults
@ -32,6 +32,7 @@ export const OnePassSyncFields = () => {
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.vaultId", "");
setValue("destinationConfig.valueLabel", "");
}}
/>
@ -69,6 +70,22 @@ export const OnePassSyncFields = () => {
</FormControl>
)}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
isOptional
label="Value Label"
tooltipText="It's the label of the 1Password item field which will hold your secret value. For example, if you were to sync Infisical secret 'foo: bar', the 1Password item equivalent would have an item title of 'foo', and a field on that item 'value: bar'. The field label 'value' is what gets changed by this option."
>
<Input value={value} onChange={onChange} placeholder="value" />
</FormControl>
)}
control={control}
name="destinationConfig.valueLabel"
/>
</>
);
};

View File

@ -76,7 +76,7 @@ export const GcpSyncFields = () => {
helperText={
<Tooltip
className="max-w-md"
content="Ensure that you've enabled the Secret Manager API and Cloud Resource Manager API on your GCP project. Additionally, make sure that the service account is assigned the appropriate GCP roles."
content="Ensure that you've enabled the Secret Manager API, Cloud Resource Manager API, and Service Usage API on your GCP project. Additionally, make sure that the service account is assigned the appropriate GCP roles."
>
<div>
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "}

View File

@ -6,7 +6,15 @@ import { SecretSync } from "@app/hooks/api/secretSyncs";
export const OnePassSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.OnePass }>();
const vaultId = watch("destinationConfig.vaultId");
const [vaultId, valueLabel] = watch([
"destinationConfig.vaultId",
"destinationConfig.valueLabel"
]);
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
return (
<>
<GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>
<GenericFieldLabel label="Value Key">{valueLabel || "value"}</GenericFieldLabel>
</>
);
};

View File

@ -7,7 +7,8 @@ export const OnePassSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.OnePass),
destinationConfig: z.object({
vaultId: z.string().trim().min(1, "Vault ID required")
vaultId: z.string().trim().min(1, "Vault ID required"),
valueLabel: z.string().trim().optional()
})
})
);

View File

@ -1,4 +1,6 @@
import { ReactNode } from "react";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
type Props = {
@ -7,6 +9,7 @@ type Props = {
className?: string;
labelClassName?: string;
truncate?: boolean;
icon?: IconDefinition;
};
export const GenericFieldLabel = ({
@ -14,11 +17,15 @@ export const GenericFieldLabel = ({
children,
className,
labelClassName,
truncate
truncate,
icon
}: Props) => {
return (
<div className={twMerge("min-w-0", className)}>
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
<div className="flex items-center gap-1.5">
{icon && <FontAwesomeIcon icon={icon} className="text-mineshaft-400" size="sm" />}
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
</div>
{children ? (
<p className={twMerge("text-sm text-mineshaft-100", truncate && "truncate")}>{children}</p>
) : (

View File

@ -64,7 +64,7 @@ export const Pagination = ({
<FontAwesomeIcon className="text-xs" icon={faCaretDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-fit">
<DropdownMenuContent sideOffset={2} className="min-w-fit">
{perPageList.map((perPageOption) => (
<DropdownMenuItem
key={`pagination-per-page-options-${perPageOption}`}

View File

@ -11,5 +11,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = {
[IdentityAuthMethod.OCI_AUTH]: "OCI Auth",
[IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth",
[IdentityAuthMethod.LDAP_AUTH]: "LDAP Auth",
[IdentityAuthMethod.JWT_AUTH]: "JWT Auth"
[IdentityAuthMethod.JWT_AUTH]: "JWT Auth",
[IdentityAuthMethod.TLS_CERT_AUTH]: "TLS Certificate Auth"
};

View File

@ -9,7 +9,8 @@ export enum IdentityAuthMethod {
OCI_AUTH = "oci-auth",
OIDC_AUTH = "oidc-auth",
LDAP_AUTH = "ldap-auth",
JWT_AUTH = "jwt-auth"
JWT_AUTH = "jwt-auth",
TLS_CERT_AUTH = "tls-cert-auth"
}
export enum IdentityJwtConfigurationType {

View File

@ -14,6 +14,7 @@ import {
AddIdentityLdapAuthDTO,
AddIdentityOciAuthDTO,
AddIdentityOidcAuthDTO,
AddIdentityTlsCertAuthDTO,
AddIdentityTokenAuthDTO,
AddIdentityUniversalAuthDTO,
ClientSecretData,
@ -32,6 +33,7 @@ import {
DeleteIdentityLdapAuthDTO,
DeleteIdentityOciAuthDTO,
DeleteIdentityOidcAuthDTO,
DeleteIdentityTlsCertAuthDTO,
DeleteIdentityTokenAuthDTO,
DeleteIdentityUniversalAuthClientSecretDTO,
DeleteIdentityUniversalAuthDTO,
@ -46,6 +48,7 @@ import {
IdentityLdapAuth,
IdentityOciAuth,
IdentityOidcAuth,
IdentityTlsCertAuth,
IdentityTokenAuth,
IdentityUniversalAuth,
RevokeTokenDTO,
@ -60,6 +63,7 @@ import {
UpdateIdentityLdapAuthDTO,
UpdateIdentityOciAuthDTO,
UpdateIdentityOidcAuthDTO,
UpdateIdentityTlsCertAuthDTO,
UpdateIdentityTokenAuthDTO,
UpdateIdentityUniversalAuthDTO,
UpdateTokenIdentityTokenAuthDTO
@ -655,6 +659,107 @@ export const useDeleteIdentityAliCloudAuth = () => {
});
};
export const useAddIdentityTlsCertAuth = () => {
const queryClient = useQueryClient();
return useMutation<IdentityTlsCertAuth, object, AddIdentityTlsCertAuthDTO>({
mutationFn: async ({
identityId,
allowedCommonNames,
caCertificate,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}) => {
const {
data: { identityTlsCertAuth }
} = await apiRequest.post<{ identityTlsCertAuth: IdentityTlsCertAuth }>(
`/api/v1/auth/tls-cert-auth/identities/${identityId}`,
{
allowedCommonNames,
caCertificate,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}
);
return identityTlsCertAuth;
},
onSuccess: (_, { identityId, organizationId }) => {
queryClient.invalidateQueries({
queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
});
queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
queryClient.invalidateQueries({
queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId)
});
}
});
};
export const useUpdateIdentityTlsCertAuth = () => {
const queryClient = useQueryClient();
return useMutation<IdentityTlsCertAuth, object, UpdateIdentityTlsCertAuthDTO>({
mutationFn: async ({
identityId,
allowedCommonNames,
caCertificate,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}) => {
const {
data: { identityTlsCertAuth }
} = await apiRequest.patch<{ identityTlsCertAuth: IdentityTlsCertAuth }>(
`/api/v1/auth/tls-cert-auth/identities/${identityId}`,
{
caCertificate,
allowedCommonNames,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}
);
return identityTlsCertAuth;
},
onSuccess: (_, { identityId, organizationId }) => {
queryClient.invalidateQueries({
queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
});
queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
queryClient.invalidateQueries({
queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId)
});
}
});
};
export const useDeleteIdentityTlsCertAuth = () => {
const queryClient = useQueryClient();
return useMutation<IdentityTlsCertAuth, object, DeleteIdentityTlsCertAuthDTO>({
mutationFn: async ({ identityId }) => {
const {
data: { identityTlsCertAuth }
} = await apiRequest.delete(`/api/v1/auth/tls-cert-auth/identities/${identityId}`);
return identityTlsCertAuth;
},
onSuccess: (_, { organizationId, identityId }) => {
queryClient.invalidateQueries({
queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
});
queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
queryClient.invalidateQueries({
queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId)
});
}
});
};
export const useUpdateIdentityOidcAuth = () => {
const queryClient = useQueryClient();
return useMutation<IdentityOidcAuth, object, UpdateIdentityOidcAuthDTO>({

View File

@ -17,6 +17,7 @@ import {
IdentityMembershipOrg,
IdentityOciAuth,
IdentityOidcAuth,
IdentityTlsCertAuth,
IdentityTokenAuth,
IdentityUniversalAuth,
TSearchIdentitiesDTO
@ -34,6 +35,8 @@ export const identitiesKeys = {
getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const,
getIdentityOidcAuth: (identityId: string) => [{ identityId }, "identity-oidc-auth"] as const,
getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const,
getIdentityTlsCertAuth: (identityId: string) =>
[{ identityId }, "identity-tls-cert-auth"] as const,
getIdentityAliCloudAuth: (identityId: string) =>
[{ identityId }, "identity-alicloud-auth"] as const,
getIdentityOciAuth: (identityId: string) => [{ identityId }, "identity-oci-auth"] as const,
@ -78,7 +81,8 @@ export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => {
search
});
return data;
}
},
placeholderData: (previousData) => previousData
});
};
@ -175,6 +179,27 @@ export const useGetIdentityAwsAuth = (
});
};
export const useGetIdentityTlsCertAuth = (
identityId: string,
options?: TReactQueryOptions["options"]
) => {
return useQuery({
queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId),
queryFn: async () => {
const {
data: { identityTlsCertAuth }
} = await apiRequest.get<{ identityTlsCertAuth: IdentityTlsCertAuth }>(
`/api/v1/auth/tls-cert-auth/identities/${identityId}`
);
return identityTlsCertAuth;
},
staleTime: 0,
gcTime: 0,
...options,
enabled: Boolean(identityId) && (options?.enabled ?? true)
});
};
export const useGetIdentityOciAuth = (
identityId: string,
options?: TReactQueryOptions["options"]

View File

@ -485,6 +485,47 @@ export type DeleteIdentityKubernetesAuthDTO = {
identityId: string;
};
export type IdentityTlsCertAuth = {
identityId: string;
caCertificate: string;
allowedCommonNames: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: IdentityTrustedIp[];
};
export type AddIdentityTlsCertAuthDTO = {
organizationId: string;
identityId: string;
caCertificate: string;
allowedCommonNames?: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: {
ipAddress: string;
}[];
};
export type UpdateIdentityTlsCertAuthDTO = {
organizationId: string;
identityId: string;
caCertificate: string;
allowedCommonNames?: string | null;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: {
ipAddress: string;
}[];
};
export type DeleteIdentityTlsCertAuthDTO = {
organizationId: string;
identityId: string;
};
export type CreateIdentityUniversalAuthClientSecretDTO = {
identityId: string;
description?: string;

View File

@ -154,6 +154,6 @@ export type TOrgIdentitiesList = {
};
export enum OrgIdentityOrderBy {
Name = "name"
// Role = "role"
Name = "name",
Role = "role"
}

View File

@ -6,6 +6,7 @@ export type TOnePassSync = TRootSecretSync & {
destination: SecretSync.OnePass;
destinationConfig: {
vaultId: string;
valueLabel?: string;
};
connection: {
app: AppConnection.OnePass;

View File

@ -164,7 +164,10 @@ export const MinimizedOrgSidebar = () => {
const handleCopyToken = async () => {
try {
await window.navigator.clipboard.writeText(getAuthToken());
createNotification({ type: "success", text: "Copied current login session token to clipboard" });
createNotification({
type: "success",
text: "Copied current login session token to clipboard"
});
} catch (error) {
console.log(error);
createNotification({ type: "error", text: "Failed to copy user token to clipboard" });

View File

@ -56,12 +56,12 @@ export const OrgGroupsSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
<OrgPermissionCan I={OrgPermissionGroupActions.Create} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}

View File

@ -2,14 +2,17 @@ import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faCopy,
faEdit,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash,
faUserGroup,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
@ -261,7 +264,8 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
className="h-8 w-48 bg-mineshaft-700"
position="popper"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
@ -282,13 +286,19 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
className="w-6"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCopy} />}
onClick={(e) => {
e.stopPropagation();
createNotification({
@ -306,10 +316,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("group", {
@ -320,7 +327,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
customRole
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
@ -332,10 +339,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faUserGroup} />}
onClick={() =>
navigate({
to: "/organization/groups/$groupId",
@ -344,7 +348,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
}
})
}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Manage Members
</DropdownMenuItem>
@ -356,11 +360,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
@ -368,7 +368,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
name
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>

View File

@ -17,6 +17,7 @@ import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm";
import { IdentityLdapAuthForm } from "./IdentityLdapAuthForm";
import { IdentityOciAuthForm } from "./IdentityOciAuthForm";
import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm";
import { IdentityTlsCertAuthForm } from "./IdentityTlsCertAuthForm";
import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm";
import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm";
@ -52,6 +53,7 @@ const identityAuthMethods = [
{ label: "OCI Auth", value: IdentityAuthMethod.OCI_AUTH },
{ label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH },
{ label: "LDAP Auth", value: IdentityAuthMethod.LDAP_AUTH },
{ label: "TLS Certificate Auth", value: IdentityAuthMethod.TLS_CERT_AUTH },
{
label: "JWT Auth",
value: IdentityAuthMethod.JWT_AUTH
@ -123,6 +125,15 @@ export const IdentityAuthMethodModalContent = ({
/>
)
},
[IdentityAuthMethod.TLS_CERT_AUTH]: {
render: () => (
<IdentityTlsCertAuthForm
identityId={identityAuthMethodData.identityId}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
)
},
[IdentityAuthMethod.OIDC_AUTH]: {
render: () => (

View File

@ -1,4 +1,4 @@
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@ -71,20 +71,22 @@ export const IdentitySection = withPermission(
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/identities/overview"
className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"
>
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<OrgPermissionCan
@ -93,7 +95,7 @@ export const IdentitySection = withPermission(
>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {

View File

@ -1,12 +1,15 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useCallback, useState } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faCheckCircle,
faChevronRight,
faEdit,
faEllipsisV,
faFilter,
faMagnifyingGlass,
faServer
faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@ -15,19 +18,18 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState,
FormControl,
IconButton,
Input,
Pagination,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
@ -38,7 +40,6 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
@ -63,6 +64,10 @@ type Props = {
) => void;
};
type Filter = {
roles: string[];
};
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
@ -90,7 +95,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage);
};
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const [filter, setFilter] = useState<Filter>({
roles: []
});
const organizationId = currentOrg?.id || "";
@ -103,7 +110,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
orderBy,
search: {
name: debouncedSearch ? { $contains: debouncedSearch } : undefined,
role: filteredRoles?.length ? { $in: filteredRoles } : undefined
role: filter.roles?.length ? { $in: filter.roles } : undefined
}
});
@ -113,7 +120,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
offset,
setPage
});
const filterForm = useForm<{ roles: string }>();
const { data: roles } = useGetOrgRoles(organizationId);
@ -153,79 +159,80 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}
};
const handleRoleToggle = useCallback(
(roleSlug: string) =>
setFilter((state) => {
const currentRoles = state.roles || [];
if (currentRoles.includes(roleSlug)) {
return { ...state, roles: currentRoles.filter((role) => role !== roleSlug) };
}
return { ...state, roles: [...currentRoles, roleSlug] };
}),
[]
);
const isTableFiltered = Boolean(filter.roles.length);
return (
<div>
<div className="mb-4 flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Identities"
variant="plain"
size="sm"
className={twMerge(
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Roles
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Roles to Filter Identities
</DropdownMenuLabel>
{roles?.map(({ id, slug, name }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleRoleToggle(slug);
}}
key={id}
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div>
<Popover>
<PopoverTrigger>
<IconButton
ariaLabel="filter"
variant="outline_bg"
className={filteredRoles?.length ? "border-primary" : ""}
>
<Tooltip content="Advance Filter">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl">
<div className="mb-4 border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Advance Filter
</div>
<form
onSubmit={filterForm.handleSubmit((el) => {
setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []);
})}
>
<Controller
control={filterForm.control}
name="roles"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Roles"
helperText="Eg: admin,viewer"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
type="submit"
size="xs"
colorSchema="primary"
variant="outline_bg"
className="mt-4"
>
Apply Filter
</Button>
{Boolean(filteredRoles.length) && (
<Button
size="xs"
variant="link"
className="ml-4 mt-4"
onClick={() => {
filterForm.reset({ roles: "" });
setFilteredRoles([]);
}}
>
Clear
</Button>
)}
</div>
</form>
</PopoverContent>
</Popover>
</div>
</div>
<TableContainer>
<Table>
@ -251,8 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
</IconButton>
</div>
</Th>
<Th>Role</Th>
{/* <Th>
<Th>
<div className="flex items-center">
Role
<IconButton
@ -271,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
/>
</IconButton>
</div>
</Th> */}
</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
@ -303,7 +309,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
className="h-8 w-48 bg-mineshaft-700"
position="popper"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
@ -324,21 +331,24 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="flex justify-center hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
className="w-6"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-3 p-1">
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
navigate({
@ -348,7 +358,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
Edit Identity
</DropdownMenuItem>
@ -360,11 +370,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteIdentity", {
@ -372,7 +377,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
name
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Identity
</DropdownMenuItem>
@ -398,7 +404,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
{!isPending && data && data?.identities.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0 || filteredRoles?.length > 0
debouncedSearch.trim().length > 0 || filter.roles?.length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}

View File

@ -0,0 +1,358 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
IconButton,
Input,
Tab,
TabList,
TabPanel,
Tabs,
TextArea
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import {
useAddIdentityTlsCertAuth,
useGetIdentityTlsCertAuth,
useUpdateIdentityTlsCertAuth
} from "@app/hooks/api";
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityFormTab } from "./types";
const schema = z.object({
allowedCommonNames: z.string().optional(),
caCertificate: z.string().min(1),
accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token TTL cannot be greater than 315360000"
}),
accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
message: "Access Token Max TTL cannot be greater than 315360000"
}),
accessTokenNumUsesLimit: z.string(),
accessTokenTrustedIps: z
.array(
z.object({
ipAddress: z.string().max(50)
})
)
.min(1)
});
export type FormData = z.infer<typeof schema>;
type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod"]>,
state?: boolean
) => void;
identityId?: string;
isUpdate?: boolean;
};
export const IdentityTlsCertAuthForm = ({
handlePopUpOpen,
handlePopUpToggle,
identityId,
isUpdate
}: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { subscription } = useSubscription();
const { mutateAsync: addMutateAsync } = useAddIdentityTlsCertAuth();
const { mutateAsync: updateMutateAsync } = useUpdateIdentityTlsCertAuth();
const [tabValue, setTabValue] = useState<IdentityFormTab>(IdentityFormTab.Configuration);
const { data } = useGetIdentityTlsCertAuth(identityId ?? "", {
enabled: isUpdate
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
caCertificate: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
}
});
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
useEffect(() => {
if (data) {
reset({
caCertificate: data.caCertificate,
allowedCommonNames: data.allowedCommonNames || undefined,
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
accessTokenTrustedIps: data.accessTokenTrustedIps.map(
({ ipAddress, prefix }: IdentityTrustedIp) => {
return {
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
});
} else {
reset({
caCertificate: "",
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
});
}
}, [data]);
const onFormSubmit = async ({
caCertificate,
allowedCommonNames,
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
}: FormData) => {
try {
if (!identityId) return;
if (data) {
await updateMutateAsync({
organizationId: orgId,
caCertificate,
allowedCommonNames: allowedCommonNames || null,
identityId,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
} else {
await addMutateAsync({
organizationId: orgId,
identityId,
caCertificate,
allowedCommonNames: allowedCommonNames || undefined,
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
});
}
handlePopUpToggle("identityAuthMethod", false);
createNotification({
text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
type: "success"
});
reset();
} catch {
createNotification({
text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}>
<TabList>
<Tab value={IdentityFormTab.Configuration}>Configuration</Tab>
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
<Controller
control={control}
name="caCertificate"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Certificate"
errorText={error?.message}
isError={Boolean(error)}
tooltipText="A PEM-encoded CA certificate. This will be used to validate client certificate."
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="allowedCommonNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Common Names"
isError={Boolean(error)}
isOptional
errorText={error?.message}
tooltipText="Comma separated common names allowed to authenticate against the identity. Leave empty to allow any certificate."
>
<Input {...field} placeholder="" type="text" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
tooltipText="The lifetime for an access token in seconds. This value will be referenced at renewal time."
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="2592000" type="number" min="0" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="2592000"
name="accessTokenMaxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The maximum lifetime for an access token in seconds. This value will be referenced at renewal time."
>
<Input {...field} placeholder="2592000" type="number" min="0" step="1" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="0"
name="accessTokenNumUsesLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token Max Number of Uses"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses."
>
<Input {...field} placeholder="0" type="number" min="0" step="1" />
</FormControl>
)}
/>
</TabPanel>
<TabPanel value={IdentityFormTab.Advanced}>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the 0.0.0.0/0, allowing usage from any network address."
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
});
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
</TabPanel>
</Tabs>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("identityAuthMethod", false)}
>
Cancel
</Button>
</div>
</form>
);
};

View File

@ -115,12 +115,12 @@ export const OrgMembersSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddMemberModal()}

View File

@ -4,11 +4,14 @@ import {
faArrowUp,
faCheckCircle,
faChevronRight,
faEllipsis,
faEdit,
faEllipsisV,
faFilter,
faMagnifyingGlass,
faSearch,
faUsers
faUsers,
faUserSlash,
faUserXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@ -79,7 +82,8 @@ type Props = {
enum OrgMembersOrderBy {
Name = "firstName",
Email = "email"
Email = "email",
Role = "role"
}
type Filter = {
@ -99,8 +103,10 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
const { data: serverDetails } = useFetchServerStatus();
const { data: members = [], isPending: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: resendOrgMemberInvitation } = useResendOrgMemberInvitation();
const { mutateAsync: resendOrgMemberInvitation, isPending: isResendInvitePending } =
useResendOrgMemberInvitation();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
@ -136,6 +142,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
};
const onResendInvite = async (membershipId: string) => {
setResendInviteId(membershipId);
try {
const signupToken = await resendOrgMemberInvitation({
membershipId
@ -156,6 +163,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
text: "Failed to resend org invitation",
type: "error"
});
} finally {
setResendInviteId(null);
}
};
@ -229,6 +238,16 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case OrgMembersOrderBy.Role:
valueOne =
memberOne.role === "custom"
? findRoleFromId(memberOne.roleId)!.slug
: memberOne.role;
valueTwo =
memberTwo.role === "custom"
? findRoleFromId(memberTwo.roleId)!.slug
: memberTwo.role;
break;
case OrgMembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
@ -284,7 +303,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
@ -378,7 +397,26 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-1/3">
<div className="flex items-center">
Role
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Role ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Role)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Role
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
@ -398,7 +436,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
isActive
}) => {
const name =
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : null;
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
@ -415,7 +453,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>
{name}
{name ?? <span className="text-mineshaft-400">Not Set</span>}
{u.superAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
@ -429,79 +467,77 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<>
{!isActive && (
<Button
isDisabled
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => {}}
>
Suspended
</Button>
)}
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="h-8 w-48 bg-mineshaft-700"
position="popper"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
</OrgPermissionCan>
</Td>
<Td>
<div className="flex items-center justify-end gap-6">
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
className="w-48"
isDisabled={!isAllowed || isResendInvitePending}
className="h-8 border-mineshaft-600 bg-mineshaft-700 font-normal"
colorSchema="primary"
variant="outline_bg"
isLoading={
isResendInvitePending && resendInviteId === orgMembershipId
}
onClick={(e) => {
onResendInvite(orgMembershipId);
e.stopPropagation();
}}
>
Resend invite
Resend Invite
</Button>
)}
</>
)}
</OrgPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
</OrgPermissionCan>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className={twMerge("w-6", userId === u?.id && "opacity-50")}
variant="plain"
isDisabled={userId === u?.id}
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
@ -511,7 +547,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
}
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit User
</DropdownMenuItem>
@ -523,15 +560,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
icon={<FontAwesomeIcon icon={faUserSlash} />}
onClick={async (e) => {
e.stopPropagation();
@ -560,7 +589,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
username
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
@ -572,11 +601,6 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
@ -593,7 +617,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
username
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faUserXmark} />}
>
Remove User
</DropdownMenuItem>
@ -601,7 +626,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Td>
</Tr>
);

View File

@ -6,10 +6,10 @@ export const OrgRoleTabSection = () => {
return (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgRoleTable />
</motion.div>

View File

@ -1,4 +1,17 @@
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faCopy,
faEdit,
faEllipsisV,
faEye,
faIdBadge,
faMagnifyingGlass,
faPlus,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@ -14,6 +27,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@ -30,13 +47,25 @@ import {
useOrganization,
useSubscription
} from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { isCustomOrgRole, isCustomProjectRole } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useDeleteOrgRole, useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TOrgRole } from "@app/hooks/api/roles/types";
import { DuplicateOrgRoleModal } from "@app/pages/organization/RoleByIDPage/components/DuplicateOrgRoleModal";
import { RoleModal } from "@app/pages/organization/RoleByIDPage/components/RoleModal";
enum RolesOrderBy {
Name = "name",
Slug = "slug",
Type = "type"
}
export const OrgRoleTable = () => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
@ -93,14 +122,89 @@ export const OrgRoleTable = () => {
}
};
const {
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy,
search,
setSearch,
page,
perPage,
setPerPage,
setPage,
offset
} = usePagination<RolesOrderBy>(RolesOrderBy.Type, {
initPerPage: getUserTablePreference("orgRolesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("orgRolesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRoles = useMemo(
() =>
roles
?.filter((role) => {
const { slug, name } = role;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) || slug.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [roleOne, roleTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case RolesOrderBy.Slug:
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
case RolesOrderBy.Type: {
const roleOneValue = isCustomOrgRole(roleOne.slug) ? -1 : 1;
const roleTwoValue = isCustomOrgRole(roleTwo.slug) ? -1 : 1;
return roleTwoValue - roleOneValue;
}
case RolesOrderBy.Name:
default:
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
}
}) ?? [],
[roles, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredRoles.length,
offset,
setPage
});
const handleSort = (column: RolesOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: RolesOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: RolesOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Organization Roles</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
@ -113,18 +217,63 @@ export const OrgRoleTable = () => {
)}
</OrgPermissionCan>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search roles..."
className="flex-1"
containerClassName="mb-4"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Slug)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Slug)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Slug)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Type)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Type)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Type)} />
</IconButton>
</div>
</Th>
<Th aria-label="actions" className="w-5" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={3} innerKey="org-roles" />}
{roles?.map((role) => {
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{filteredRoles?.slice(offset, perPage * page).map((role) => {
const { id, name, slug } = role;
const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug);
const isDefaultOrgRole = isCustomOrgRole(slug)
@ -162,23 +311,30 @@ export const OrgRoleTable = () => {
<Td className="max-w-md overflow-hidden text-ellipsis whitespace-nowrap">
{slug}
</Td>
<Td>
<Badge className="w-min whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
{isCustomProjectRole(slug) ? "Custom" : "Default"}
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
@ -188,7 +344,8 @@ export const OrgRoleTable = () => {
}
});
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={isNonMutatable ? faEye : faEdit} />}
>
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
@ -200,14 +357,12 @@ export const OrgRoleTable = () => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("duplicateRole", role);
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCopy} />}
>
Duplicate Role
</DropdownMenuItem>
@ -220,14 +375,12 @@ export const OrgRoleTable = () => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
disabled={!isAllowed}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handleSetRoleAsDefault(slug);
}}
icon={<FontAwesomeIcon icon={faIdBadge} />}
>
Set as Default Role
</DropdownMenuItem>
@ -250,16 +403,12 @@ export const OrgRoleTable = () => {
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed && !isDefaultOrgRole
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
}}
disabled={!isAllowed || isDefaultOrgRole}
icon={<FontAwesomeIcon icon={faTrash} />}
isDisabled={!isAllowed || isDefaultOrgRole}
>
Delete Role
</DropdownMenuItem>
@ -276,6 +425,25 @@ export const OrgRoleTable = () => {
})}
</TBody>
</Table>
{Boolean(filteredRoles?.length) && (
<Pagination
count={filteredRoles!.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!filteredRoles?.length && !isRolesLoading && (
<EmptyState
title={
roles?.length
? "No roles match search..."
: "This organization does not have any roles"
}
icon={roles?.length ? faSearch : undefined}
/>
)}
</TableContainer>
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal

View File

@ -15,6 +15,7 @@ import {
useDeleteIdentityLdapAuth,
useDeleteIdentityOciAuth,
useDeleteIdentityOidcAuth,
useDeleteIdentityTlsCertAuth,
useDeleteIdentityTokenAuth,
useDeleteIdentityUniversalAuth
} from "@app/hooks/api";
@ -29,6 +30,7 @@ import { ViewIdentityKubernetesAuthContent } from "./ViewIdentityKubernetesAuthC
import { ViewIdentityLdapAuthContent } from "./ViewIdentityLdapAuthContent";
import { ViewIdentityOciAuthContent } from "./ViewIdentityOciAuthContent";
import { ViewIdentityOidcAuthContent } from "./ViewIdentityOidcAuthContent";
import { ViewIdentityTlsCertAuthContent } from "./ViewIdentityTlsCertAuthContent";
import { ViewIdentityTokenAuthContent } from "./ViewIdentityTokenAuthContent";
import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthContent";
@ -63,6 +65,7 @@ export const Content = ({
const { mutateAsync: revokeTokenAuth } = useDeleteIdentityTokenAuth();
const { mutateAsync: revokeKubernetesAuth } = useDeleteIdentityKubernetesAuth();
const { mutateAsync: revokeGcpAuth } = useDeleteIdentityGcpAuth();
const { mutateAsync: revokeTlsCertAuth } = useDeleteIdentityTlsCertAuth();
const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth();
const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth();
const { mutateAsync: revokeAliCloudAuth } = useDeleteIdentityAliCloudAuth();
@ -93,6 +96,10 @@ export const Content = ({
revokeMethod = revokeGcpAuth;
Component = ViewIdentityGcpAuthContent;
break;
case IdentityAuthMethod.TLS_CERT_AUTH:
revokeMethod = revokeTlsCertAuth;
Component = ViewIdentityTlsCertAuthContent;
break;
case IdentityAuthMethod.AWS_AUTH:
revokeMethod = revokeAwsAuth;
Component = ViewIdentityAwsAuthContent;

View File

@ -0,0 +1,88 @@
import { faBan, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, EmptyState, Spinner, Tooltip } from "@app/components/v2";
import { useGetIdentityTlsCertAuth } from "@app/hooks/api";
import { IdentityTlsCertAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm";
import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
import { ViewAuthMethodProps } from "./types";
import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper";
export const ViewIdentityTlsCertAuthContent = ({
identityId,
handlePopUpToggle,
handlePopUpOpen,
onDelete,
popUp
}: ViewAuthMethodProps) => {
const { data, isPending } = useGetIdentityTlsCertAuth(identityId);
if (isPending) {
return (
<div className="flex w-full items-center justify-center">
<Spinner className="text-mineshaft-400" />
</div>
);
}
if (!data) {
return (
<EmptyState
icon={faBan}
title="Could not find TLS Certificate Auth associated with this Identity."
/>
);
}
if (popUp.identityAuthMethod.isOpen) {
return (
<IdentityTlsCertAuthForm
identityId={identityId}
isUpdate
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
);
}
return (
<ViewIdentityContentWrapper
onEdit={() => handlePopUpOpen("identityAuthMethod")}
onDelete={onDelete}
>
<IdentityAuthFieldDisplay label="Access Token TTL (seconds)">
{data.accessTokenTTL}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Access Token Max TTL (seconds)">
{data.accessTokenMaxTTL}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Access Token Max Number of Uses">
{data.accessTokenNumUsesLimit}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Access Token Trusted IPs">
{data.accessTokenTrustedIps.map((ip) => ip.ipAddress).join(", ")}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay className="col-span-2" label="CA Certificate">
<Tooltip
side="right"
className="max-w-xl p-2"
content={<p className="break-words rounded bg-mineshaft-600 p-2">{data.caCertificate}</p>}
>
<div className="w-min">
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faEye} />
<span>Reveal</span>
</Badge>
</div>
</Tooltip>
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay className="col-span-2" label="Allowed Common Names">
{data.allowedCommonNames
?.split(",")
.map((cn) => cn.trim())
.join(", ")}
</IdentityAuthFieldDisplay>
</ViewIdentityContentWrapper>
);
};

View File

@ -197,7 +197,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
@ -298,7 +298,8 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
{!isMembersLoading &&
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
const name =
u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : null;
const email = u?.email || inviteEmail;
return (
@ -328,7 +329,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
})
}
>
<Td>{name}</Td>
<Td>{name ?? <span className="text-mineshaft-400">Not Set</span>}</Td>
<Td>{email}</Td>
<Td>
<div className="flex items-center space-x-2">

View File

@ -18,6 +18,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
Button,
DeleteActionModal,
DropdownMenu,
@ -39,6 +40,7 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { isCustomProjectRole } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
@ -53,7 +55,8 @@ import { RoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/R
enum RolesOrderBy {
Name = "name",
Slug = "slug"
Slug = "slug",
Type = "type"
}
export const ProjectRoleList = () => {
@ -98,7 +101,7 @@ export const ProjectRoleList = () => {
setPerPage,
setPage,
offset
} = usePagination<RolesOrderBy>(RolesOrderBy.Name, {
} = usePagination<RolesOrderBy>(RolesOrderBy.Type, {
initPerPage: getUserTablePreference("projectRolesTable", PreferenceKey.PerPage, 20)
});
@ -125,6 +128,12 @@ export const ProjectRoleList = () => {
switch (orderBy) {
case RolesOrderBy.Slug:
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
case RolesOrderBy.Type: {
const roleOneValue = isCustomProjectRole(roleOne.slug) ? -1 : 1;
const roleTwoValue = isCustomProjectRole(roleTwo.slug) ? -1 : 1;
return roleTwoValue - roleOneValue;
}
case RolesOrderBy.Name:
default:
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
@ -210,11 +219,24 @@ export const ProjectRoleList = () => {
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Type)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Type)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Type)} />
</IconButton>
</div>
</Th>
<Th aria-label="actions" className="w-5" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={3} innerKey="project-roles" />}
{isRolesLoading && <TableSkeleton columns={4} innerKey="project-roles" />}
{filteredRoles?.slice(offset, perPage * page).map((role) => {
const { id, name, slug } = role;
const isNonMutatable = Object.values(ProjectMembershipRole).includes(
@ -237,6 +259,11 @@ export const ProjectRoleList = () => {
>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<Badge className="w-min whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
{isCustomProjectRole(slug) ? "Custom" : "Default"}
</Badge>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>

View File

@ -12,7 +12,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
import { Button, Checkbox, IconButton, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
@ -241,16 +241,19 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
/>
</Tooltip>
{!isDisabled && (
<Button
leftIcon={<FontAwesomeIcon icon={faTrash} />}
variant="outline_bg"
size="xs"
className="ml-auto mr-3"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove Rule
</Button>
<Tooltip content="Remove Rule">
<IconButton
ariaLabel="Remove rule"
colorSchema="danger"
variant="plain"
size="xs"
className="ml-auto mr-3 rounded"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
{!isDisabled && (
<Tooltip position="left" content="Drag to reorder permission">
@ -271,16 +274,19 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
<div className="flex w-full justify-between">
<div className="mb-2">Actions</div>
{!isDisabled && !isConditionalSubjects(subject) && (
<Button
leftIcon={<FontAwesomeIcon icon={faTrash} />}
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove Rule
</Button>
<Tooltip content="Remove Rule">
<IconButton
ariaLabel="Remove rule"
colorSchema="danger"
variant="plain"
size="xs"
className="ml-auto rounded"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</div>
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">

View File

@ -247,9 +247,7 @@ export const AccessApprovalRequest = ({
};
else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`,
label: "Pending Additional Reviews",
type: "primary",
icon: faClipboardCheck
};

View File

@ -1,10 +1,9 @@
import { useCallback, useMemo, useState } from "react";
import { ReactNode, useCallback, useMemo, useState } from "react";
import {
faCheckCircle,
faCircle,
faTriangleExclamation,
faUsers,
faXmarkCircle
faBan,
faCheck,
faHourglass,
faTriangleExclamation
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
@ -15,12 +14,10 @@ import {
Button,
Checkbox,
FormControl,
GenericFieldLabel,
Input,
Modal,
ModalContent,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@ -38,10 +35,22 @@ import { groupBy } from "@app/lib/fn/array";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
return (
<Badge variant="success" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faCheck} size="xs" />
</Badge>
);
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
return (
<Badge variant="danger" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faBan} size="xs" />
</Badge>
);
return (
<Badge variant="primary" className="flex h-4 items-center justify-center">
<FontAwesomeIcon icon={faHourglass} size="xs" />
</Badge>
);
};
export const ReviewAccessRequestModal = ({
@ -267,139 +276,160 @@ export const ReviewAccessRequestModal = ({
</div>
<div className="">
<div className="mb-2 mt-4 text-mineshaft-200">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Environment</div>
<div>{accessDetails.env || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Secret Path</div>
<div>{accessDetails.secretPath || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Access Type</div>
<div>{getAccessLabel()}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Permission</div>
<div>{requestedAccess}</div>
</div>
<div className="col-span-2">
<div className="mb-1 text-xs font-semibold uppercase">Note</div>
<div>{request.note || "-"}</div>
</div>
<div className="flex flex-wrap gap-8">
<GenericFieldLabel label="Environment">{accessDetails.env}</GenericFieldLabel>
<GenericFieldLabel truncate label="Secret Path">
{accessDetails.secretPath}
</GenericFieldLabel>
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel>
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
{request.note && (
<GenericFieldLabel className="col-span-full" label="Note">
{request.note}
</GenericFieldLabel>
)}
</div>
</div>
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
<div className="thin-scrollbar max-h-64 overflow-y-auto rounded p-2">
{approverSequence?.approvers?.map((approver, index) => (
<div
key={`approval-list-${index + 1}`}
className={twMerge(
"relative mb-2 flex items-center rounded border border-mineshaft-500 bg-mineshaft-700 p-4",
approverSequence?.currentSequence !== approver.sequence &&
!hasApproved &&
"text-mineshaft-400"
)}
>
<div>
<div
className={twMerge(
"mr-8 flex h-8 w-8 items-center justify-center text-3xl font-medium",
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
>
{index + 1}
</div>
{index !== (approverSequence?.approvers?.length || 0) - 1 && (
<div
className={twMerge(
"absolute bottom-0 left-8 h-5 border-r-2 border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
{index !== 0 && (
<div
className={twMerge(
"absolute left-8 top-0 h-5 border-r-2 border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(",") || "-"}
</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(",") || "-"}
</div>
</div>
<div className="flex items-center">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{approver.approvals || "-"}</div>
</div>
<div className="ml-16">
<Popover>
<PopoverTrigger>
<FontAwesomeIcon icon={faUsers} />
</PopoverTrigger>
<PopoverContent hideCloseBtn className="pt-3">
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col gap-1 overflow-y-auto rounded">
{approver.reviewers.map((el, idx) => (
<div
key={`reviewer-${idx + 1}`}
className="flex items-center gap-2 bg-mineshaft-700 p-1 text-sm"
>
<div className="flex-grow">{el.username}</div>
<Tooltip
content={`Status: ${el?.status || ApprovalStatus.PENDING}`}
>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</Tooltip>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
))}
<div className="mt-4 flex items-center justify-between border-b-2 border-mineshaft-500 py-2">
<span>Approvers</span>
{approverSequence.isMyReviewInThisSequence &&
request.status === ApprovalStatus.PENDING && (
<Badge variant="primary" className="h-min">
Awaiting Your Review
</Badge>
)}
</div>
<div className="thin-scrollbar max-h-[40vh] overflow-y-auto rounded py-2">
{approverSequence?.approvers &&
approverSequence.approvers.map((approver, index) => {
const isInactive =
approverSequence?.currentSequence <
(approver.sequence ?? approverSequence.approvers!.length);
const isPending = approverSequence?.currentSequence === approver.sequence;
let StepComponent: ReactNode;
let BadgeComponent: ReactNode = null;
if (approver.hasRejected) {
StepComponent = (
<Badge
variant="danger"
className="flex h-6 min-w-6 items-center justify-center"
>
<FontAwesomeIcon icon={faBan} />
</Badge>
);
BadgeComponent = <Badge variant="danger">Rejected</Badge>;
} else if (approver.hasApproved) {
StepComponent = (
<Badge
variant="success"
className="flex h-6 min-w-6 items-center justify-center"
>
<FontAwesomeIcon icon={faCheck} />
</Badge>
);
BadgeComponent = <Badge variant="success">Approved</Badge>;
} else if (isPending) {
StepComponent = (
<Badge
variant="primary"
className="flex h-6 min-w-6 items-center justify-center"
>
<FontAwesomeIcon icon={faHourglass} />
</Badge>
);
BadgeComponent = <Badge variant="primary">Pending</Badge>;
} else {
StepComponent = (
<Badge
className={
isInactive
? "py-auto my-auto flex h-6 min-w-6 items-center justify-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-center text-bunker-200"
: ""
}
>
<span>{index + 1}</span>
</Badge>
);
}
return (
<div
key={`approval-list-${index + 1}`}
className={twMerge("flex", isInactive && "opacity-50")}
>
{approverSequence.approvers!.length > 1 && (
<div className="flex w-12 flex-col items-center gap-2 pr-4">
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index !== 0 && "border-r"
)}
/>
{StepComponent}
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index < approverSequence.approvers!.length - 1 && "border-r"
)}
/>
</div>
)}
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
<GenericFieldLabel className="col-span-2" label="Users">
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(", ")}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-2" label="Groups">
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(", ")}
</GenericFieldLabel>
<GenericFieldLabel label="Approvals Required">
<div className="flex items-center">
<span className="mr-2">{approver.approvals}</span>
{BadgeComponent && (
<Tooltip
className="max-w-lg"
content={
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col divide-y divide-mineshaft-500 overflow-y-auto rounded">
{approver.reviewers.map((el, idx) => (
<div
key={`reviewer-${idx + 1}`}
className="flex items-center gap-2 px-2 py-2 text-sm"
>
<div className="flex-1">{el.username}</div>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</div>
))}
</div>
</div>
}
>
<div>{BadgeComponent}</div>
</Tooltip>
)}
</div>
</GenericFieldLabel>
</div>
</div>
);
})}
</div>
{approverSequence.isMyReviewInThisSequence &&
request.status === ApprovalStatus.PENDING && (
<div className="mb-4 rounded-r border-l-2 border-l-primary-400 bg-mineshaft-300/5 px-4 py-2.5 text-sm">
Awaiting review from you.
</div>
)}
{shouldBlockRequestActions ? (
<div
className={twMerge(
"mb-4 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
"mt-2 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
isReviewedByMe && "border-l-green-400",
!approverSequence.isMyReviewInThisSequence && "border-l-primary-400",
hasRejected && "border-l-red-500"
@ -409,6 +439,35 @@ export const ReviewAccessRequestModal = ({
</div>
) : (
<>
{isSoftEnforcement && request.isRequestedByCurrentUser && canBypass && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
className={twMerge("mr-2", bypassApproval ? "!border-red/30 !bg-red/10" : "")}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
@ -423,6 +482,7 @@ export const ReviewAccessRequestModal = ({
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
variant="outline_bg"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
@ -444,43 +504,6 @@ export const ReviewAccessRequestModal = ({
Reject Request
</Button>
</div>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypass && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy
protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
</>
)}
</div>

View File

@ -1,5 +1,12 @@
import { useMemo } from "react";
import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import {
faClipboardCheck,
faEdit,
faEllipsisV,
faTrash,
faUser,
faUserGroup
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@ -179,26 +186,40 @@ export const ApprovalPolicyRow = ({
}`}
>
<div className="p-4">
<div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
<div className="border-b-2 border-mineshaft-500 pb-2">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
>
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
<div>{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
<div key={`approval-list-${index + 1}`} className="flex">
{labels.length > 1 && (
<div className="flex w-12 flex-col items-center gap-2 pr-4">
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index !== 0 && "border-r"
)}
/>
{labels.length > 1 && (
<Badge className="my-auto flex h-5 w-min min-w-5 items-center justify-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-center text-bunker-200">
<span>{index + 1}</span>
</Badge>
)}
<div
className={twMerge(
"flex-grow border-mineshaft-600",
index < labels.length - 1 && "border-r"
)}
/>
</div>
)}
{index !== 0 && (
<div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
)}
<div className="grid flex-grow grid-cols-3">
<GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
<div className="grid flex-1 grid-cols-5 border-b border-mineshaft-600 p-4">
<GenericFieldLabel className="col-span-2" icon={faUser} label="Users">
{el.userLabels}
</GenericFieldLabel>
<GenericFieldLabel className="col-span-2" icon={faUserGroup} label="Groups">
{el.groupLabels}
</GenericFieldLabel>
<GenericFieldLabel icon={faClipboardCheck} label="Approvals Required">
{el.approvals}
</GenericFieldLabel>
</div>
</div>
))}

View File

@ -102,43 +102,73 @@ export const SecretApprovalRequestAction = ({
if (!hasMerged && status === "open") {
return (
<div className="flex w-full flex-col items-start justify-between py-4 transition-all">
<div className="flex items-center space-x-4 px-4">
<div
className={`flex items-center justify-center rounded-full ${isMergable ? "h-10 w-10 bg-green" : "h-11 w-11 bg-red-600"}`}
>
<FontAwesomeIcon
icon={isMergable ? faCheck : faXmark}
className={isMergable ? "text-lg text-black" : "text-2xl text-white"}
/>
<div className="flex w-full flex-col items-start justify-between py-4 text-mineshaft-100 transition-all">
<div className="flex w-full flex-col justify-between xl:flex-row xl:items-center">
<div className="mr-auto flex items-center space-x-4 px-4">
<div
className={`flex items-center justify-center rounded-full ${isMergable ? "h-8 w-8 bg-green" : "h-10 w-10 bg-red-600"}`}
>
<FontAwesomeIcon
icon={isMergable ? faCheck : faXmark}
className={isMergable ? "text-lg text-white" : "text-2xl text-white"}
/>
</div>
<span className="flex flex-col">
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
{isMergable ? "Good to merge" : "Merging is blocked"}
</p>
{!isMergable && (
<span className="inline-block text-xs text-mineshaft-300">
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
eligible reviewers.
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
)}
</span>
</div>
<span className="flex flex-col">
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
{isMergable ? "Good to merge" : "Merging is blocked"}
</p>
{!isMergable && (
<span className="inline-block text-xs text-bunker-200">
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
eligible reviewers.
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
<div className="mt-4 flex items-center justify-end space-x-2 px-4 xl:mt-0">
{canApprove || isSoftEnforcement ? (
<div className="flex items-center space-x-4">
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
className="hover:border-red/60 hover:bg-red/10"
>
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
isDisabled={
!(
(isMergable && canApprove) ||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
)
}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
variant="outline_bg"
>
Merge
</Button>
</div>
) : (
<div className="text-sm text-mineshaft-400">Only approvers can merge</div>
)}
</span>
</div>
</div>
{isSoftEnforcement && !isMergable && isBypasser && (
<div
className={`mt-4 w-full border-mineshaft-600 px-5 ${isMergable ? "border-t pb-2" : "border-y pb-4"}`}
>
<div className="mt-4 w-full border-t border-mineshaft-600 px-5">
<div className="mt-2 flex flex-col space-y-2 pt-2">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
className={twMerge("mr-2", byPassApproval ? "!border-red/30 !bg-red/10" : "")}
>
<span className="text-sm">
Merge without waiting for approval (bypass secret change policy)
@ -162,51 +192,18 @@ export const SecretApprovalRequestAction = ({
</div>
</div>
)}
<div className="mt-2 flex w-full items-center justify-end space-x-2 px-4">
{canApprove || isSoftEnforcement ? (
<div className="flex items-center space-x-4">
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
className="hover:border-red/60 hover:bg-red/10"
>
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
isDisabled={
!(
(isMergable && canApprove) ||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
)
}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
variant="solid"
>
Merge
</Button>
</div>
) : (
<div>Only approvers can merge</div>
)}
</div>
</div>
);
}
if (hasMerged && status === "close")
return (
<div className="flex w-full items-center justify-between rounded-md border border-primary/60 bg-primary/10">
<div className="flex items-start space-x-4 p-4">
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
<div className="flex w-full items-center justify-between rounded-md border border-green/60 bg-green/10">
<div className="flex items-start space-x-2 p-4">
<FontAwesomeIcon icon={faCheck} className="mt-0.5 text-xl text-green" />
<span className="flex flex-col">
Change request merged
<span className="inline-block text-xs text-bunker-200">
<span className="inline-block text-xs text-mineshaft-300">
Merged by {statusChangeByEmail}.
</span>
</span>
@ -215,26 +212,26 @@ export const SecretApprovalRequestAction = ({
);
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-start space-x-4">
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
<div className="flex w-full items-center justify-between rounded-md border border-yellow/60 bg-yellow/10">
<div className="flex items-start space-x-2 p-4">
<FontAwesomeIcon icon={faUserLock} className="mt-0.5 text-xl text-yellow" />
<span className="flex flex-col">
Secret approval has been closed
<span className="inline-block text-xs text-bunker-200">
<span className="inline-block text-xs text-mineshaft-300">
Closed by {statusChangeByEmail}
</span>
</span>
</div>
<div className="flex items-center space-x-6">
<Button
onClick={() => handleSecretApprovalStatusChange("open")}
isLoading={isStatusChanging}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Reopen request
</Button>
</div>
<Button
onClick={() => handleSecretApprovalStatusChange("open")}
isLoading={isStatusChanging}
variant="plain"
colorSchema="secondary"
className="mr-4 text-yellow/60 hover:text-yellow"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Reopen request
</Button>
</div>
);
};

View File

@ -3,11 +3,12 @@
/* eslint-disable no-nested-ternary */
import { useState } from "react";
import {
faCircleCheck,
faCircleXmark,
faExclamationTriangle,
faEye,
faEyeSlash,
faInfo,
faInfoCircle,
faKey
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -29,14 +30,14 @@ export type Props = {
};
const generateItemTitle = (op: CommitType) => {
let text = { label: "", color: "" };
if (op === CommitType.CREATE) text = { label: "create", color: "#60DD00" };
else if (op === CommitType.UPDATE) text = { label: "change", color: "#F8EB30" };
else text = { label: "deletion", color: "#F83030" };
let text = { label: "", className: "" };
if (op === CommitType.CREATE) text = { label: "create", className: "text-green-600" };
else if (op === CommitType.UPDATE) text = { label: "change", className: "text-yellow-600" };
else text = { label: "deletion", className: "text-red-600" };
return (
<div className="text-md pb-2 font-medium">
Request for <span style={{ color: text.color }}>secret {text.label}</span>
Request for <span className={text.className}>secret {text.label}</span>
</div>
);
};
@ -68,15 +69,15 @@ export const SecretApprovalRequestChangeItem = ({
<div className="flex items-center px-1 py-1">
<div className="flex-grow">{generateItemTitle(op)}</div>
{!hasMerged && isStale && (
<div className="flex items-center">
<FontAwesomeIcon icon={faInfo} className="text-sm text-primary-600" />
<span className="ml-2 text-xs">Secret has been changed(stale)</span>
<div className="flex items-center text-mineshaft-300">
<FontAwesomeIcon icon={faInfoCircle} className="text-xs" />
<span className="ml-1 text-xs">Secret has been changed (stale)</span>
</div>
)}
{hasMerged && hasConflict && (
<div className="flex items-center space-x-2 text-sm text-bunker-300">
<div className="flex items-center space-x-1 text-xs text-bunker-300">
<Tooltip content="Merge Conflict">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-700" />
<FontAwesomeIcon icon={faExclamationTriangle} className="text-xs text-red" />
</Tooltip>
<div>{generateConflictText(op)}</div>
</div>
@ -95,7 +96,7 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Key</div>
<div className="text-sm">{secretVersion?.secretKey} </div>
<p className="max-w-lg break-words text-sm">{secretVersion?.secretKey}</p>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Value</div>
@ -147,7 +148,7 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
<div className="max-h-[5rem] overflow-y-auto text-sm">
<div className="thin-scrollbar max-h-[5rem] max-w-[34rem] overflow-y-auto break-words text-sm xl:max-w-[28rem]">
{secretVersion?.secretComment || (
<span className="text-sm text-mineshaft-300">-</span>
)}{" "}
@ -186,15 +187,27 @@ export const SecretApprovalRequestChangeItem = ({
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.key}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.key}
</div>
</Tooltip>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.value}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tooltip>
</Tag>
</div>
))}
@ -215,13 +228,13 @@ export const SecretApprovalRequestChangeItem = ({
<div className="mb-4 flex flex-row justify-between">
<span className="text-md font-medium">New Secret</span>
<div className="rounded-full bg-green-600 px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
<FontAwesomeIcon icon={faCircleCheck} className="pr-1 text-white" />
New
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Key</div>
<div className="text-sm">{newVersion?.secretKey} </div>
<div className="max-w-md break-words text-sm">{newVersion?.secretKey} </div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Value</div>
@ -273,7 +286,7 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
<div className="max-h-[5rem] overflow-y-auto text-sm">
<div className="thin-scrollbar max-h-[5rem] max-w-[34rem] overflow-y-auto break-words text-sm xl:max-w-[28rem]">
{newVersion?.secretComment || (
<span className="text-sm text-mineshaft-300">-</span>
)}{" "}
@ -281,15 +294,15 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-y-2">
{(newVersion?.tags?.length ?? 0) ? (
newVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
className="flex w-min items-center space-x-1.5 border border-mineshaft-500 bg-mineshaft-800"
key={`${newVersion.id}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
@ -311,15 +324,27 @@ export const SecretApprovalRequestChangeItem = ({
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.key}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.key}
</div>
</Tooltip>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.value}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tooltip>
</Tag>
</div>
))}

View File

@ -3,12 +3,12 @@ import { Controller, useForm } from "react-hook-form";
import {
faAngleDown,
faArrowLeft,
faCheckCircle,
faCircle,
faBan,
faCheck,
faCodeBranch,
faComment,
faFolder,
faXmarkCircle
faHourglass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
@ -26,6 +26,7 @@ import {
DropdownMenuTrigger,
EmptyState,
FormControl,
GenericFieldLabel,
IconButton,
TextArea,
Tooltip
@ -81,10 +82,10 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
return <FontAwesomeIcon icon={faCheck} size="xs" className="text-green" />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
return <FontAwesomeIcon icon={faBan} size="xs" className="text-red" />;
return <FontAwesomeIcon icon={faHourglass} size="xs" className="text-yellow" />;
};
type Props = {
@ -223,8 +224,8 @@ export const SecretApprovalRequestChanges = ({
const hasMerged = secretApprovalRequestDetails?.hasMerged;
return (
<div className="flex space-x-6">
<div className="flex-grow">
<div className="flex flex-col space-x-6 lg:flex-row">
<div className="flex-1 lg:max-w-[calc(100%-17rem)]">
<div className="sticky top-0 z-20 flex items-center space-x-4 bg-bunker-800 pb-6 pt-2">
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
<FontAwesomeIcon icon={faArrowLeft} />
@ -242,17 +243,17 @@ export const SecretApprovalRequestChanges = ({
: secretApprovalRequestDetails.status}
</span>
</div>
<div className="flex-grow flex-col">
<div className="-mt-0.5 flex-grow flex-col">
<div className="text-xl">
{generateCommitText(
secretApprovalRequestDetails.commits,
secretApprovalRequestDetails.isReplicated
)}
</div>
<div className="flex items-center space-x-2 text-xs text-gray-400">
<span className="-mt-1 flex items-center space-x-2 text-xs text-gray-400">
By {secretApprovalRequestDetails?.committerUser?.firstName} (
{secretApprovalRequestDetails?.committerUser?.email})
</div>
</span>
</div>
{!hasMerged &&
secretApprovalRequestDetails.status === "open" &&
@ -262,7 +263,10 @@ export const SecretApprovalRequestChanges = ({
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
>
<DropdownMenuTrigger asChild>
<Button rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}>
<Button
colorSchema="secondary"
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
>
Review
</Button>
</DropdownMenuTrigger>
@ -279,82 +283,87 @@ export const SecretApprovalRequestChanges = ({
{...field}
placeholder="Leave a comment..."
reSize="none"
className="text-md mt-2 h-40 border border-mineshaft-600 bg-bunker-800"
className="text-md mt-2 h-40 border border-mineshaft-600 bg-mineshaft-800 placeholder:text-mineshaft-400"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="mb-4 space-y-2"
aria-label="Status"
<div className="flex justify-between">
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-300 text-red focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<div className="flex justify-end">
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="space-y-2"
aria-label="Status"
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-400 text-green focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-400 text-red focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<Button
type="submit"
isLoading={isApproving || isRejecting || isSubmitting}
variant="outline_bg"
className="mt-auto h-min"
>
Submit Review
</Button>
@ -371,14 +380,14 @@ export const SecretApprovalRequestChanges = ({
<div className="text-sm text-bunker-300">
A secret import in
<p
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
style={{ padding: "2px 4px" }}
>
{secretApprovalRequestDetails?.environment}
</p>
<div className="mr-2 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
</p>
<Tooltip content={approvalSecretPath}>
<p
@ -391,14 +400,14 @@ export const SecretApprovalRequestChanges = ({
</div>
has pending changes to be accepted from its source at{" "}
<p
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
style={{ padding: "2px 4px" }}
>
{replicatedImport?.importEnv?.slug}
</p>
<div className="inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
</p>
<Tooltip content={replicatedImport?.importPath}>
<p
@ -415,14 +424,14 @@ export const SecretApprovalRequestChanges = ({
<div className="text-sm text-bunker-300">
<p className="inline">Secret(s) in</p>
<p
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
style={{ padding: "2px 4px" }}
>
{secretApprovalRequestDetails?.environment}
</p>
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
</p>
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
<p
@ -463,7 +472,7 @@ export const SecretApprovalRequestChanges = ({
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4"
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4 text-sm text-mineshaft-100"
key={`required-approver-${requiredApprover.userId}`}
>
<div>
@ -477,14 +486,16 @@ export const SecretApprovalRequestChanges = ({
{reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"}
</span>{" "}
the request on{" "}
{format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}.
{format(
new Date(secretApprovalRequestDetails.createdAt),
"MM/dd/yyyy h:mm:ss aa"
)}
.
</div>
{reviewer?.comment && (
<FormControl label="Comment" className="mb-0 mt-4">
<TextArea value={reviewer.comment} isDisabled reSize="none">
{reviewer?.comment && reviewer.comment}
</TextArea>
</FormControl>
<GenericFieldLabel label="Comment" className="mt-2 max-w-4xl break-words">
{reviewer?.comment && reviewer.comment}
</GenericFieldLabel>
)}
</div>
);
@ -505,7 +516,7 @@ export const SecretApprovalRequestChanges = ({
/>
</div>
</div>
<div className="sticky top-0 w-1/5 cursor-default pt-4" style={{ minWidth: "240px" }}>
<div className="sticky top-0 z-[51] w-1/5 cursor-default pt-2" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers
@ -526,17 +537,17 @@ export const SecretApprovalRequestChanges = ({
requiredApprover.lastName || ""
}`}
>
<span>{requiredApprover?.email} </span>
<span>{requiredApprover?.email}</span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
{reviewer?.comment && (
<Tooltip content={reviewer.comment}>
<Tooltip className="max-w-lg break-words" content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
className="mr-1.5 text-mineshaft-300"
/>
</Tooltip>
)}

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