Compare commits

..

39 Commits

Author SHA1 Message Date
carlosmonastyrski
8044999785 feat(telemetry): increase even redis key exp to 15 mins 2025-06-27 14:31:54 -03:00
carlosmonastyrski
be51e4372d feat(telemetry): addressed PR suggestions 2025-06-27 14:30:31 -03:00
carlosmonastyrski
0f04890d8f feat(telemetry): addressed PR suggestions 2025-06-26 21:18:07 -03:00
carlosmonastyrski
61274243e2 feat(telemetry): add batch events and groups logic 2025-06-26 20:58:01 -03:00
Sheen
842a2e9a06 Merge pull request #3834 from Infisical/misc/add-self-serve-for-github-app-connection-setup
misc: add self-serve for github app connection setup
2025-06-24 02:45:51 +08:00
Akhil Mohan
de81d2d380 Merge pull request #3833 from akhilmhdh/feat/pg-queue
feat: migrated dynamic secret to pg queue and corrected service layer
2025-06-23 23:51:06 +05:30
=
f5d769fa05 feat: addressed review comments 2025-06-23 23:38:07 +05:30
Scott Wilson
b3ace353ce Merge pull request #3843 from Infisical/email-verify-more-aggressive-rate-limit
improvement(verify-endpoints): add more aggressive rate limiting to verify endpoints
2025-06-23 10:43:25 -07:00
x032205
48353ab201 Merge pull request #3842 from Infisical/sort-tax-id-dropdown
sort tax ID dropdown
2025-06-23 13:40:01 -04:00
Scott Wilson
2137d13157 improve key check operator 2025-06-23 10:36:09 -07:00
Scott Wilson
647e13d654 improvement: add more aggressive rate limiting to verify endpoints 2025-06-23 10:27:36 -07:00
x032205
bb2a933a39 sort tax ID dropdown 2025-06-23 13:26:54 -04:00
Daniel Hougaard
6f75debb9c Merge pull request #3841 from Infisical/daniel/fix-k8s-dynamic-secret-without-gateway
fix(dynamic-secrets/k8s): fix for SSL when not using gateway
2025-06-23 21:26:20 +04:00
Daniel Hougaard
90588bc3c9 fix(dynamic-secrets/k8s): fix for SSL when not using gateway 2025-06-23 21:18:15 +04:00
Sheen
4a09fc5e63 Merge pull request #3840 from Infisical/doc/added-architecture-doc-for-cloud
doc: architecture for US and EU cloud
2025-06-24 00:53:54 +08:00
Sheen Capadngan
f0ec8c883f misc: addressed comments 2025-06-24 00:52:18 +08:00
Sheen Capadngan
b30706607f misc: changed from for to of 2025-06-23 21:13:59 +08:00
Sheen Capadngan
2a3d19dcb2 misc: finalized title 2025-06-23 19:31:19 +08:00
Sheen Capadngan
b4ff620b44 doc: removed specifics 2025-06-23 19:28:05 +08:00
Sheen Capadngan
23f1888123 misc: added mention of separated AWS accounts 2025-06-23 19:16:08 +08:00
Sheen Capadngan
7764f63299 misc: made terms consistent 2025-06-23 19:12:09 +08:00
Sheen Capadngan
cb3365afd4 misc: removed troubleshooting section 2025-06-23 19:08:36 +08:00
Sheen Capadngan
58705ffc3f doc: removed duplicate permission block 2025-06-23 19:03:50 +08:00
Sheen Capadngan
67e57d8993 doc: added mention of NAT 2025-06-23 19:00:45 +08:00
Sheen Capadngan
90ff13a6b5 doc: architecture for US and EU cloud 2025-06-23 18:49:26 +08:00
Maidul Islam
36145a15c1 Merge pull request #3838 from Infisical/docs-update
upgrade mintlify docs
2025-06-23 03:38:53 -04:00
Vladyslav Matsiiako
4f64ed6b42 upgrade mintlify docs 2025-06-22 17:25:17 -07:00
Scott Wilson
d47959ca83 Merge pull request #3822 from Infisical/approval-ui-revisions
improvements(approval-workflows): Improve Approval Workflow Tables and Add Additional Functionality
2025-06-20 15:25:19 -07:00
Scott Wilson
3b2953ca58 chore: revert license 2025-06-20 12:37:24 -07:00
Scott Wilson
1daa503e0e improvement: add space to users/groups list label 2025-06-20 12:34:20 -07:00
Scott Wilson
d69e8d2a8d deconflict merge 2025-06-20 12:33:37 -07:00
Scott Wilson
7c7af347fc improvements: address feedback and fix bugs 2025-06-20 12:25:28 -07:00
Akhil Mohan
e5a967b918 Update license-fns.ts 2025-06-20 23:50:03 +05:30
=
3cfe2223b6 feat: migrated dynamic secret to pg queue and corrected service layer types to non infer version 2025-06-20 23:32:40 +05:30
Scott Wilson
7e9743b4c2 improvement: standardize and update server side pagination for change requests 2025-06-19 09:39:42 -07:00
Scott Wilson
34cf544b3a fix: correct empty state/search logic 2025-06-19 09:39:42 -07:00
Scott Wilson
12fd063cd5 improvements: minor ui adjustments/additions and pagination for access request table 2025-06-19 09:39:42 -07:00
Scott Wilson
8fb6063686 improvement: better badge color 2025-06-19 09:39:42 -07:00
Scott Wilson
459b262865 improvements: improve approval tables UI and add additional functionality 2025-06-19 09:39:42 -07:00
66 changed files with 4296 additions and 3085 deletions

View File

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

View File

@@ -26,6 +26,7 @@ export const mockQueue = (): TQueueServiceFactory => {
getRepeatableJobs: async () => [], getRepeatableJobs: async () => [],
clearQueue: async () => {}, clearQueue: async () => {},
stopJobById: async () => {}, stopJobById: async () => {},
stopJobByIdPg: async () => {},
stopRepeatableJobByJobId: async () => true, stopRepeatableJobByJobId: async () => true,
stopRepeatableJobByKey: async () => true stopRepeatableJobByKey: async () => true
}; };

View File

@@ -10,8 +10,8 @@ import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/au
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-types";
import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types"; import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types";
import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service";
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types";
import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-types";
import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";

View File

@@ -89,7 +89,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
schema: { schema: {
querystring: z.object({ querystring: z.object({
projectSlug: z.string().trim(), projectSlug: z.string().trim(),
authorProjectMembershipId: z.string().trim().optional(), authorUserId: z.string().trim().optional(),
envSlug: z.string().trim().optional() envSlug: z.string().trim().optional()
}), }),
response: { response: {
@@ -143,7 +143,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
handler: async (req) => { handler: async (req) => {
const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({ const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({
projectSlug: req.query.projectSlug, projectSlug: req.query.projectSlug,
authorProjectMembershipId: req.query.authorProjectMembershipId, authorUserId: req.query.authorUserId,
envSlug: req.query.envSlug, envSlug: req.query.envSlug,
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,

View File

@@ -30,6 +30,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
workspaceId: z.string().trim(), workspaceId: z.string().trim(),
environment: z.string().trim().optional(), environment: z.string().trim().optional(),
committer: z.string().trim().optional(), committer: z.string().trim().optional(),
search: z.string().trim().optional(),
status: z.nativeEnum(RequestState).optional(), status: z.nativeEnum(RequestState).optional(),
limit: z.coerce.number().default(20), limit: z.coerce.number().default(20),
offset: z.coerce.number().default(0) offset: z.coerce.number().default(0)
@@ -66,13 +67,14 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
userId: z.string().nullable().optional() userId: z.string().nullable().optional()
}) })
.array() .array()
}).array() }).array(),
totalCount: z.number()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const approvals = await server.services.secretApprovalRequest.getSecretApprovals({ const { approvals, totalCount } = await server.services.secretApprovalRequest.getSecretApprovals({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
@@ -80,7 +82,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
...req.query, ...req.query,
projectId: req.query.workspaceId projectId: req.query.workspaceId
}); });
return { approvals }; return { approvals, totalCount };
} }
}); });

View File

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

View File

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

View File

@@ -725,16 +725,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
) )
.where(`${TableName.Environment}.projectId`, projectId) .where(`${TableName.Environment}.projectId`, projectId)
.where(`${TableName.AccessApprovalPolicy}.deletedAt`, null)
.select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"))
.select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId"))
.select(db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"));
const formattedRequests = sqlNestRelationships({ const formattedRequests = sqlNestRelationships({
data: accessRequests, data: accessRequests,
key: "id", key: "id",
parentMapper: (doc) => ({ parentMapper: (doc) => ({
...AccessApprovalRequestsSchema.parse(doc) ...AccessApprovalRequestsSchema.parse(doc),
isPolicyDeleted: Boolean(doc.policyDeletedAt)
}), }),
childrenMapper: [ childrenMapper: [
{ {
@@ -751,7 +752,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
(req) => (req) =>
!req.privilegeId && !req.privilegeId &&
!req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) &&
req.status === ApprovalStatus.PENDING req.status === ApprovalStatus.PENDING &&
!req.isPolicyDeleted
); );
// an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required. // an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required.
@@ -759,7 +761,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
(req) => (req) =>
req.privilegeId || req.privilegeId ||
req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) ||
req.status !== ApprovalStatus.PENDING req.status !== ApprovalStatus.PENDING ||
req.isPolicyDeleted
); );
return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length };

View File

@@ -275,7 +275,7 @@ export const accessApprovalRequestServiceFactory = ({
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug, projectSlug,
authorProjectMembershipId, authorUserId,
envSlug, envSlug,
actor, actor,
actorOrgId, actorOrgId,
@@ -300,8 +300,8 @@ export const accessApprovalRequestServiceFactory = ({
const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); const policies = await accessApprovalPolicyDAL.find({ projectId: project.id });
let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id));
if (authorProjectMembershipId) { if (authorUserId) {
requests = requests.filter((request) => request.requestedByUserId === actorId); requests = requests.filter((request) => request.requestedByUserId === authorUserId);
} }
if (envSlug) { if (envSlug) {

View File

@@ -31,7 +31,7 @@ export type TCreateAccessApprovalRequestDTO = {
export type TListApprovalRequestsDTO = { export type TListApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
authorProjectMembershipId?: string; authorUserId?: string;
envSlug?: string; envSlug?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;

View File

@@ -3,9 +3,43 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { DynamicSecretLeasesSchema, TableName } from "@app/db/schemas"; import { DynamicSecretLeasesSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols, TOrmify } from "@app/lib/knex";
export type TDynamicSecretLeaseDALFactory = ReturnType<typeof dynamicSecretLeaseDALFactory>; export interface TDynamicSecretLeaseDALFactory extends Omit<TOrmify<TableName.DynamicSecretLease>, "findById"> {
countLeasesForDynamicSecret: (dynamicSecretId: string, tx?: Knex) => Promise<number>;
findById: (
id: string,
tx?: Knex
) => Promise<
| {
dynamicSecret: {
id: string;
name: string;
version: number;
type: string;
defaultTTL: string;
maxTTL: string | null | undefined;
encryptedInput: Buffer;
folderId: string;
status: string | null | undefined;
statusDetails: string | null | undefined;
createdAt: Date;
updatedAt: Date;
};
version: number;
id: string;
createdAt: Date;
updatedAt: Date;
externalEntityId: string;
expireAt: Date;
dynamicSecretId: string;
status?: string | null | undefined;
config?: unknown;
statusDetails?: string | null | undefined;
}
| undefined
>;
}
export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { export const dynamicSecretLeaseDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.DynamicSecretLease); const orm = ormify(db, TableName.DynamicSecretLease);

View File

@@ -21,7 +21,12 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = {
folderDAL: Pick<TSecretFolderDALFactory, "findById">; folderDAL: Pick<TSecretFolderDALFactory, "findById">;
}; };
export type TDynamicSecretLeaseQueueServiceFactory = ReturnType<typeof dynamicSecretLeaseQueueServiceFactory>; export type TDynamicSecretLeaseQueueServiceFactory = {
pruneDynamicSecret: (dynamicSecretCfgId: string) => Promise<void>;
setLeaseRevocation: (leaseId: string, expiryAt: Date) => Promise<void>;
unsetLeaseRevocation: (leaseId: string) => Promise<void>;
init: () => Promise<void>;
};
export const dynamicSecretLeaseQueueServiceFactory = ({ export const dynamicSecretLeaseQueueServiceFactory = ({
queueService, queueService,
@@ -30,55 +35,48 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
dynamicSecretLeaseDAL, dynamicSecretLeaseDAL,
kmsService, kmsService,
folderDAL folderDAL
}: TDynamicSecretLeaseQueueServiceFactoryDep) => { }: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => {
const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { const pruneDynamicSecret = async (dynamicSecretCfgId: string) => {
await queueService.queue( await queueService.queuePg<QueueName.DynamicSecretRevocation>(
QueueName.DynamicSecretRevocation,
QueueJobs.DynamicSecretPruning, QueueJobs.DynamicSecretPruning,
{ dynamicSecretCfgId }, { dynamicSecretCfgId },
{ {
jobId: dynamicSecretCfgId, singletonKey: dynamicSecretCfgId,
backoff: { retryLimit: 3,
type: "exponential", retryBackoff: true
delay: 3000
},
removeOnFail: {
count: 3
},
removeOnComplete: true
} }
); );
}; };
const setLeaseRevocation = async (leaseId: string, expiry: number) => { const setLeaseRevocation = async (leaseId: string, expiryAt: Date) => {
await queueService.queue( await queueService.queuePg<QueueName.DynamicSecretRevocation>(
QueueName.DynamicSecretRevocation,
QueueJobs.DynamicSecretRevocation, QueueJobs.DynamicSecretRevocation,
{ leaseId }, { leaseId },
{ {
jobId: leaseId, id: leaseId,
backoff: { singletonKey: leaseId,
type: "exponential", startAfter: expiryAt,
delay: 3000 retryLimit: 3,
}, retryBackoff: true,
delay: expiry, retentionDays: 2
removeOnFail: {
count: 3
},
removeOnComplete: true
} }
); );
}; };
const unsetLeaseRevocation = async (leaseId: string) => { const unsetLeaseRevocation = async (leaseId: string) => {
await queueService.stopJobById(QueueName.DynamicSecretRevocation, leaseId); await queueService.stopJobById(QueueName.DynamicSecretRevocation, leaseId);
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId);
}; };
queueService.start(QueueName.DynamicSecretRevocation, async (job) => { const $dynamicSecretQueueJob = async (
jobName: string,
jobId: string,
data: { leaseId: string } | { dynamicSecretCfgId: string }
): Promise<void> => {
try { try {
if (job.name === QueueJobs.DynamicSecretRevocation) { if (jobName === QueueJobs.DynamicSecretRevocation) {
const { leaseId } = job.data as { leaseId: string }; const { leaseId } = data as { leaseId: string };
logger.info("Dynamic secret lease revocation started: ", leaseId, job.id); logger.info("Dynamic secret lease revocation started: ", leaseId, jobId);
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId);
if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
@@ -107,9 +105,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
return; return;
} }
if (job.name === QueueJobs.DynamicSecretPruning) { if (jobName === QueueJobs.DynamicSecretPruning) {
const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string };
logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, job.id); logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, jobId);
const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId); const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId);
if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" }); if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" });
if ((dynamicSecretCfg.status as DynamicSecretStatus) !== DynamicSecretStatus.Deleting) if ((dynamicSecretCfg.status as DynamicSecretStatus) !== DynamicSecretStatus.Deleting)
@@ -150,38 +148,68 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
await dynamicSecretDAL.deleteById(dynamicSecretCfgId); await dynamicSecretDAL.deleteById(dynamicSecretCfgId);
} }
logger.info("Finished dynamic secret job", job.id); logger.info("Finished dynamic secret job", jobId);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
if (job?.name === QueueJobs.DynamicSecretPruning) { if (jobName === QueueJobs.DynamicSecretPruning) {
const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string };
await dynamicSecretDAL.updateById(dynamicSecretCfgId, { await dynamicSecretDAL.updateById(dynamicSecretCfgId, {
status: DynamicSecretStatus.FailedDeletion, status: DynamicSecretStatus.FailedDeletion,
statusDetails: (error as Error)?.message?.slice(0, 255) statusDetails: (error as Error)?.message?.slice(0, 255)
}); });
} }
if (job?.name === QueueJobs.DynamicSecretRevocation) { if (jobName === QueueJobs.DynamicSecretRevocation) {
const { leaseId } = job.data as { leaseId: string }; const { leaseId } = data as { leaseId: string };
await dynamicSecretLeaseDAL.updateById(leaseId, { await dynamicSecretLeaseDAL.updateById(leaseId, {
status: DynamicSecretStatus.FailedDeletion, status: DynamicSecretStatus.FailedDeletion,
statusDetails: (error as Error)?.message?.slice(0, 255) statusDetails: (error as Error)?.message?.slice(0, 255)
}); });
} }
if (error instanceof DisableRotationErrors) { if (error instanceof DisableRotationErrors) {
if (job.id) { if (jobId) {
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id); await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId);
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId);
} }
} }
// propogate to next part // propogate to next part
throw error; throw error;
} }
};
queueService.start(QueueName.DynamicSecretRevocation, async (job) => {
await $dynamicSecretQueueJob(job.name, job.id as string, job.data);
}); });
const init = async () => {
await queueService.startPg<QueueName.DynamicSecretRevocation>(
QueueJobs.DynamicSecretRevocation,
async ([job]) => {
await $dynamicSecretQueueJob(job.name, job.id, job.data);
},
{
workerCount: 5,
pollingIntervalSeconds: 1
}
);
await queueService.startPg<QueueName.DynamicSecretRevocation>(
QueueJobs.DynamicSecretPruning,
async ([job]) => {
await $dynamicSecretQueueJob(job.name, job.id, job.data);
},
{
workerCount: 1,
pollingIntervalSeconds: 1
}
);
};
return { return {
pruneDynamicSecret, pruneDynamicSecret,
setLeaseRevocation, setLeaseRevocation,
unsetLeaseRevocation unsetLeaseRevocation,
init
}; };
}; };

View File

@@ -26,12 +26,8 @@ import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue"; import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue";
import { import {
DynamicSecretLeaseStatus, DynamicSecretLeaseStatus,
TCreateDynamicSecretLeaseDTO,
TDeleteDynamicSecretLeaseDTO,
TDetailsDynamicSecretLeaseDTO,
TDynamicSecretLeaseConfig, TDynamicSecretLeaseConfig,
TListDynamicSecretLeasesDTO, TDynamicSecretLeaseServiceFactory
TRenewDynamicSecretLeaseDTO
} from "./dynamic-secret-lease-types"; } from "./dynamic-secret-lease-types";
type TDynamicSecretLeaseServiceFactoryDep = { type TDynamicSecretLeaseServiceFactoryDep = {
@@ -48,8 +44,6 @@ type TDynamicSecretLeaseServiceFactoryDep = {
identityDAL: TIdentityDALFactory; identityDAL: TIdentityDALFactory;
}; };
export type TDynamicSecretLeaseServiceFactory = ReturnType<typeof dynamicSecretLeaseServiceFactory>;
export const dynamicSecretLeaseServiceFactory = ({ export const dynamicSecretLeaseServiceFactory = ({
dynamicSecretLeaseDAL, dynamicSecretLeaseDAL,
dynamicSecretProviders, dynamicSecretProviders,
@@ -62,14 +56,14 @@ export const dynamicSecretLeaseServiceFactory = ({
kmsService, kmsService,
userDAL, userDAL,
identityDAL identityDAL
}: TDynamicSecretLeaseServiceFactoryDep) => { }: TDynamicSecretLeaseServiceFactoryDep): TDynamicSecretLeaseServiceFactory => {
const extractEmailUsername = (email: string) => { const extractEmailUsername = (email: string) => {
const regex = new RE2(/^([^@]+)/); const regex = new RE2(/^([^@]+)/);
const match = email.match(regex); const match = email.match(regex);
return match ? match[1] : email; return match ? match[1] : email;
}; };
const create = async ({ const create: TDynamicSecretLeaseServiceFactory["create"] = async ({
environmentSlug, environmentSlug,
path, path,
name, name,
@@ -80,7 +74,7 @@ export const dynamicSecretLeaseServiceFactory = ({
actorAuthMethod, actorAuthMethod,
ttl, ttl,
config config
}: TCreateDynamicSecretLeaseDTO) => { }) => {
const appCfg = getConfig(); const appCfg = getConfig();
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -184,11 +178,11 @@ export const dynamicSecretLeaseServiceFactory = ({
config config
}); });
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt);
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
}; };
const renewLease = async ({ const renewLease: TDynamicSecretLeaseServiceFactory["renewLease"] = async ({
ttl, ttl,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
@@ -198,7 +192,7 @@ export const dynamicSecretLeaseServiceFactory = ({
path, path,
environmentSlug, environmentSlug,
leaseId leaseId
}: TRenewDynamicSecretLeaseDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -278,7 +272,7 @@ export const dynamicSecretLeaseServiceFactory = ({
); );
await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id);
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt);
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
expireAt, expireAt,
externalEntityId: entityId externalEntityId: entityId
@@ -286,7 +280,7 @@ export const dynamicSecretLeaseServiceFactory = ({
return updatedDynamicSecretLease; return updatedDynamicSecretLease;
}; };
const revokeLease = async ({ const revokeLease: TDynamicSecretLeaseServiceFactory["revokeLease"] = async ({
leaseId, leaseId,
environmentSlug, environmentSlug,
path, path,
@@ -296,7 +290,7 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
isForced isForced
}: TDeleteDynamicSecretLeaseDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -376,7 +370,7 @@ export const dynamicSecretLeaseServiceFactory = ({
return deletedDynamicSecretLease; return deletedDynamicSecretLease;
}; };
const listLeases = async ({ const listLeases: TDynamicSecretLeaseServiceFactory["listLeases"] = async ({
path, path,
name, name,
actor, actor,
@@ -385,7 +379,7 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId, actorOrgId,
environmentSlug, environmentSlug,
actorAuthMethod actorAuthMethod
}: TListDynamicSecretLeasesDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -424,7 +418,7 @@ export const dynamicSecretLeaseServiceFactory = ({
return dynamicSecretLeases; return dynamicSecretLeases;
}; };
const getLeaseDetails = async ({ const getLeaseDetails: TDynamicSecretLeaseServiceFactory["getLeaseDetails"] = async ({
projectSlug, projectSlug,
actorOrgId, actorOrgId,
path, path,
@@ -433,7 +427,7 @@ export const dynamicSecretLeaseServiceFactory = ({
actorId, actorId,
leaseId, leaseId,
actorAuthMethod actorAuthMethod
}: TDetailsDynamicSecretLeaseDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });

View File

@@ -1,4 +1,5 @@
import { TProjectPermission } from "@app/lib/types"; import { TDynamicSecretLeases } from "@app/db/schemas";
import { TDynamicSecretWithMetadata, TProjectPermission } from "@app/lib/types";
export enum DynamicSecretLeaseStatus { export enum DynamicSecretLeaseStatus {
FailedDeletion = "Failed to delete" FailedDeletion = "Failed to delete"
@@ -48,3 +49,40 @@ export type TDynamicSecretKubernetesLeaseConfig = {
}; };
export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig; export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig;
export type TDynamicSecretLeaseServiceFactory = {
create: (arg: TCreateDynamicSecretLeaseDTO) => Promise<{
lease: TDynamicSecretLeases;
dynamicSecret: TDynamicSecretWithMetadata;
data: unknown;
}>;
listLeases: (arg: TListDynamicSecretLeasesDTO) => Promise<TDynamicSecretLeases[]>;
revokeLease: (arg: TDeleteDynamicSecretLeaseDTO) => Promise<TDynamicSecretLeases>;
renewLease: (arg: TRenewDynamicSecretLeaseDTO) => Promise<TDynamicSecretLeases>;
getLeaseDetails: (arg: TDetailsDynamicSecretLeaseDTO) => Promise<{
dynamicSecret: {
id: string;
name: string;
version: number;
type: string;
defaultTTL: string;
maxTTL: string | null | undefined;
encryptedInput: Buffer;
folderId: string;
status: string | null | undefined;
statusDetails: string | null | undefined;
createdAt: Date;
updatedAt: Date;
};
version: number;
id: string;
createdAt: Date;
updatedAt: Date;
externalEntityId: string;
expireAt: Date;
dynamicSecretId: string;
status?: string | null | undefined;
config?: unknown;
statusDetails?: string | null | undefined;
}>;
};

View File

@@ -10,17 +10,35 @@ import {
selectAllTableCols, selectAllTableCols,
sqlNestRelationships, sqlNestRelationships,
TFindFilter, TFindFilter,
TFindOpt TFindOpt,
TOrmify
} from "@app/lib/knex"; } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection, TDynamicSecretWithMetadata } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory>; export interface TDynamicSecretDALFactory extends Omit<TOrmify<TableName.DynamicSecret>, "findOne"> {
findOne: (filter: TFindFilter<TDynamicSecrets>, tx?: Knex) => Promise<TDynamicSecretWithMetadata>;
listDynamicSecretsByFolderIds: (
arg: {
folderIds: string[];
search?: string | undefined;
limit?: number | undefined;
offset?: number | undefined;
orderBy?: SecretsOrderBy | undefined;
orderDirection?: OrderByDirection | undefined;
},
tx?: Knex
) => Promise<Array<TDynamicSecretWithMetadata & { environment: string }>>;
findWithMetadata: (
filter: TFindFilter<TDynamicSecrets>,
arg?: TFindOpt<TDynamicSecrets>
) => Promise<TDynamicSecretWithMetadata[]>;
}
export const dynamicSecretDALFactory = (db: TDbClient) => { export const dynamicSecretDALFactory = (db: TDbClient): TDynamicSecretDALFactory => {
const orm = ormify(db, TableName.DynamicSecret); const orm = ormify(db, TableName.DynamicSecret);
const findOne = async (filter: TFindFilter<TDynamicSecrets>, tx?: Knex) => { const findOne: TDynamicSecretDALFactory["findOne"] = async (filter, tx) => {
const query = (tx || db.replicaNode())(TableName.DynamicSecret) const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.leftJoin( .leftJoin(
TableName.ResourceMetadata, TableName.ResourceMetadata,
@@ -55,9 +73,9 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
return docs[0]; return docs[0];
}; };
const findWithMetadata = async ( const findWithMetadata: TDynamicSecretDALFactory["findWithMetadata"] = async (
filter: TFindFilter<TDynamicSecrets>, filter,
{ offset, limit, sort, tx }: TFindOpt<TDynamicSecrets> = {} { offset, limit, sort, tx } = {}
) => { ) => {
const query = (tx || db.replicaNode())(TableName.DynamicSecret) const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.leftJoin( .leftJoin(
@@ -101,23 +119,9 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
}; };
// find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination) // find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination)
const listDynamicSecretsByFolderIds = async ( const listDynamicSecretsByFolderIds: TDynamicSecretDALFactory["listDynamicSecretsByFolderIds"] = async (
{ { folderIds, search, limit, offset = 0, orderBy = SecretsOrderBy.Name, orderDirection = OrderByDirection.ASC },
folderIds, tx
search,
limit,
offset = 0,
orderBy = SecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC
}: {
folderIds: string[];
search?: string;
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
},
tx?: Knex
) => { ) => {
try { try {
const query = (tx || db.replicaNode())(TableName.DynamicSecret) const query = (tx || db.replicaNode())(TableName.DynamicSecret)

View File

@@ -8,7 +8,7 @@ import {
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { OrderByDirection } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -20,17 +20,7 @@ import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/
import { TGatewayDALFactory } from "../gateway/gateway-dal"; import { TGatewayDALFactory } from "../gateway/gateway-dal";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; import { TDynamicSecretDALFactory } from "./dynamic-secret-dal";
import { import { DynamicSecretStatus, TDynamicSecretServiceFactory } from "./dynamic-secret-types";
DynamicSecretStatus,
TCreateDynamicSecretDTO,
TDeleteDynamicSecretDTO,
TDetailsDynamicSecretDTO,
TGetDynamicSecretsCountDTO,
TListDynamicSecretsByFolderMappingsDTO,
TListDynamicSecretsDTO,
TListDynamicSecretsMultiEnvDTO,
TUpdateDynamicSecretDTO
} from "./dynamic-secret-types";
import { AzureEntraIDProvider } from "./providers/azure-entra-id"; import { AzureEntraIDProvider } from "./providers/azure-entra-id";
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models"; import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
@@ -51,8 +41,6 @@ type TDynamicSecretServiceFactoryDep = {
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">; resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
}; };
export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>;
export const dynamicSecretServiceFactory = ({ export const dynamicSecretServiceFactory = ({
dynamicSecretDAL, dynamicSecretDAL,
dynamicSecretLeaseDAL, dynamicSecretLeaseDAL,
@@ -65,8 +53,8 @@ export const dynamicSecretServiceFactory = ({
kmsService, kmsService,
gatewayDAL, gatewayDAL,
resourceMetadataDAL resourceMetadataDAL
}: TDynamicSecretServiceFactoryDep) => { }: TDynamicSecretServiceFactoryDep): TDynamicSecretServiceFactory => {
const create = async ({ const create: TDynamicSecretServiceFactory["create"] = async ({
path, path,
actor, actor,
name, name,
@@ -80,7 +68,7 @@ export const dynamicSecretServiceFactory = ({
actorAuthMethod, actorAuthMethod,
metadata, metadata,
usernameTemplate usernameTemplate
}: TCreateDynamicSecretDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -188,7 +176,7 @@ export const dynamicSecretServiceFactory = ({
return dynamicSecretCfg; return dynamicSecretCfg;
}; };
const updateByName = async ({ const updateByName: TDynamicSecretServiceFactory["updateByName"] = async ({
name, name,
maxTTL, maxTTL,
defaultTTL, defaultTTL,
@@ -203,7 +191,7 @@ export const dynamicSecretServiceFactory = ({
actorAuthMethod, actorAuthMethod,
metadata, metadata,
usernameTemplate usernameTemplate
}: TUpdateDynamicSecretDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -345,7 +333,7 @@ export const dynamicSecretServiceFactory = ({
return updatedDynamicCfg; return updatedDynamicCfg;
}; };
const deleteByName = async ({ const deleteByName: TDynamicSecretServiceFactory["deleteByName"] = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
@@ -355,7 +343,7 @@ export const dynamicSecretServiceFactory = ({
path, path,
environmentSlug, environmentSlug,
isForced isForced
}: TDeleteDynamicSecretDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -413,7 +401,7 @@ export const dynamicSecretServiceFactory = ({
return deletedDynamicSecretCfg; return deletedDynamicSecretCfg;
}; };
const getDetails = async ({ const getDetails: TDynamicSecretServiceFactory["getDetails"] = async ({
name, name,
projectSlug, projectSlug,
path, path,
@@ -422,7 +410,7 @@ export const dynamicSecretServiceFactory = ({
actorOrgId, actorOrgId,
actorId, actorId,
actor actor
}: TDetailsDynamicSecretDTO) => { }) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -480,7 +468,7 @@ export const dynamicSecretServiceFactory = ({
}; };
// get unique dynamic secret count across multiple envs // get unique dynamic secret count across multiple envs
const getCountMultiEnv = async ({ const getCountMultiEnv: TDynamicSecretServiceFactory["getCountMultiEnv"] = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
@@ -490,7 +478,7 @@ export const dynamicSecretServiceFactory = ({
environmentSlugs, environmentSlugs,
search, search,
isInternal isInternal
}: TListDynamicSecretsMultiEnvDTO) => { }) => {
if (!isInternal) { if (!isInternal) {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor, actor,
@@ -526,7 +514,7 @@ export const dynamicSecretServiceFactory = ({
}; };
// get dynamic secret count for a single env // get dynamic secret count for a single env
const getDynamicSecretCount = async ({ const getDynamicSecretCount: TDynamicSecretServiceFactory["getDynamicSecretCount"] = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
@@ -535,7 +523,7 @@ export const dynamicSecretServiceFactory = ({
environmentSlug, environmentSlug,
search, search,
projectId projectId
}: TGetDynamicSecretsCountDTO) => { }) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
@@ -561,7 +549,7 @@ export const dynamicSecretServiceFactory = ({
return Number(dynamicSecretCfg[0]?.count ?? 0); return Number(dynamicSecretCfg[0]?.count ?? 0);
}; };
const listDynamicSecretsByEnv = async ({ const listDynamicSecretsByEnv: TDynamicSecretServiceFactory["listDynamicSecretsByEnv"] = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
@@ -575,7 +563,7 @@ export const dynamicSecretServiceFactory = ({
orderDirection = OrderByDirection.ASC, orderDirection = OrderByDirection.ASC,
search, search,
...params ...params
}: TListDynamicSecretsDTO) => { }) => {
let { projectId } = params; let { projectId } = params;
if (!projectId) { if (!projectId) {
@@ -619,9 +607,9 @@ export const dynamicSecretServiceFactory = ({
}); });
}; };
const listDynamicSecretsByFolderIds = async ( const listDynamicSecretsByFolderIds: TDynamicSecretServiceFactory["listDynamicSecretsByFolderIds"] = async (
{ folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO, { folderMappings, filters, projectId },
actor: OrgServiceActor actor
) => { ) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor: actor.type, actor: actor.type,
@@ -657,7 +645,7 @@ export const dynamicSecretServiceFactory = ({
}; };
// get dynamic secrets for multiple envs // get dynamic secrets for multiple envs
const listDynamicSecretsByEnvs = async ({ const listDynamicSecretsByEnvs: TDynamicSecretServiceFactory["listDynamicSecretsByEnvs"] = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
@@ -667,7 +655,7 @@ export const dynamicSecretServiceFactory = ({
projectId, projectId,
isInternal, isInternal,
...params ...params
}: TListDynamicSecretsMultiEnvDTO) => { }) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
@@ -700,14 +688,10 @@ export const dynamicSecretServiceFactory = ({
}); });
}; };
const fetchAzureEntraIdUsers = async ({ const fetchAzureEntraIdUsers: TDynamicSecretServiceFactory["fetchAzureEntraIdUsers"] = async ({
tenantId, tenantId,
applicationId, applicationId,
clientSecret clientSecret
}: {
tenantId: string;
applicationId: string;
clientSecret: string;
}) => { }) => {
const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers( const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers(
tenantId, tenantId,

View File

@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TDynamicSecrets } from "@app/db/schemas";
import { OrderByDirection, OrgServiceActor, TDynamicSecretWithMetadata, TProjectPermission } from "@app/lib/types";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
@@ -83,3 +84,27 @@ export type TListDynamicSecretsMultiEnvDTO = Omit<
export type TGetDynamicSecretsCountDTO = Omit<TListDynamicSecretsDTO, "projectSlug" | "projectId"> & { export type TGetDynamicSecretsCountDTO = Omit<TListDynamicSecretsDTO, "projectSlug" | "projectId"> & {
projectId: string; projectId: string;
}; };
export type TDynamicSecretServiceFactory = {
create: (arg: TCreateDynamicSecretDTO) => Promise<TDynamicSecrets>;
updateByName: (arg: TUpdateDynamicSecretDTO) => Promise<TDynamicSecrets>;
deleteByName: (arg: TDeleteDynamicSecretDTO) => Promise<TDynamicSecrets>;
getDetails: (arg: TDetailsDynamicSecretDTO) => Promise<TDynamicSecretWithMetadata>;
listDynamicSecretsByEnv: (arg: TListDynamicSecretsDTO) => Promise<TDynamicSecretWithMetadata[]>;
listDynamicSecretsByEnvs: (
arg: TListDynamicSecretsMultiEnvDTO
) => Promise<Array<TDynamicSecretWithMetadata & { environment: string }>>;
getDynamicSecretCount: (arg: TGetDynamicSecretsCountDTO) => Promise<number>;
getCountMultiEnv: (arg: TListDynamicSecretsMultiEnvDTO) => Promise<number>;
fetchAzureEntraIdUsers: (arg: { tenantId: string; applicationId: string; clientSecret: string }) => Promise<
{
name: string;
id: string;
email: string;
}[]
>;
listDynamicSecretsByFolderIds: (
arg: TListDynamicSecretsByFolderMappingsDTO,
actor: OrgServiceActor
) => Promise<Array<TDynamicSecretWithMetadata & { environment: string; path: string }>>;
};

View File

@@ -52,9 +52,8 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: string; gatewayId: string;
targetHost: string; targetHost: string;
targetPort: number; targetPort: number;
caCert?: string; httpsAgent?: https.Agent;
reviewTokenThroughGateway: boolean; reviewTokenThroughGateway: boolean;
enableSsl: boolean;
}, },
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T> gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => { ): Promise<T> => {
@@ -85,10 +84,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
key: relayDetails.privateKey.toString() key: relayDetails.privateKey.toString()
}, },
// we always pass this, because its needed for both tcp and http protocol // we always pass this, because its needed for both tcp and http protocol
httpsAgent: new https.Agent({ httpsAgent: inputs.httpsAgent
ca: inputs.caCert,
rejectUnauthorized: inputs.enableSsl
})
} }
); );
@@ -311,6 +307,14 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const k8sHost = `${url.protocol}//${url.hostname}`; const k8sHost = `${url.protocol}//${url.hostname}`;
try { try {
const httpsAgent =
providerInputs.ca && providerInputs.sslEnabled
? new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: true
})
: undefined;
if (providerInputs.gatewayId) { if (providerInputs.gatewayId) {
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
await $gatewayProxyWrapper( await $gatewayProxyWrapper(
@@ -318,8 +322,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: providerInputs.gatewayId, gatewayId: providerInputs.gatewayId,
targetHost: k8sHost, targetHost: k8sHost,
targetPort: k8sPort, targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled, httpsAgent,
caCert: providerInputs.ca,
reviewTokenThroughGateway: true reviewTokenThroughGateway: true
}, },
providerInputs.credentialType === KubernetesCredentialType.Static providerInputs.credentialType === KubernetesCredentialType.Static
@@ -332,8 +335,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: providerInputs.gatewayId, gatewayId: providerInputs.gatewayId,
targetHost: k8sGatewayHost, targetHost: k8sGatewayHost,
targetPort: k8sPort, targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled, httpsAgent,
caCert: providerInputs.ca,
reviewTokenThroughGateway: false reviewTokenThroughGateway: false
}, },
providerInputs.credentialType === KubernetesCredentialType.Static providerInputs.credentialType === KubernetesCredentialType.Static
@@ -342,9 +344,9 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
); );
} }
} else if (providerInputs.credentialType === KubernetesCredentialType.Static) { } else if (providerInputs.credentialType === KubernetesCredentialType.Static) {
await serviceAccountStaticCallback(k8sHost, k8sPort); await serviceAccountStaticCallback(k8sHost, k8sPort, httpsAgent);
} else { } else {
await serviceAccountDynamicCallback(k8sHost, k8sPort); await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent);
} }
return true; return true;
@@ -546,6 +548,15 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
try { try {
let tokenData; let tokenData;
const httpsAgent =
providerInputs.ca && providerInputs.sslEnabled
? new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: true
})
: undefined;
if (providerInputs.gatewayId) { if (providerInputs.gatewayId) {
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
tokenData = await $gatewayProxyWrapper( tokenData = await $gatewayProxyWrapper(
@@ -553,8 +564,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: providerInputs.gatewayId, gatewayId: providerInputs.gatewayId,
targetHost: k8sHost, targetHost: k8sHost,
targetPort: k8sPort, targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled, httpsAgent,
caCert: providerInputs.ca,
reviewTokenThroughGateway: true reviewTokenThroughGateway: true
}, },
providerInputs.credentialType === KubernetesCredentialType.Static providerInputs.credentialType === KubernetesCredentialType.Static
@@ -567,8 +577,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: providerInputs.gatewayId, gatewayId: providerInputs.gatewayId,
targetHost: k8sGatewayHost, targetHost: k8sGatewayHost,
targetPort: k8sPort, targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled, httpsAgent,
caCert: providerInputs.ca,
reviewTokenThroughGateway: false reviewTokenThroughGateway: false
}, },
providerInputs.credentialType === KubernetesCredentialType.Static providerInputs.credentialType === KubernetesCredentialType.Static
@@ -579,8 +588,8 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
} else { } else {
tokenData = tokenData =
providerInputs.credentialType === KubernetesCredentialType.Static providerInputs.credentialType === KubernetesCredentialType.Static
? await tokenRequestStaticCallback(k8sHost, k8sPort) ? await tokenRequestStaticCallback(k8sHost, k8sPort, httpsAgent)
: await serviceAccountDynamicCallback(k8sHost, k8sPort); : await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent);
} }
return { return {
@@ -684,6 +693,14 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
const k8sPort = url.port ? Number(url.port) : 443; const k8sPort = url.port ? Number(url.port) : 443;
const k8sHost = `${url.protocol}//${url.hostname}`; const k8sHost = `${url.protocol}//${url.hostname}`;
const httpsAgent =
providerInputs.ca && providerInputs.sslEnabled
? new https.Agent({
ca: providerInputs.ca,
rejectUnauthorized: true
})
: undefined;
if (providerInputs.gatewayId) { if (providerInputs.gatewayId) {
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
await $gatewayProxyWrapper( await $gatewayProxyWrapper(
@@ -691,8 +708,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: providerInputs.gatewayId, gatewayId: providerInputs.gatewayId,
targetHost: k8sHost, targetHost: k8sHost,
targetPort: k8sPort, targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled, httpsAgent,
caCert: providerInputs.ca,
reviewTokenThroughGateway: true reviewTokenThroughGateway: true
}, },
serviceAccountDynamicCallback serviceAccountDynamicCallback
@@ -703,15 +719,14 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
gatewayId: providerInputs.gatewayId, gatewayId: providerInputs.gatewayId,
targetHost: k8sGatewayHost, targetHost: k8sGatewayHost,
targetPort: k8sPort, targetPort: k8sPort,
enableSsl: providerInputs.sslEnabled, httpsAgent,
caCert: providerInputs.ca,
reviewTokenThroughGateway: false reviewTokenThroughGateway: false
}, },
serviceAccountDynamicCallback serviceAccountDynamicCallback
); );
} }
} else { } else {
await serviceAccountDynamicCallback(k8sHost, k8sPort); await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent);
} }
} }

View File

@@ -24,6 +24,7 @@ type TFindQueryFilter = {
committer?: string; committer?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string;
}; };
export const secretApprovalRequestDALFactory = (db: TDbClient) => { export const secretApprovalRequestDALFactory = (db: TDbClient) => {
@@ -314,7 +315,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
) )
.andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null))
.select("status", `${TableName.SecretApprovalRequest}.id`) .select("status", `${TableName.SecretApprovalRequest}.id`)
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
.count("status") .count("status")
@@ -340,13 +340,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
}; };
const findByProjectId = async ( const findByProjectId = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter,
tx?: Knex tx?: Knex
) => { ) => {
try { try {
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination // akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
// this is the place u wanna look at. // this is the place u wanna look at.
const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest) const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join( .join(
@@ -435,7 +435,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName") db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
) )
.orderBy("createdAt", "desc"); .distinctOn(`${TableName.SecretApprovalRequest}.id`)
.as("inner");
const query = (tx || db)
.select("*")
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery)
.orderBy("createdAt", "desc") as typeof innerQuery;
if (search) {
void query.where((qb) => {
void qb
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
db.ref("firstName").withSchema("committerUser"),
db.ref("lastName").withSchema("committerUser"),
`%${search}%`
])
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`])
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`)
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`)
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`);
});
}
const docs = await (tx || db) const docs = await (tx || db)
.with("w", query) .with("w", query)
@@ -443,6 +466,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.from<Awaited<typeof query>[number]>("w") .from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", offset) .where("w.rank", ">=", offset)
.andWhere("w.rank", "<", offset + limit); .andWhere("w.rank", "<", offset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({ const formattedDoc = sqlNestRelationships({
data: docs, data: docs,
key: "id", key: "id",
@@ -504,23 +531,26 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
} }
] ]
}); });
return formattedDoc.map((el) => ({ return {
approvals: formattedDoc.map((el) => ({
...el, ...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
})); })),
totalCount
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindSAR" }); throw new DatabaseError({ error, name: "FindSAR" });
} }
}; };
const findByProjectIdBridgeSecretV2 = async ( const findByProjectIdBridgeSecretV2 = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter,
tx?: Knex tx?: Knex
) => { ) => {
try { try {
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination // akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
// this is the place u wanna look at. // this is the place u wanna look at.
const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest) const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join( .join(
@@ -609,14 +639,42 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName") db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
) )
.orderBy("createdAt", "desc"); .distinctOn(`${TableName.SecretApprovalRequest}.id`)
.as("inner");
const query = (tx || db)
.select("*")
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery)
.orderBy("createdAt", "desc") as typeof innerQuery;
if (search) {
void query.where((qb) => {
void qb
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
db.ref("firstName").withSchema("committerUser"),
db.ref("lastName").withSchema("committerUser"),
`%${search}%`
])
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`])
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`)
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`)
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`);
});
}
const rankOffset = offset + 1;
const docs = await (tx || db) const docs = await (tx || db)
.with("w", query) .with("w", query)
.select("*") .select("*")
.from<Awaited<typeof query>[number]>("w") .from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", offset) .where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", offset + limit); .andWhere("w.rank", "<", rankOffset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({ const formattedDoc = sqlNestRelationships({
data: docs, data: docs,
key: "id", key: "id",
@@ -682,10 +740,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
} }
] ]
}); });
return formattedDoc.map((el) => ({ return {
approvals: formattedDoc.map((el) => ({
...el, ...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
})); })),
totalCount
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindSAR" }); throw new DatabaseError({ error, name: "FindSAR" });
} }

View File

@@ -194,7 +194,8 @@ export const secretApprovalRequestServiceFactory = ({
environment, environment,
committer, committer,
limit, limit,
offset offset,
search
}: TListApprovalsDTO) => { }: TListApprovalsDTO) => {
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
@@ -208,6 +209,7 @@ export const secretApprovalRequestServiceFactory = ({
}); });
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({
projectId, projectId,
@@ -216,19 +218,21 @@ export const secretApprovalRequestServiceFactory = ({
status, status,
userId: actorId, userId: actorId,
limit, limit,
offset offset,
search
}); });
} }
const approvals = await secretApprovalRequestDAL.findByProjectId({
return secretApprovalRequestDAL.findByProjectId({
projectId, projectId,
committer, committer,
environment, environment,
status, status,
userId: actorId, userId: actorId,
limit, limit,
offset offset,
search
}); });
return approvals;
}; };
const getSecretApprovalDetails = async ({ const getSecretApprovalDetails = async ({

View File

@@ -93,6 +93,7 @@ export type TListApprovalsDTO = {
committer?: string; committer?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string;
} & TProjectPermission; } & TProjectPermission;
export type TSecretApprovalDetailsDTO = { export type TSecretApprovalDetailsDTO = {

View File

@@ -72,6 +72,7 @@ type TWaitTillReady = {
export type TKeyStoreFactory = { export type TKeyStoreFactory = {
setItem: (key: string, value: string | number | Buffer, prefix?: string) => Promise<"OK">; setItem: (key: string, value: string | number | Buffer, prefix?: string) => Promise<"OK">;
getItem: (key: string, prefix?: string) => Promise<string | null>; getItem: (key: string, prefix?: string) => Promise<string | null>;
getItems: (keys: string[], prefix?: string) => Promise<(string | null)[]>;
setExpiry: (key: string, expiryInSeconds: number) => Promise<number>; setExpiry: (key: string, expiryInSeconds: number) => Promise<number>;
setItemWithExpiry: ( setItemWithExpiry: (
key: string, key: string,
@@ -80,6 +81,7 @@ export type TKeyStoreFactory = {
prefix?: string prefix?: string
) => Promise<"OK">; ) => Promise<"OK">;
deleteItem: (key: string) => Promise<number>; deleteItem: (key: string) => Promise<number>;
deleteItemsByKeyIn: (keys: string[]) => Promise<number>;
deleteItems: (arg: TDeleteItems) => Promise<number>; deleteItems: (arg: TDeleteItems) => Promise<number>;
incrementBy: (key: string, value: number) => Promise<number>; incrementBy: (key: string, value: number) => Promise<number>;
acquireLock( acquireLock(
@@ -88,6 +90,7 @@ export type TKeyStoreFactory = {
settings?: Partial<Settings> settings?: Partial<Settings>
): Promise<{ release: () => Promise<ExecutionResult> }>; ): Promise<{ release: () => Promise<ExecutionResult> }>;
waitTillReady: ({ key, waitingCb, keyCheckCb, waitIteration, delay, jitter }: TWaitTillReady) => Promise<void>; waitTillReady: ({ key, waitingCb, keyCheckCb, waitIteration, delay, jitter }: TWaitTillReady) => Promise<void>;
getKeysByPattern: (pattern: string, limit?: number) => Promise<string[]>;
}; };
export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => { export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => {
@@ -99,6 +102,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); 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 ( const setItemWithExpiry = async (
key: string, key: string,
expiryInSeconds: number | string, expiryInSeconds: number | string,
@@ -108,6 +114,11 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
const deleteItem = async (key: string) => redis.del(key); 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) => { const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => {
let cursor = "0"; let cursor = "0";
let totalDeleted = 0; let totalDeleted = 0;
@@ -163,6 +174,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 { return {
setItem, setItem,
getItem, getItem,
@@ -174,6 +203,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
acquireLock(resources: string[], duration: number, settings?: Partial<Settings>) { acquireLock(resources: string[], duration: number, settings?: Partial<Settings>) {
return redisLock.acquire(resources, duration, 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 => { export const inMemoryKeyStore = (): TKeyStoreFactory => {
const store: Record<string, string | number | Buffer> = {}; const store: Record<string, string | number | Buffer> = {};
const getRegex = (pattern: string) =>
new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
return { return {
setItem: async (key, value) => { setItem: async (key, value) => {
@@ -24,7 +26,7 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => {
return 1; return 1;
}, },
deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => { 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; let totalDeleted = 0;
const keys = Object.keys(store); const keys = Object.keys(store);
@@ -59,6 +61,27 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => {
release: () => {} release: () => {}
}) as Promise<Lock>; }) 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

@@ -19,3 +19,5 @@ export const getMinExpiresIn = (exp1: string | number, exp2: string | number): s
return ms1 <= ms2 ? exp1 : exp2; return ms1 <= ms2 ? exp1 : exp2;
}; };
export const convertMsToSecond = (time: number) => time / 1000;

View File

@@ -1,3 +1,4 @@
import { TDynamicSecrets } from "@app/db/schemas";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
export type TGenericPermission = { export type TGenericPermission = {
@@ -84,3 +85,7 @@ export enum QueueWorkerProfile {
Standard = "standard", Standard = "standard",
SecretScanning = "secret-scanning" SecretScanning = "secret-scanning"
} }
export interface TDynamicSecretWithMetadata extends TDynamicSecrets {
metadata: { id: string; key: string; value: string }[];
}

View File

@@ -62,7 +62,8 @@ export enum QueueName {
SecretRotationV2 = "secret-rotation-v2", SecretRotationV2 = "secret-rotation-v2",
FolderTreeCheckpoint = "folder-tree-checkpoint", FolderTreeCheckpoint = "folder-tree-checkpoint",
InvalidateCache = "invalidate-cache", InvalidateCache = "invalidate-cache",
SecretScanningV2 = "secret-scanning-v2" SecretScanningV2 = "secret-scanning-v2",
TelemetryAggregatedEvents = "telemetry-aggregated-events"
} }
export enum QueueJobs { export enum QueueJobs {
@@ -101,7 +102,8 @@ export enum QueueJobs {
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
SecretScanningV2SendNotification = "secret-scanning-v2-notification", SecretScanningV2SendNotification = "secret-scanning-v2-notification",
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", 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 = { export type TQueueJobTypes = {
@@ -292,6 +294,10 @@ export type TQueueJobTypes = {
name: QueueJobs.PkiSubscriberDailyAutoRenewal; name: QueueJobs.PkiSubscriberDailyAutoRenewal;
payload: undefined; payload: undefined;
}; };
[QueueName.TelemetryAggregatedEvents]: {
name: QueueJobs.TelemetryAggregatedEvents;
payload: undefined;
};
}; };
const SECRET_SCANNING_JOBS = [ const SECRET_SCANNING_JOBS = [
@@ -377,6 +383,7 @@ export type TQueueServiceFactory = {
stopRepeatableJobByKey: <T extends QueueName>(name: T, repeatJobKey: string) => Promise<boolean>; stopRepeatableJobByKey: <T extends QueueName>(name: T, repeatJobKey: string) => Promise<boolean>;
clearQueue: (name: QueueName) => Promise<void>; clearQueue: (name: QueueName) => Promise<void>;
stopJobById: <T extends QueueName>(name: T, jobId: string) => Promise<void | undefined>; stopJobById: <T extends QueueName>(name: T, jobId: string) => Promise<void | undefined>;
stopJobByIdPg: <T extends QueueName>(name: T, jobId: string) => Promise<void | undefined>;
getRepeatableJobs: ( getRepeatableJobs: (
name: QueueName, name: QueueName,
startOffset?: number, startOffset?: number,
@@ -542,6 +549,10 @@ export const queueServiceFactory = (
return q.removeRepeatableByKey(repeatJobKey); return q.removeRepeatableByKey(repeatJobKey);
}; };
const stopJobByIdPg: TQueueServiceFactory["stopJobByIdPg"] = async (name, jobId) => {
await pgBoss.deleteJob(name, jobId);
};
const stopJobById: TQueueServiceFactory["stopJobById"] = async (name, jobId) => { const stopJobById: TQueueServiceFactory["stopJobById"] = async (name, jobId) => {
const q = queueContainer[name]; const q = queueContainer[name];
const job = await q.getJob(jobId); const job = await q.getJob(jobId);
@@ -568,6 +579,7 @@ export const queueServiceFactory = (
stopRepeatableJobByKey, stopRepeatableJobByKey,
clearQueue, clearQueue,
stopJobById, stopJobById,
stopJobByIdPg,
getRepeatableJobs, getRepeatableJobs,
startPg, startPg,
queuePg, queuePg,

View File

@@ -686,7 +686,8 @@ export const registerRoutes = async (
const telemetryQueue = telemetryQueueServiceFactory({ const telemetryQueue = telemetryQueueServiceFactory({
keyStore, keyStore,
telemetryDAL, telemetryDAL,
queueService queueService,
telemetryService
}); });
const invalidateCacheQueue = invalidateCacheQueueFactory({ const invalidateCacheQueue = invalidateCacheQueueFactory({
@@ -1903,6 +1904,7 @@ export const registerRoutes = async (
await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiSubscriberQueue.startDailyAutoRenewalJob();
await kmsService.startService(); await kmsService.startService();
await microsoftTeamsService.start(); await microsoftTeamsService.start();
await dynamicSecretQueueService.init();
// inject all services // inject all services
server.decorate<FastifyZodProvider["services"]>("services", { server.decorate<FastifyZodProvider["services"]>("services", {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.UserOrgInvitation, event: PostHogEventTypes.UserOrgInvitation,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
inviteeEmails: req.body.inviteeEmails, inviteeEmails: req.body.inviteeEmails,
organizationRoleSlug: req.body.organizationRoleSlug, organizationRoleSlug: req.body.organizationRoleSlug,
@@ -83,7 +84,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
config: { config: {
rateLimit: smtpRateLimit({ rateLimit: smtpRateLimit({
keyGenerator: (req) => keyGenerator: (req) =>
(req.body as { membershipId?: string })?.membershipId?.trim().substring(0, 100) ?? req.realIp (req.body as { membershipId?: string })?.membershipId?.trim().substring(0, 100) || req.realIp
}) })
}, },
method: "POST", method: "POST",

View File

@@ -81,7 +81,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
url: "/email/password-reset", url: "/email/password-reset",
config: { config: {
rateLimit: smtpRateLimit({ rateLimit: smtpRateLimit({
keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) ?? req.realIp keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) || req.realIp
}) })
}, },
schema: { schema: {
@@ -107,7 +107,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
method: "POST", method: "POST",
url: "/email/password-reset-verify", url: "/email/password-reset-verify",
config: { config: {
rateLimit: authRateLimit rateLimit: smtpRateLimit({
keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) || req.realIp
})
}, },
schema: { schema: {
body: z.object({ body: z.object({

View File

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

View File

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

View File

@@ -198,6 +198,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.ProjectCreated, event: PostHogEventTypes.ProjectCreated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
orgId: project.orgId, orgId: project.orgId,
name: project.name, name: project.name,

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
import { ApiKeysSchema } from "@app/db/schemas/api-keys"; import { ApiKeysSchema } from "@app/db/schemas/api-keys";
import { authRateLimit, readLimit, smtpRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, smtpRateLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
@@ -13,7 +13,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
url: "/me/emails/code", url: "/me/emails/code",
config: { config: {
rateLimit: smtpRateLimit({ rateLimit: smtpRateLimit({
keyGenerator: (req) => (req.body as { username?: string })?.username?.trim().substring(0, 100) ?? req.realIp keyGenerator: (req) => (req.body as { username?: string })?.username?.trim().substring(0, 100) || req.realIp
}) })
}, },
schema: { schema: {
@@ -34,7 +34,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
method: "POST", method: "POST",
url: "/me/emails/verify", url: "/me/emails/verify",
config: { config: {
rateLimit: authRateLimit rateLimit: smtpRateLimit({
keyGenerator: (req) => (req.body as { username?: string })?.username?.trim().substring(0, 100) || req.realIp
})
}, },
schema: { schema: {
body: z.object({ body: z.object({

View File

@@ -339,6 +339,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled, event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId, workspaceId,
@@ -484,6 +485,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled, event: PostHogEventTypes.SecretPulled,
organizationId: req.permission.orgId,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
@@ -600,6 +602,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated, event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -725,6 +728,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated, event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -815,6 +819,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted, event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -922,6 +927,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled, event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: req.query.workspaceId, workspaceId: req.query.workspaceId,
@@ -1001,6 +1007,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled, event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.query.workspaceId, workspaceId: req.query.workspaceId,
@@ -1172,6 +1179,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated, event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -1361,6 +1369,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated, event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -1484,6 +1493,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted, event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: 1, numberOfSecrets: 1,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -1667,6 +1677,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated, event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -1793,6 +1804,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated, event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -1911,6 +1923,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted, event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: req.body.workspaceId, workspaceId: req.body.workspaceId,
@@ -2019,6 +2032,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretCreated, event: PostHogEventTypes.SecretCreated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: secrets[0].workspace, workspaceId: secrets[0].workspace,
@@ -2174,6 +2188,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretUpdated, event: PostHogEventTypes.SecretUpdated,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: secrets[0].workspace, workspaceId: secrets[0].workspace,
@@ -2272,6 +2287,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretDeleted, event: PostHogEventTypes.SecretDeleted,
distinctId: getTelemetryDistinctId(req), distinctId: getTelemetryDistinctId(req),
organizationId: req.permission.orgId,
properties: { properties: {
numberOfSecrets: secrets.length, numberOfSecrets: secrets.length,
workspaceId: secrets[0].workspace, workspaceId: secrets[0].workspace,

View File

@@ -14,7 +14,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
method: "POST", method: "POST",
config: { config: {
rateLimit: smtpRateLimit({ rateLimit: smtpRateLimit({
keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) ?? req.realIp keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) || req.realIp
}) })
}, },
schema: { schema: {
@@ -55,7 +55,9 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
url: "/email/verify", url: "/email/verify",
method: "POST", method: "POST",
config: { config: {
rateLimit: authRateLimit rateLimit: smtpRateLimit({
keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) || req.realIp
})
}, },
schema: { schema: {
body: z.object({ body: z.object({

View File

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

View File

@@ -1,3 +1,4 @@
import { createHash, randomUUID } from "crypto";
import { PostHog } from "posthog-node"; import { PostHog } from "posthog-node";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; 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_PROCESSED_KEY = "telemetry-secret-processed";
export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations"; 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 TTelemetryServiceFactory = ReturnType<typeof telemetryServiceFactory>;
export type TTelemetryServiceFactoryDep = { export type TTelemetryServiceFactoryDep = {
keyStore: Pick<TKeyStoreFactory, "getItem" | "incrementBy">; keyStore: Pick<
TKeyStoreFactory,
"incrementBy" | "deleteItemsByKeyIn" | "setItemWithExpiry" | "getKeysByPattern" | "getItems"
>;
licenseService: Pick<TLicenseServiceFactory, "getInstanceType">; 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) => { export const telemetryServiceFactory = ({ keyStore, licenseService }: TTelemetryServiceFactoryDep) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -64,11 +102,33 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
const instanceType = licenseService.getInstanceType(); const instanceType = licenseService.getInstanceType();
// capture posthog only when its cloud or signup event happens in self-hosted // capture posthog only when its cloud or signup event happens in self-hosted
if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) { if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) {
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({ postHog.capture({
event: event.event, event: event.event,
distinctId: event.distinctId, distinctId: event.distinctId,
properties: event.properties properties: event.properties,
...(event.organizationId ? { groups: { organization: event.organizationId } } : {})
}); });
}
return; 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 () => { const flushAll = async () => {
if (postHog) { if (postHog) {
await postHog.shutdownAsync(); await postHog.shutdownAsync();
@@ -98,6 +312,8 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
return { return {
sendLoopsEvent, sendLoopsEvent,
sendPostHogEvents, 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 { export enum PostHogEventTypes {
SecretPush = "secrets pushed", SecretPush = "secrets pushed",
SecretPulled = "secrets pulled", SecretPulled = "secrets pulled",
@@ -40,6 +50,14 @@ export type TSecretModifiedEvent = {
secretPath: string; secretPath: string;
channel?: string; channel?: string;
userAgent?: 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 | TSecretModifiedEvent
| TAdminInitEvent | TAdminInitEvent
| TUserSignedUpEvent | TUserSignedUpEvent

2230
docs/docs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
---
title: "Infisical Cloud"
description: "Architecture overview of Infisical's US and EU cloud deployments"
---
This document provides an overview of Infisical's cloud architecture for our US and EU deployments, detailing the core components and how they interact to provide security and infrastructure services.
## Overview
Infisical Cloud operates on AWS infrastructure using containerized services deployed via Amazon ECS (Elastic Container Service). Our US and EU deployments use identical architectural patterns to ensure consistency and reliability across regions.
![Infisical Cloud Architecture](/images/self-hosting/reference-architectures/Infisical-AWS-ECS-architecture.jpeg)
## Components
A typical Infisical Cloud deployment consists of the following components:
### Application Services
- **Infisical Core**: Main application server running the Infisical backend API
- **License API**: Dedicated API service for license management with separate RDS instance (shared between US/EU)
- **Application Load Balancer**: Routes incoming traffic to application containers with SSL termination and host-based routing
### Data Layer
- **Amazon RDS (PostgreSQL)**:
- **Main RDS Instance**: Primary database for secrets, users, and metadata (Multi-AZ, encryption enabled)
- **License API RDS Instance**: Dedicated database for license management services
- **Amazon ElastiCache (Redis)**:
- **Main Redis Cluster**: Multi-AZ replication group for core application caching and queuing
- **License API Redis**: Dedicated cache for license services
- Redis 7 engine with CloudWatch logging and snapshot backups
### Infrastructure
- **ECS Fargate**: Serverless container platform running application services
- **AWS Global Accelerator**: Global traffic routing and performance optimization
- **Cloudflare**: DNS management and routing
- **AWS SSM Parameter Store**: Stores application configuration and secrets
- **CloudWatch**: Centralized logging and monitoring
## System Layout
### Service Architecture
The Infisical application runs as multiple containerized services on ECS:
- **Main Server**: Auto-scaling containerized application services
- **License API**: Dedicated service with separate infrastructure (shared globally)
- **Monitoring**: AWS OTel Collector and Datadog Agent sidecars
Container images are pulled from Docker Hub and managed via GitHub Actions for deployments.
### Network Configuration
Services are deployed in private subnets with the following connectivity:
- External traffic → Application Load Balancer → ECS Services
- Main server exposes port 8080
- License API exposes port 4000 (portal.infisical.com, license.infisical.com)
- Service-to-service communication via AWS Service Connect
### Data Flow
1. **DNS resolution** via Cloudflare routes traffic to AWS Global Accelerator
2. **Global Accelerator** optimizes routing to the nearest AWS region
3. **Client requests** are routed through the Application Load Balancer to ECS containers
4. **Application logic** processes requests in the Infisical Core service
5. **Data persistence** occurs via encrypted connections to RDS
6. **Caching** utilizes ElastiCache for performance optimization
7. **Configuration** is retrieved from AWS SSM Parameter Store
## Regional Deployments
Each region operates in a separate AWS account, providing strong isolation boundaries for security, compliance, and operational independence.
### US Cloud (us.infisical.com or app.infisical.com)
- **AWS Account**: Dedicated US AWS account
- **Infrastructure**: ECS-based containerized deployment
- **Monitoring**: Integrated with Datadog for observability and security monitoring
### EU Cloud (eu.infisical.com)
- **AWS Account**: Dedicated EU AWS account
- **Infrastructure**: ECS-based containerized deployment
- **Monitoring**: Integrated with Datadog for observability and security monitoring
## Configuration Management
Application configuration and secrets are managed through AWS SSM Parameter Store, with deployment automation handled via GitHub Actions.
## Monitoring and Observability
### Logging
- **CloudWatch**: 365-day retention for application logs
- **Health Checks**: HTTP endpoint monitoring for service health
### Metrics
- **AWS OTel Collector**: Prometheus metrics collection
- **Datadog Agent**: Application performance monitoring and infrastructure metrics
## Container Management
- **Images**: `infisical/staging_infisical` and `infisical/license-api` from Docker Hub
- **Deployment**: Automated via GitHub Actions updating SSM parameter for image tags
- **Registry Access**: Docker Hub credentials stored in AWS Secrets Manager
- **Platform**: ECS Fargate serverless container platform
## Security Overview
### Data Protection
- **Encryption**: All secrets encrypted at rest and in transit
- **Network Isolation**: Services deployed in private subnets with controlled access
- **Authentication**: API tokens and service accounts for secure access
- **Audit Logging**: Comprehensive audit trails for all secret operations
### Network Architecture
- **VPC Design**: Dedicated VPC with public and private subnets across multiple Availability Zones
- **NAT Gateway**: Controlled outbound connectivity from private subnets
- **Load Balancing**: Application Load Balancer with SSL termination and health checks
- **Security Groups**: Restrictive firewall rules and controlled network access
- **High Availability**: Multi-AZ deployment with automatic failover
- **Network Monitoring**: VPC Flow Logs with 365-day retention for traffic analysis

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
* {
border-radius: 0 !important;
}
#navbar .max-w-8xl { #navbar .max-w-8xl {
max-width: 100%; max-width: 100%;
border-bottom: 1px solid #ebebeb; border-bottom: 1px solid #ebebeb;
@@ -26,24 +30,20 @@
} }
#sidebar li > div.mt-2 { #sidebar li > div.mt-2 {
border-radius: 0;
padding: 5px; padding: 5px;
} }
#sidebar li > a.text-primary { #sidebar li > a.text-primary {
border-radius: 0;
background-color: #FBFFCC; background-color: #FBFFCC;
border-left: 4px solid #EFFF33; border-left: 4px solid #EFFF33;
padding: 5px; padding: 5px;
} }
#sidebar li > a.mt-2 { #sidebar li > a.mt-2 {
border-radius: 0;
padding: 5px; padding: 5px;
} }
#sidebar li > a.leading-6 { #sidebar li > a.leading-6 {
border-radius: 0;
padding: 0px; padding: 0px;
} }
@@ -68,65 +68,26 @@
} }
#content-area .mt-8 .block{ #content-area .mt-8 .block{
border-radius: 0;
border-width: 1px; border-width: 1px;
background-color: #FCFBFA; background-color: #FCFBFA;
border-color: #ebebeb; border-color: #ebebeb;
} }
/* #content-area:hover .mt-8 .block:hover{ /* #content-area:hover .mt-8 .block:hover{
border-radius: 0;
border-width: 1px; border-width: 1px;
background-color: #FDFFE5; background-color: #FDFFE5;
border-color: #EFFF33; border-color: #EFFF33;
} */ } */
#content-area .mt-8 .rounded-xl{
border-radius: 0;
}
#content-area .mt-8 .rounded-lg{
border-radius: 0;
}
#content-area .mt-6 .rounded-xl{
border-radius: 0;
}
#content-area .mt-6 .rounded-lg{
border-radius: 0;
}
#content-area .mt-6 .rounded-md{
border-radius: 0;
}
#content-area .mt-8 .rounded-md{
border-radius: 0;
}
#content-area div.my-4{ #content-area div.my-4{
border-radius: 0;
border-width: 1px; border-width: 1px;
} }
#content-area div.flex-1 { /* #content-area div.flex-1 {
/* text-transform: uppercase; */
opacity: 0.8; opacity: 0.8;
font-weight: 400; font-weight: 400;
} } */
#content-area button {
border-radius: 0;
}
#content-area a {
border-radius: 0;
}
#content-area .not-prose {
border-radius: 0;
}
/* .eyebrow { /* .eyebrow {
text-transform: uppercase; text-transform: uppercase;

View File

@@ -94,7 +94,7 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
className={twMerge( className={twMerge(
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700", "block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className, className,
isDisabled ? "pointer-events-none opacity-50" : "" isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""
)} )}
> >
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}> <Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>

View File

@@ -1,12 +1,20 @@
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faArrowRightToBracket, faEdit } from "@fortawesome/free-solid-svg-icons";
import { PolicyType } from "@app/hooks/api/policies/enums"; import { PolicyType } from "@app/hooks/api/policies/enums";
export const policyDetails: Record<PolicyType, { name: string; className: string }> = { export const policyDetails: Record<
PolicyType,
{ name: string; className: string; icon: IconDefinition }
> = {
[PolicyType.AccessPolicy]: { [PolicyType.AccessPolicy]: {
className: "bg-lime-900 text-lime-100", className: "bg-green/20 text-green",
name: "Access Policy" name: "Access Policy",
icon: faArrowRightToBracket
}, },
[PolicyType.ChangePolicy]: { [PolicyType.ChangePolicy]: {
className: "bg-indigo-900 text-indigo-100", className: "bg-yellow/20 text-yellow",
name: "Change Policy" name: "Change Policy",
icon: faEdit
} }
}; };

View File

@@ -65,11 +65,11 @@ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequests
const fetchApprovalRequests = async ({ const fetchApprovalRequests = async ({
projectSlug, projectSlug,
envSlug, envSlug,
authorProjectMembershipId authorUserId
}: TGetAccessApprovalRequestsDTO) => { }: TGetAccessApprovalRequestsDTO) => {
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>( const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
"/api/v1/access-approvals/requests", "/api/v1/access-approvals/requests",
{ params: { projectSlug, envSlug, authorProjectMembershipId } } { params: { projectSlug, envSlug, authorUserId } }
); );
return data.requests.map((request) => ({ return data.requests.map((request) => ({
@@ -109,12 +109,12 @@ export const useGetAccessRequestsCount = ({
export const useGetAccessApprovalPolicies = ({ export const useGetAccessApprovalPolicies = ({
projectSlug, projectSlug,
envSlug, envSlug,
authorProjectMembershipId, authorUserId,
options = {} options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) => }: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({ useQuery({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug), queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }), queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorUserId }),
...options, ...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true) enabled: Boolean(projectSlug) && (options?.enabled ?? true)
}); });
@@ -122,16 +122,13 @@ export const useGetAccessApprovalPolicies = ({
export const useGetAccessApprovalRequests = ({ export const useGetAccessApprovalRequests = ({
projectSlug, projectSlug,
envSlug, envSlug,
authorProjectMembershipId, authorUserId,
options = {} options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) => }: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({ useQuery({
queryKey: accessApprovalKeys.getAccessApprovalRequests( queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, authorUserId),
projectSlug, queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorUserId }),
envSlug,
authorProjectMembershipId
),
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
...options, ...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true) enabled: Boolean(projectSlug) && (options?.enabled ?? true),
placeholderData: (previousData) => previousData
}); });

View File

@@ -148,7 +148,7 @@ export type TCreateAccessRequestDTO = {
export type TGetAccessApprovalRequestsDTO = { export type TGetAccessApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
envSlug?: string; envSlug?: string;
authorProjectMembershipId?: string; authorUserId?: string;
}; };
export type TGetAccessPolicyApprovalCountDTO = { export type TGetAccessPolicyApprovalCountDTO = {

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { import {
decryptAssymmetric, decryptAssymmetric,
@@ -25,10 +25,11 @@ export const secretApprovalRequestKeys = {
status, status,
committer, committer,
offset, offset,
limit limit,
search
}: TGetSecretApprovalRequestList) => }: TGetSecretApprovalRequestList) =>
[ [
{ workspaceId, environment, status, committer, offset, limit }, { workspaceId, environment, status, committer, offset, limit, search },
"secret-approval-requests" "secret-approval-requests"
] as const, ] as const,
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) => detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
@@ -118,23 +119,25 @@ const fetchSecretApprovalRequestList = async ({
committer, committer,
status = "open", status = "open",
limit = 20, limit = 20,
offset offset = 0,
search = ""
}: TGetSecretApprovalRequestList) => { }: TGetSecretApprovalRequestList) => {
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>( const { data } = await apiRequest.get<{
"/api/v1/secret-approval-requests", approvals: TSecretApprovalRequest[];
{ totalCount: number;
}>("/api/v1/secret-approval-requests", {
params: { params: {
workspaceId, workspaceId,
environment, environment,
committer, committer,
status, status,
limit, limit,
offset offset,
search
} }
} });
);
return data.approvals; return data;
}; };
export const useGetSecretApprovalRequests = ({ export const useGetSecretApprovalRequests = ({
@@ -143,31 +146,32 @@ export const useGetSecretApprovalRequests = ({
options = {}, options = {},
status, status,
limit = 20, limit = 20,
offset = 0,
search,
committer committer
}: TGetSecretApprovalRequestList & TReactQueryOptions) => }: TGetSecretApprovalRequestList & TReactQueryOptions) =>
useInfiniteQuery({ useQuery({
initialPageParam: 0,
queryKey: secretApprovalRequestKeys.list({ queryKey: secretApprovalRequestKeys.list({
workspaceId, workspaceId,
environment, environment,
committer, committer,
status status,
limit,
search,
offset
}), }),
queryFn: ({ pageParam }) => queryFn: () =>
fetchSecretApprovalRequestList({ fetchSecretApprovalRequestList({
workspaceId, workspaceId,
environment, environment,
status, status,
committer, committer,
limit, limit,
offset: pageParam offset,
search
}), }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true), enabled: Boolean(workspaceId) && (options?.enabled ?? true),
getNextPageParam: (lastPage, pages) => { placeholderData: (previousData) => previousData
if (lastPage.length && lastPage.length < limit) return undefined;
return lastPage?.length !== 0 ? pages.length * limit : undefined;
}
}); });
const fetchSecretApprovalRequestDetails = async ({ const fetchSecretApprovalRequestDetails = async ({

View File

@@ -113,6 +113,7 @@ export type TGetSecretApprovalRequestList = {
committer?: string; committer?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string;
}; };
export type TGetSecretApprovalRequestCount = { export type TGetSecretApprovalRequestCount = {

View File

@@ -352,9 +352,9 @@ export const ProjectLayout = () => {
secretApprovalReqCount?.open || secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount accessApprovalRequestCount?.pendingCount
) && ( ) && (
<span className="ml-2 rounded border border-primary-400 bg-primary-600 px-1 py-0.5 text-xs font-semibold text-black"> <Badge variant="primary" className="ml-1.5">
{pendingRequestsCount} {pendingRequestsCount}
</span> </Badge>
)} )}
</MenuItem> </MenuItem>
)} )}

View File

@@ -19,41 +19,38 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const taxIDTypes = [ const taxIDTypes = [
{ label: "Australia ABN", value: "au_abn" }, { label: "Australia ABN", value: "au_abn" },
{ label: "Australia ARN", value: "au_arn" }, { label: "Australia ARN", value: "au_arn" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Brazil CNPJ", value: "br_cnpj" }, { label: "Brazil CNPJ", value: "br_cnpj" },
{ label: "Brazil CPF", value: "br_cpf" }, { label: "Brazil CPF", value: "br_cpf" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Canada BN", value: "ca_bn" }, { label: "Canada BN", value: "ca_bn" },
{ label: "Canada GST/HST", value: "ca_gst_hst" }, { label: "Canada GST/HST", value: "ca_gst_hst" },
{ label: "Canada PST BC", value: "ca_pst_bc" }, { label: "Canada PST BC", value: "ca_pst_bc" },
{ label: "Canada PST MB", value: "ca_pst_mb" }, { label: "Canada PST MB", value: "ca_pst_mb" },
{ label: "Canada PST SK", value: "ca_pst_sk" }, { label: "Canada PST SK", value: "ca_pst_sk" },
{ label: "Canada QST", value: "ca_qst" }, { label: "Canada QST", value: "ca_qst" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Chile TIN", value: "cl_tin" }, { label: "Chile TIN", value: "cl_tin" },
{ label: "Egypt TIN", value: "eg_tin" }, { label: "Egypt TIN", value: "eg_tin" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "EU OSS VAT", value: "eu_oss_vat" }, { label: "EU OSS VAT", value: "eu_oss_vat" },
{ label: "EU VAT", value: "eu_vat" }, { label: "EU VAT", value: "eu_vat" },
{ label: "GB VAT", value: "gb_vat" }, { label: "GB VAT", value: "gb_vat" },
{ label: "Georgia VAT", value: "ge_vat" }, { label: "Georgia VAT", value: "ge_vat" },
{ label: "Hong Kong BR", value: "hk_br" }, { label: "Hong Kong BR", value: "hk_br" },
{ label: "Hungary TIN", value: "hu_tin" }, { label: "Hungary TIN", value: "hu_tin" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Indonesia NPWP", value: "id_npwp" }, { label: "Indonesia NPWP", value: "id_npwp" },
{ label: "Israel VAT", value: "il_vat" }, { label: "Israel VAT", value: "il_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "Japan CN", value: "jp_cn" }, { label: "Japan CN", value: "jp_cn" },
{ label: "Japan RN", value: "jp_rn" }, { label: "Japan RN", value: "jp_rn" },
{ label: "Japan TRN", value: "jp_trn" }, { label: "Japan TRN", value: "jp_trn" },
{ label: "Kenya PIN", value: "ke_pin" }, { label: "Kenya PIN", value: "ke_pin" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Liechtenstein UID", value: "li_uid" }, { label: "Liechtenstein UID", value: "li_uid" },
{ label: "Mexico RFC", value: "mx_rfc" },
{ label: "Malaysia FRP", value: "my_frp" }, { label: "Malaysia FRP", value: "my_frp" },
{ label: "Malaysia ITN", value: "my_itn" }, { label: "Malaysia ITN", value: "my_itn" },
{ label: "Malaysia SST", value: "my_sst" }, { label: "Malaysia SST", value: "my_sst" },
{ label: "Norway VAT", value: "no_vat" }, { label: "Mexico RFC", value: "mx_rfc" },
{ label: "New Zealand GST", value: "nz_gst" }, { label: "New Zealand GST", value: "nz_gst" },
{ label: "Norway VAT", value: "no_vat" },
{ label: "Philippines TIN", value: "ph_tin" }, { label: "Philippines TIN", value: "ph_tin" },
{ label: "Russia INN", value: "ru_inn" }, { label: "Russia INN", value: "ru_inn" },
{ label: "Russia KPP", value: "ru_kpp" }, { label: "Russia KPP", value: "ru_kpp" },
@@ -61,12 +58,15 @@ const taxIDTypes = [
{ label: "Singapore GST", value: "sg_gst" }, { label: "Singapore GST", value: "sg_gst" },
{ label: "Singapore UEN", value: "sg_uen" }, { label: "Singapore UEN", value: "sg_uen" },
{ label: "Slovenia TIN", value: "si_tin" }, { label: "Slovenia TIN", value: "si_tin" },
{ label: "South Africa VAT", value: "za_vat" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Thailand VAT", value: "th_vat" }, { label: "Thailand VAT", value: "th_vat" },
{ label: "Turkey TIN", value: "tr_tin" }, { label: "Turkey TIN", value: "tr_tin" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Ukraine VAT", value: "ua_vat" },
{ label: "US EIN", value: "us_ein" }, { label: "US EIN", value: "us_ein" },
{ label: "South Africa VAT", value: "za_vat" } { label: "Ukraine VAT", value: "ua_vat" }
]; ];
const schema = z const schema = z

View File

@@ -1,7 +1,5 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => {
<PageHeader <PageHeader
title="Approval Workflows" title="Approval Workflows"
description="Create approval policies for any modifications to secrets in sensitive environments and folders." description="Create approval policies for any modifications to secrets in sensitive environments and folders."
>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<span 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"
/> />
</span>
</a>
</PageHeader>
<Tabs defaultValue={defaultTab}> <Tabs defaultValue={defaultTab}>
<TabList> <TabList>
<Tab value={TabSection.SecretApprovalRequests}> <Tab value={TabSection.SecretApprovalRequests}>

View File

@@ -2,15 +2,25 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
faArrowUpRightFromSquare,
faBan,
faBookOpen,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
faChevronDown, faChevronDown,
faClipboardCheck,
faLock, faLock,
faPlus faMagnifyingGlass,
faPlus,
faSearch,
faStopwatch,
faUser,
IconDefinition
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { import {
@@ -21,6 +31,8 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Input,
Pagination,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -32,7 +44,12 @@ import {
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api"; import { useGetWorkspaceUsers } from "@app/hooks/api";
import { import {
accessApprovalKeys, accessApprovalKeys,
@@ -48,28 +65,21 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { RequestAccessModal } from "./components/RequestAccessModal"; import { RequestAccessModal } from "./components/RequestAccessModal";
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { const generateRequestText = (request: TAccessApprovalRequest) => {
const { isTemporary } = request; const { isTemporary } = request;
return ( return (
<div className="flex w-full items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div> <div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "} Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.policy.secretPath} {request.policy.secretPath}
</code> </code>{" "}
in in{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.environmentName} {request.environmentName}
</code> </code>
</div> </div>
<div>
{request.requestedByUserId === userId && (
<span className="text-xs text-gray-500">
<Badge className="ml-1">Requested By You</Badge>
</span>
)}
</div>
</div> </div>
); );
}; };
@@ -120,30 +130,64 @@ export const AccessApprovalRequest = ({
projectSlug projectSlug
}); });
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ const {
data: requests,
refetch: refetchRequests,
isPending: areRequestsPending
} = useGetAccessApprovalRequests({
projectSlug, projectSlug,
authorProjectMembershipId: requestedByFilter, authorUserId: requestedByFilter,
envSlug: envFilter envSlug: envFilter
}); });
const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", {
initPerPage: getUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRequests = useMemo(() => { const filteredRequests = useMemo(() => {
let accessRequests: typeof requests;
if (statusFilter === "open") if (statusFilter === "open")
return requests?.filter( accessRequests = requests?.filter(
(request) => (request) =>
!request.policy.deletedAt && !request.policy.deletedAt &&
!request.isApproved && !request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
if (statusFilter === "close") if (statusFilter === "close")
return requests?.filter( accessRequests = requests?.filter(
(request) => (request) =>
request.policy.deletedAt || request.policy.deletedAt ||
request.isApproved || request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
return requests; return (
}, [requests, statusFilter, requestedByFilter, envFilter]); accessRequests?.filter((request) => {
const { environmentName, requestedByUser } = request;
const searchValue = search.trim().toLowerCase();
return (
environmentName?.toLowerCase().includes(searchValue) ||
`${requestedByUser?.email ?? ""} ${requestedByUser?.firstName ?? ""} ${requestedByUser?.lastName ?? ""}`
.toLowerCase()
.includes(searchValue)
);
}) ?? []
);
}, [requests, statusFilter, requestedByFilter, envFilter, search]);
useResetPageHelper({
totalCount: filteredRequests.length,
offset,
setPage
});
const generateRequestDetails = useCallback( const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => { (request: TAccessApprovalRequest) => {
@@ -162,9 +206,15 @@ export const AccessApprovalRequest = ({
const canBypass = const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id); !request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
let displayData: { label: string; type: "primary" | "danger" | "success" } = { let displayData: {
label: string;
type: "primary" | "danger" | "success";
tooltipContent?: string;
icon: IconDefinition | null;
} = {
label: "", label: "",
type: "primary" type: "primary",
icon: null
}; };
const isExpired = const isExpired =
@@ -172,20 +222,42 @@ export const AccessApprovalRequest = ({
request.isApproved && request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired) displayData = { label: "Access Expired", type: "danger" }; if (isExpired)
else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; displayData = {
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; label: "Access Expired",
type: "danger",
icon: faStopwatch,
tooltipContent: request.privilege?.temporaryAccessEndTime
? `Expired ${format(request.privilege.temporaryAccessEndTime, "M/d/yyyy h:mm aa")}`
: undefined
};
else if (isAccepted)
displayData = {
label: "Access Granted",
type: "success",
icon: faCheck,
tooltipContent: `Granted ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
};
else if (isRejectedByAnyone)
displayData = {
label: "Rejected",
type: "danger",
icon: faBan,
tooltipContent: `Rejected ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
};
else if (userReviewStatus === ApprovalStatus.APPROVED) { else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = { displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${ label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : "" request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`, }`,
type: "primary" type: "primary",
icon: faClipboardCheck
}; };
} else if (!isReviewedByUser) } else if (!isReviewedByUser)
displayData = { displayData = {
label: "Review Required", label: "Review Required",
type: "primary" type: "primary",
icon: faClipboardCheck
}; };
return { return {
@@ -225,16 +297,42 @@ export const AccessApprovalRequest = ({
[generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen] [generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen]
); );
const isFiltered = Boolean(search || envFilter || requestedByFilter);
return ( return (
<AnimatePresence mode="wait">
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div> <div>
<div className="mb-6 flex items-end justify-between"> <div className="flex items-start gap-1">
<div className="flex flex-col"> <p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span> <a
<div className="mt-2 text-sm text-bunker-300"> href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
Request access to secrets in sensitive environments and folders. target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] 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> </div>
</a>
</div>
<p className="text-sm text-bunker-300">
Request and review access to secrets in sensitive environments and folders
</p>
</div> </div>
<div>
<Tooltip <Tooltip
content="To submit Access Requests, your project needs to create Access Request policies first." content="To submit Access Requests, your project needs to create Access Request policies first."
isDisabled={policiesLoading || !!policies?.length} isDisabled={policiesLoading || !!policies?.length}
@@ -247,25 +345,23 @@ export const AccessApprovalRequest = ({
} }
handlePopUpOpen("requestAccess"); handlePopUpOpen("requestAccess");
}} }}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={policiesLoading || !policies?.length} isDisabled={policiesLoading || !policies?.length}
> >
Request access Request Access
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
</div> <Input
value={search}
<AnimatePresence> onChange={(e) => setSearch(e.target.value)}
<motion.div leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
key="approval-changes-list" placeholder="Search approval requests by requesting user or environment..."
transition={{ duration: 0.1 }} className="flex-1"
initial={{ opacity: 0, translateX: 30 }} containerClassName="mb-4"
animate={{ opacity: 1, translateX: 0 }} />
exit={{ opacity: 0, translateX: 30 }} <div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
className="rounded-md text-gray-300"
>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -273,17 +369,19 @@ export const AccessApprovalRequest = ({
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open"); if (evt.key === "Enter") setStatusFilter("open");
}} }}
className={ className={twMerge(
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
> >
<FontAwesomeIcon icon={faLock} className="mr-2" /> <FontAwesomeIcon icon={faLock} className="mr-2" />
{!!requestCount && requestCount?.pendingCount} Pending {!!requestCount && requestCount?.pendingCount} Pending
</div> </div>
<div <div
className={ className={twMerge(
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setStatusFilter("close")} onClick={() => setStatusFilter("close")}
@@ -292,7 +390,7 @@ export const AccessApprovalRequest = ({
}} }}
> >
<FontAwesomeIcon icon={faCheck} className="mr-2" /> <FontAwesomeIcon icon={faCheck} className="mr-2" />
{!!requestCount && requestCount.finalizedCount} Completed {!!requestCount && requestCount.finalizedCount} Closed
</div> </div>
<div className="flex flex-grow justify-end space-x-8"> <div className="flex flex-grow justify-end space-x-8">
<DropdownMenu> <DropdownMenu>
@@ -300,14 +398,20 @@ export const AccessApprovalRequest = ({
<Button <Button
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
className="text-bunker-300" className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />} rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
> >
Environments Environments
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select an environment</DropdownMenuLabel> align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => ( {currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -337,15 +441,27 @@ export const AccessApprovalRequest = ({
Requested By Requested By
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
<DropdownMenuLabel>Select an author</DropdownMenuLabel> align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select Requesting User
</DropdownMenuLabel>
{members?.map(({ user: membershipUser, id }) => ( {members?.map(({ user: membershipUser, id }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
setRequestedByFilter((state) => (state === id ? undefined : id)) setRequestedByFilter((state) =>
state === membershipUser.id ? undefined : membershipUser.id
)
} }
key={`request-filter-member-${id}`} key={`request-filter-member-${id}`}
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />} icon={
requestedByFilter === membershipUser.id && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right" iconPos="right"
> >
{membershipUser.username} {membershipUser.username}
@@ -357,19 +473,26 @@ export const AccessApprovalRequest = ({
</div> </div>
</div> </div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800"> <div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{filteredRequests?.length === 0 && ( {filteredRequests?.length === 0 && !isFiltered && (
<div className="py-12"> <div className="py-12">
<EmptyState title="No more access requests pending." /> <EmptyState
title={`No ${statusFilter === "open" ? "Pending" : "Closed"} Access Requests`}
/>
</div>
)}
{Boolean(!filteredRequests?.length && isFiltered && !areRequestsPending) && (
<div className="py-12">
<EmptyState title="No Requests Match Filters" icon={faSearch} />
</div> </div>
)} )}
{!!filteredRequests?.length && {!!filteredRequests?.length &&
filteredRequests?.map((request) => { filteredRequests?.slice(offset, perPage * page).map((request) => {
const details = generateRequestDetails(request); const details = generateRequestDetails(request);
return ( return (
<div <div
key={request.id} key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80" className="flex w-full cursor-pointer border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => handleSelectRequest(request)} onClick={() => handleSelectRequest(request)}
@@ -379,14 +502,18 @@ export const AccessApprovalRequest = ({
} }
}} }}
> >
<div className="w-full"> <div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col justify-between"> <div className="flex w-full flex-col justify-between">
<div className="mb-1 flex w-full items-center"> <div className="mb-1 flex w-full items-center">
<FontAwesomeIcon icon={faLock} className="mr-2" /> <FontAwesomeIcon
{generateRequestText(request, user.id)} icon={faLock}
size="xs"
className="mr-1.5 text-mineshaft-300"
/>
{generateRequestText(request)}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs text-gray-500"> <div className="text-xs leading-3 text-gray-500">
{membersGroupById?.[request.requestedByUserId]?.user && ( {membersGroupById?.[request.requestedByUserId]?.user && (
<> <>
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
@@ -397,21 +524,45 @@ export const AccessApprovalRequest = ({
</> </>
)} )}
</div> </div>
</div>
</div>
<div className="flex items-center gap-3">
{request.requestedByUserId === user.id && (
<div className="flex items-center gap-1.5 whitespace-nowrap text-xs text-bunker-300">
<FontAwesomeIcon icon={faUser} size="sm" />
<span>Requested By You</span>
</div>
)}
<Tooltip content={details.displayData.tooltipContent}>
<div> <div>
<Badge variant={details.displayData.type}> <Badge
{details.displayData.label} className="flex items-center gap-1.5 whitespace-nowrap"
variant={details.displayData.type}
>
{details.displayData.icon && (
<FontAwesomeIcon icon={details.displayData.icon} />
)}
<span>{details.displayData.label}</span>
</Badge> </Badge>
</div> </div>
</div> </Tooltip>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{Boolean(filteredRequests.length) && (
<Pagination
className="border-none"
count={filteredRequests.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</div> </div>
</motion.div>
</AnimatePresence>
{!!policies && ( {!!policies && (
<RequestAccessModal <RequestAccessModal
policies={policies} policies={policies}
@@ -452,6 +603,7 @@ export const AccessApprovalRequest = ({
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")} onOpenChange={() => handlePopUpClose("upgradePlan")}
/> />
</div> </motion.div>
</AnimatePresence>
); );
}; };

View File

@@ -1,11 +1,19 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCheckCircle, faCheckCircle,
faChevronDown,
faFileShield, faFileShield,
faPlus faFilter,
faMagnifyingGlass,
faPlus,
faSearch
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -19,8 +27,9 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Modal, IconButton,
ModalContent, Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -38,7 +47,12 @@ import {
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { import {
useDeleteAccessApprovalPolicy, useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy, useDeleteSecretApprovalPolicy,
@@ -47,6 +61,7 @@ import {
useListWorkspaceGroups useListWorkspaceGroups
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PolicyType } from "@app/hooks/api/policies/enums"; import { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
@@ -57,6 +72,18 @@ interface IProps {
workspaceId: string; workspaceId: string;
} }
enum PolicyOrderBy {
Name = "name",
Environment = "environment",
SecretPath = "secret-path",
Type = "type"
}
type PolicyFilters = {
type: null | PolicyType;
environmentIds: string[];
};
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => { const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
{ {
@@ -112,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
currentWorkspace currentWorkspace
); );
const [filterType, setFilterType] = useState<string | null>(null); const [filters, setFilters] = useState<PolicyFilters>({
type: null,
environmentIds: []
});
const filteredPolicies = useMemo(() => { const {
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; search,
}, [policies, filterType]); setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<PolicyOrderBy>(PolicyOrderBy.Name, {
initPerPage: getUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredPolicies = useMemo(
() =>
policies
.filter(({ policyType, environment, name, secretPath }) => {
if (filters.type && policyType !== filters.type) return false;
if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id))
return false;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
environment.name.toLowerCase().includes(searchValue) ||
(secretPath ?? "*").toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [policyOne, policyTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case PolicyOrderBy.Type:
return policyOne.policyType
.toLowerCase()
.localeCompare(policyTwo.policyType.toLowerCase());
case PolicyOrderBy.Environment:
return policyOne.environment.name
.toLowerCase()
.localeCompare(policyTwo.environment.name.toLowerCase());
case PolicyOrderBy.SecretPath:
return (policyOne.secretPath ?? "*")
.toLowerCase()
.localeCompare((policyTwo.secretPath ?? "*").toLowerCase());
case PolicyOrderBy.Name:
default:
return policyOne.name.toLowerCase().localeCompare(policyTwo.name.toLowerCase());
}
}),
[policies, filters, search, orderBy, orderDirection]
);
useResetPageHelper({
totalCount: filteredPolicies.length,
offset,
setPage
});
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy(); const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
@@ -151,16 +246,57 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
} }
}; };
const isTableFiltered = filters.type !== null || Boolean(filters.environmentIds.length);
const handleSort = (column: PolicyOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: PolicyOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: PolicyOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return ( return (
<AnimatePresence mode="wait">
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div> <div>
<div className="mb-6 flex items-end justify-between"> <div className="flex items-start gap-1">
<div className="flex flex-col"> <p className="text-xl font-semibold text-mineshaft-100">Policies</p>
<span className="text-xl font-semibold text-mineshaft-100">Policies</span> <a
<div className="mt-2 text-sm text-bunker-300"> href="https://infisical.com/docs/documentation/platform/pr-workflows"
Implement granular policies for access requests and secrets management. target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] 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> </div>
</a>
</div>
<p className="text-sm text-bunker-300">
Implement granular policies for access requests and secrets management
</p>
</div> </div>
<div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
@@ -174,6 +310,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
} }
handlePopUpOpen("policyForm"); handlePopUpOpen("policyForm");
}} }}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
@@ -182,41 +319,54 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> <div className="mb-4 flex items-center gap-2">
<TableContainer> <Input
<Table> value={search}
<THead> onChange={(e) => setSearch(e.target.value)}
<Tr> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th>Name</Th> placeholder="Search policies by name, type, environment or secret path..."
<Th>Environment</Th> className="flex-1"
<Th>Secret Path</Th> />
<Th>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<Button <IconButton
ariaLabel="Filter findings"
variant="plain" variant="plain"
colorSchema="secondary" size="sm"
className="text-xs font-semibold uppercase text-bunker-300" className={twMerge(
rightIcon={ "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",
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" /> isTableFiltered && "border-primary/50 text-primary"
} )}
> >
Type <FontAwesomeIcon icon={faFilter} />
</Button> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select a type</DropdownMenuLabel> className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Policy Type</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setFilterType(null)} onClick={() =>
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />} setFilters((prev) => ({
...prev,
type: null
}))
}
icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right" iconPos="right"
> >
All All
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setFilterType(PolicyType.AccessPolicy)} onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.AccessPolicy
}))
}
icon={ icon={
filterType === PolicyType.AccessPolicy && ( filters.type === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} /> <FontAwesomeIcon icon={faCheckCircle} />
) )
} }
@@ -225,9 +375,14 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
Access Policy Access Policy
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setFilterType(PolicyType.ChangePolicy)} onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.ChangePolicy
}))
}
icon={ icon={
filterType === PolicyType.ChangePolicy && ( filters.type === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} /> <FontAwesomeIcon icon={faCheckCircle} />
) )
} }
@@ -235,25 +390,110 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
> >
Change Policy Change Policy
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{currentWorkspace.environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Name)} />
</IconButton>
</div>
</Th> </Th>
<Th /> <Th>
<div className="flex items-center">
Environment
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Secret Path
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Type)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Type)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Type)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isPoliciesLoading && ( {isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" /> <TableSkeleton
columns={5}
innerKey="secret-policies"
className="bg-mineshaft-700"
/>
)} )}
{!isPoliciesLoading && !filteredPolicies?.length && ( {!isPoliciesLoading && !policies?.length && (
<Tr> <Tr>
<Td colSpan={6}> <Td colSpan={5}>
<EmptyState title="No policies found" icon={faFileShield} /> <EmptyState title="No Policies Found" icon={faFileShield} />
</Td> </Td>
</Tr> </Tr>
)} )}
{!!currentWorkspace && {!!currentWorkspace &&
filteredPolicies?.map((policy) => ( filteredPolicies
?.slice(offset, perPage * page)
.map((policy) => (
<ApprovalPolicyRow <ApprovalPolicyRow
policy={policy} policy={policy}
key={policy.id} key={policy.id}
@@ -265,20 +505,21 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
))} ))}
</TBody> </TBody>
</Table> </Table>
{Boolean(!filteredPolicies.length && policies.length && !isPoliciesLoading) && (
<EmptyState title="No Policies Match Search" icon={faSearch} />
)}
{Boolean(filteredPolicies.length) && (
<Pagination
count={filteredPolicies.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</TableContainer> </TableContainer>
<Modal </div>
isOpen={popUp.policyForm.isOpen} </motion.div>
onOpenChange={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
>
<ModalContent
className="max-w-3xl"
title={
popUp.policyForm.data
? `Edit ${popUp?.policyForm?.data?.name || "Policy"}`
: "Create Policy"
}
id="policy-form"
>
<AccessPolicyForm <AccessPolicyForm
projectId={currentWorkspace.id} projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug} projectSlug={currentWorkspace.slug}
@@ -287,8 +528,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
members={members} members={members}
editValues={popUp.policyForm.data as TAccessApprovalPolicy} editValues={popUp.policyForm.data as TAccessApprovalPolicy}
/> />
</ModalContent>
</Modal>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deletePolicy.isOpen} isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -301,6 +540,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan." text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/> />
</div> </AnimatePresence>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { RefObject, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -13,6 +13,8 @@ import {
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
Modal,
ModalContent,
Select, Select,
SelectItem, SelectItem,
Switch, Switch,
@@ -110,20 +112,20 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({ const Form = ({
isOpen,
onToggle, onToggle,
members = [], members = [],
projectId, projectId,
projectSlug, projectSlug,
editValues editValues,
}: Props) => { modalContainer,
isEditMode
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null); const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null); const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const { const {
control, control,
handleSubmit, handleSubmit,
reset,
watch, watch,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
@@ -188,13 +190,8 @@ export const AccessPolicyForm = ({
const { data: groups } = useListWorkspaceGroups(projectId); const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
@@ -387,6 +384,7 @@ export const AccessPolicyForm = ({
setDraggedItem(null); setDraggedItem(null);
setDragOverItem(null); setDragOverItem(null);
}; };
return ( return (
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
@@ -572,7 +570,7 @@ export const AccessPolicyForm = ({
className="flex-grow" className="flex-grow"
> >
<FilterableSelect <FilterableSelect
menuPortalTarget={document.getElementById("policy-form")} menuPortalTarget={modalContainer.current}
menuPlacement="top" menuPlacement="top"
isMulti isMulti
placeholder="Select members..." placeholder="Select members..."
@@ -602,7 +600,7 @@ export const AccessPolicyForm = ({
className="flex-grow" className="flex-grow"
> >
<FilterableSelect <FilterableSelect
menuPortalTarget={document.getElementById("policy-form")} menuPortalTarget={modalContainer.current}
menuPlacement="top" menuPlacement="top"
isMulti isMulti
placeholder="Select groups..." placeholder="Select groups..."
@@ -813,3 +811,27 @@ export const AccessPolicyForm = ({
</div> </div>
); );
}; };
export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => {
const modalContainer = useRef<HTMLDivElement>(null);
const isEditMode = Boolean(editValues);
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-3xl"
ref={modalContainer}
title={isEditMode ? "Edit Policy" : "Create Policy"}
>
<Form
{...props}
isOpen={isOpen}
onToggle={onToggle}
editValues={editValues}
modalContainer={modalContainer}
isEditMode={isEditMode}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -9,6 +9,8 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
GenericFieldLabel,
IconButton,
Td, Td,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
@@ -102,36 +104,47 @@ export const ApprovalPolicyRow = ({
}} }}
onClick={() => setIsExpanded.toggle()} onClick={() => setIsExpanded.toggle()}
> >
<Td>{policy.name}</Td> <Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.name}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td> <Td>
<Badge className={policyDetails[policy.policyType].className}> <Badge
{policyDetails[policy.policyType].name} className={twMerge(
policyDetails[policy.policyType].className,
"flex w-min items-center gap-1.5 whitespace-nowrap"
)}
>
<FontAwesomeIcon icon={policyDetails[policy.policyType].icon} />
<span>{policyDetails[policy.policyType].name}</span>
</Badge> </Badge>
</Td> </Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg"> <DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400"> <DropdownMenuTrigger asChild>
<FontAwesomeIcon size="sm" icon={faEllipsis} /> <IconButton
</div> ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1"> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(); onEdit();
}} }}
disabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
> >
Edit Policy Edit Policy
</DropdownMenuItem> </DropdownMenuItem>
@@ -143,16 +156,12 @@ export const ApprovalPolicyRow = ({
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
disabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
> >
Delete Policy Delete Policy
</DropdownMenuItem> </DropdownMenuItem>
@@ -162,45 +171,41 @@ export const ApprovalPolicyRow = ({
</DropdownMenu> </DropdownMenu>
</Td> </Td>
</Tr> </Tr>
{isExpanded && (
<Tr> <Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900"> <Td colSpan={6} className="!border-none p-0">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div> <div
className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ease-in-out ${
isExpanded ? "thin-scrollbar max-h-[26rem] !overflow-y-auto opacity-100" : "max-h-0"
}`}
>
<div className="p-4">
<div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
{labels?.map((el, index) => ( {labels?.map((el, index) => (
<div <div
key={`approval-list-${index + 1}`} key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4" className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
> >
<div> <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 className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white"> <div>{index + 1}</div>
<div className="text-lg">{index + 1}</div>
</div> </div>
{index !== labels.length - 1 && ( {index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" /> <div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
)} )}
{index !== 0 && ( {index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" /> <div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
)} )}
</div>
<div className="grid flex-grow grid-cols-3"> <div className="grid flex-grow grid-cols-3">
<div> <GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<div className="mb-1 text-xs font-semibold uppercase">Users</div> <GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<div>{el.userLabels || "-"}</div> <GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
</div>
</div> </div>
</div> </div>
))} ))}
</div>
</div>
</Td> </Td>
</Tr> </Tr>
)}
</> </>
); );
}; };

View File

@@ -1,14 +1,19 @@
import { Fragment, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
faArrowUpRightFromSquare,
faBookOpen,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
faChevronDown, faChevronDown,
faCodeBranch faCodeBranch,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { formatDistance } from "date-fns"; import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { import {
Button, Button,
@@ -18,6 +23,8 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Input,
Pagination,
Skeleton Skeleton
} from "@app/components/v2"; } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
@@ -28,6 +35,12 @@ import {
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination } from "@app/hooks";
import { import {
useGetSecretApprovalRequestCount, useGetSecretApprovalRequestCount,
useGetSecretApprovalRequests, useGetSecretApprovalRequests,
@@ -52,18 +65,41 @@ export const SecretApprovalRequest = () => {
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false); const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
const { const {
data: secretApprovalRequests, debouncedSearch: debouncedSearchFilter,
isFetchingNextPage: isFetchingNextApprovalRequest, search: searchFilter,
fetchNextPage: fetchNextApprovalRequest, setSearch: setSearchFilter,
hasNextPage: hasNextApprovalPage, setPage,
page,
perPage,
setPerPage,
offset,
limit
} = usePagination("", {
initPerPage: getUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, newPerPage);
};
const {
data,
isPending: isApprovalRequestLoading, isPending: isApprovalRequestLoading,
refetch refetch
} = useGetSecretApprovalRequests({ } = useGetSecretApprovalRequests({
workspaceId, workspaceId,
status: statusFilter, status: statusFilter,
environment: envFilter, environment: envFilter,
committer: committerFilter committer: committerFilter,
search: debouncedSearchFilter,
limit,
offset
}); });
const totalApprovalCount = data?.totalCount ?? 0;
const secretApprovalRequests = data?.approvals ?? [];
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId }); useGetSecretApprovalRequestCount({ workspaceId });
const { user: userSession } = useUser(); const { user: userSession } = useUser();
@@ -88,8 +124,9 @@ export const SecretApprovalRequest = () => {
refetch(); refetch();
}; };
const isRequestListEmpty = const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0;
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
const isFiltered = Boolean(searchFilter || envFilter || committerFilter);
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -116,7 +153,38 @@ export const SecretApprovalRequest = () => {
exit={{ opacity: 0, translateX: 30 }} exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300" className="rounded-md text-gray-300"
> >
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8"> <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Change Requests</p>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] 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>
<p className="text-sm text-bunker-300">Review pending and closed change requests</p>
</div>
</div>
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search change requests by author, environment or policy path..."
className="flex-1"
containerClassName="mb-4"
/>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -124,17 +192,19 @@ export const SecretApprovalRequest = () => {
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open"); if (evt.key === "Enter") setStatusFilter("open");
}} }}
className={ className={twMerge(
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
> >
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" /> <FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div> </div>
<div <div
className={ className={twMerge(
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setStatusFilter("close")} onClick={() => setStatusFilter("close")}
@@ -152,13 +222,21 @@ export const SecretApprovalRequest = () => {
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"} className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />} rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
> >
Environments Environments
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select an environment</DropdownMenuLabel> align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => ( {currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -188,8 +266,14 @@ export const SecretApprovalRequest = () => {
Author Author
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
<DropdownMenuLabel>Select an author</DropdownMenuLabel> align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Author
</DropdownMenuLabel>
{members?.map(({ user, id }) => ( {members?.map(({ user, id }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -210,14 +294,14 @@ export const SecretApprovalRequest = () => {
</div> </div>
</div> </div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800"> <div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && ( {isRequestListEmpty && !isFiltered && (
<div className="py-12"> <div className="py-12">
<EmptyState title="No more requests pending." /> <EmptyState
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
/>
</div> </div>
)} )}
{secretApprovalRequests?.pages?.map((group, i) => ( {secretApprovalRequests.map((secretApproval) => {
<Fragment key={`secret-approval-request-${i + 1}`}>
{group?.map((secretApproval) => {
const { const {
id: reqId, id: reqId,
commits, commits,
@@ -233,7 +317,7 @@ export const SecretApprovalRequest = () => {
return ( return (
<div <div
key={reqId} key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700" className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)} onClick={() => setSelectedApprovalId(secretApproval.id)}
@@ -241,14 +325,18 @@ export const SecretApprovalRequest = () => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}} }}
> >
<div className="mb-1"> <div className="mb-1 text-sm">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" /> <FontAwesomeIcon
icon={faCodeBranch}
size="sm"
className="mr-1.5 text-mineshaft-300"
/>
{secretApproval.isReplicated {secretApproval.isReplicated
? `${commits.length} secret pending import` ? `${commits.length} secret pending import`
: generateCommitText(commits)} : generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span> <span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div> </div>
<span className="text-xs text-gray-500"> <span className="text-xs leading-3 text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} ( {committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email}) {committerUser?.email})
@@ -257,9 +345,24 @@ export const SecretApprovalRequest = () => {
</div> </div>
); );
})} })}
</Fragment> {Boolean(
))} !secretApprovalRequests.length && isFiltered && !isApprovalRequestLoading
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( ) && (
<div className="py-12">
<EmptyState title="No Requests Match Filters" icon={faSearch} />
</div>
)}
{Boolean(totalApprovalCount) && (
<Pagination
className="border-none"
count={totalApprovalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{isApprovalRequestLoading && (
<div> <div>
{Array.apply(0, Array(3)).map((_x, index) => ( {Array.apply(0, Array(3)).map((_x, index) => (
<div <div
@@ -276,18 +379,7 @@ export const SecretApprovalRequest = () => {
</div> </div>
)} )}
</div> </div>
{hasNextApprovalPage && ( </div>
<Button
className="mt-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextApprovalRequest}
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
onClick={() => fetchNextApprovalRequest()}
>
{hasNextApprovalPage ? "Load More" : "End of history"}
</Button>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
if (score[CommitType.CREATE]) if (score[CommitType.CREATE])
text.push( text.push(
<span key="created-commit"> <span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} {score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#60DD00" }}> created</span> <span className="text-green-600"> Created</span>
</span> </span>
); );
if (score[CommitType.UPDATE]) if (score[CommitType.UPDATE])
text.push( text.push(
<span key="updated-commit"> <span key="updated-commit">
{Boolean(text.length) && ", "} {Boolean(text.length) && ", "}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} {score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F8EB30" }} className="text-orange-600"> <span className="text-yellow-600"> Updated</span>
{" "}
updated
</span>
</span> </span>
); );
if (score[CommitType.DELETE]) if (score[CommitType.DELETE])
text.push( text.push(
<span className="deleted-commit"> <span className="deleted-commit">
{Boolean(text.length) && "and"} {Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} {score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
<span style={{ color: "#F83030" }}> deleted</span> <span className="text-red-600"> Deleted</span>
</span> </span>
); );
return text; return text;

View File

@@ -37,7 +37,10 @@ const formSchema = z.object({
policyArns: z.string().trim().optional(), policyArns: z.string().trim().optional(),
tags: z tags: z
.array( .array(
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
) )
.optional() .optional()
}), }),
@@ -52,7 +55,10 @@ const formSchema = z.object({
policyArns: z.string().trim().optional(), policyArns: z.string().trim().optional(),
tags: z tags: z
.array( .array(
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
) )
.optional() .optional()
}) })

View File

@@ -26,7 +26,7 @@ const formSchema = z.object({
policyArns: z.string().trim().optional(), policyArns: z.string().trim().optional(),
tags: z tags: z
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })) .array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional(), .optional()
}), }),
z.object({ z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole), method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
@@ -97,7 +97,7 @@ export const EditDynamicSecretAwsIamForm = ({
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}", usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
inputs: { inputs: {
...(dynamicSecret.inputs as TForm["inputs"]) ...(dynamicSecret.inputs as TForm["inputs"])
}, }
} }
}); });
const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey; const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey;
@@ -125,8 +125,7 @@ export const EditDynamicSecretAwsIamForm = ({
defaultTTL, defaultTTL,
inputs, inputs,
newName: newName === dynamicSecret.name ? undefined : newName, newName: newName === dynamicSecret.name ? undefined : newName,
usernameTemplate: usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
!usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
} }
}); });
onClose(); onClose();