mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-28 18:55:53 +00:00
Compare commits
39 Commits
misc/add-s
...
feat/posth
Author | SHA1 | Date | |
---|---|---|---|
|
8044999785 | ||
|
be51e4372d | ||
|
0f04890d8f | ||
|
61274243e2 | ||
|
842a2e9a06 | ||
|
de81d2d380 | ||
|
f5d769fa05 | ||
|
b3ace353ce | ||
|
48353ab201 | ||
|
2137d13157 | ||
|
647e13d654 | ||
|
bb2a933a39 | ||
|
6f75debb9c | ||
|
90588bc3c9 | ||
|
4a09fc5e63 | ||
|
f0ec8c883f | ||
|
b30706607f | ||
|
2a3d19dcb2 | ||
|
b4ff620b44 | ||
|
23f1888123 | ||
|
7764f63299 | ||
|
cb3365afd4 | ||
|
58705ffc3f | ||
|
67e57d8993 | ||
|
90ff13a6b5 | ||
|
36145a15c1 | ||
|
4f64ed6b42 | ||
|
d47959ca83 | ||
|
3b2953ca58 | ||
|
1daa503e0e | ||
|
d69e8d2a8d | ||
|
7c7af347fc | ||
|
e5a967b918 | ||
|
3cfe2223b6 | ||
|
7e9743b4c2 | ||
|
34cf544b3a | ||
|
12fd063cd5 | ||
|
8fb6063686 | ||
|
459b262865 |
@@ -8,6 +8,9 @@ import { Lock } from "@app/lib/red-lock";
|
||||
export const mockKeyStore = (): TKeyStoreFactory => {
|
||||
const store: Record<string, string | number | Buffer> = {};
|
||||
|
||||
const getRegex = (pattern: string) =>
|
||||
new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
||||
|
||||
return {
|
||||
setItem: async (key, value) => {
|
||||
store[key] = value;
|
||||
@@ -23,7 +26,7 @@ export const mockKeyStore = (): TKeyStoreFactory => {
|
||||
return 1;
|
||||
},
|
||||
deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => {
|
||||
const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
||||
const regex = getRegex(pattern);
|
||||
let totalDeleted = 0;
|
||||
const keys = Object.keys(store);
|
||||
|
||||
@@ -53,6 +56,27 @@ export const mockKeyStore = (): TKeyStoreFactory => {
|
||||
incrementBy: async () => {
|
||||
return 1;
|
||||
},
|
||||
getItems: async (keys) => {
|
||||
const values = keys.map((key) => {
|
||||
const value = store[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return values;
|
||||
},
|
||||
getKeysByPattern: async (pattern) => {
|
||||
const regex = getRegex(pattern);
|
||||
const keys = Object.keys(store);
|
||||
return keys.filter((key) => regex.test(key));
|
||||
},
|
||||
deleteItemsByKeyIn: async (keys) => {
|
||||
for (const key of keys) {
|
||||
delete store[key];
|
||||
}
|
||||
return keys.length;
|
||||
},
|
||||
acquireLock: () => {
|
||||
return Promise.resolve({
|
||||
release: () => {}
|
||||
|
@@ -26,6 +26,7 @@ export const mockQueue = (): TQueueServiceFactory => {
|
||||
getRepeatableJobs: async () => [],
|
||||
clearQueue: async () => {},
|
||||
stopJobById: async () => {},
|
||||
stopJobByIdPg: async () => {},
|
||||
stopRepeatableJobByJobId: async () => true,
|
||||
stopRepeatableJobByKey: async () => true
|
||||
};
|
||||
|
4
backend/src/@types/fastify.d.ts
vendored
4
backend/src/@types/fastify.d.ts
vendored
@@ -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 { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types";
|
||||
import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service";
|
||||
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
|
||||
import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
|
||||
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types";
|
||||
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 { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";
|
||||
|
@@ -89,7 +89,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
projectSlug: z.string().trim(),
|
||||
authorProjectMembershipId: z.string().trim().optional(),
|
||||
authorUserId: z.string().trim().optional(),
|
||||
envSlug: z.string().trim().optional()
|
||||
}),
|
||||
response: {
|
||||
@@ -143,7 +143,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
||||
handler: async (req) => {
|
||||
const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({
|
||||
projectSlug: req.query.projectSlug,
|
||||
authorProjectMembershipId: req.query.authorProjectMembershipId,
|
||||
authorUserId: req.query.authorUserId,
|
||||
envSlug: req.query.envSlug,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
|
@@ -30,6 +30,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim().optional(),
|
||||
committer: z.string().trim().optional(),
|
||||
search: z.string().trim().optional(),
|
||||
status: z.nativeEnum(RequestState).optional(),
|
||||
limit: z.coerce.number().default(20),
|
||||
offset: z.coerce.number().default(0)
|
||||
@@ -66,13 +67,14 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
userId: z.string().nullable().optional()
|
||||
})
|
||||
.array()
|
||||
}).array()
|
||||
}).array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const approvals = await server.services.secretApprovalRequest.getSecretApprovals({
|
||||
const { approvals, totalCount } = await server.services.secretApprovalRequest.getSecretApprovals({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
@@ -80,7 +82,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
...req.query,
|
||||
projectId: req.query.workspaceId
|
||||
});
|
||||
return { approvals };
|
||||
return { approvals, totalCount };
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -80,6 +80,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SignSshKey,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
certificateTemplateId: req.body.certificateTemplateId,
|
||||
principals: req.body.principals,
|
||||
@@ -171,6 +172,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueSshCreds,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
certificateTemplateId: req.body.certificateTemplateId,
|
||||
principals: req.body.principals,
|
||||
|
@@ -358,6 +358,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueSshHostUserCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
sshHostId: req.params.sshHostId,
|
||||
hostname: host.hostname,
|
||||
@@ -427,6 +428,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueSshHostHostCert,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
sshHostId: req.params.sshHostId,
|
||||
|
@@ -725,16 +725,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
)
|
||||
|
||||
.where(`${TableName.Environment}.projectId`, projectId)
|
||||
.where(`${TableName.AccessApprovalPolicy}.deletedAt`, null)
|
||||
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
||||
.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({
|
||||
data: accessRequests,
|
||||
key: "id",
|
||||
parentMapper: (doc) => ({
|
||||
...AccessApprovalRequestsSchema.parse(doc)
|
||||
...AccessApprovalRequestsSchema.parse(doc),
|
||||
isPolicyDeleted: Boolean(doc.policyDeletedAt)
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
@@ -751,7 +752,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
(req) =>
|
||||
!req.privilegeId &&
|
||||
!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.
|
||||
@@ -759,7 +761,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
||||
(req) =>
|
||||
req.privilegeId ||
|
||||
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 };
|
||||
|
@@ -275,7 +275,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
|
||||
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
|
||||
projectSlug,
|
||||
authorProjectMembershipId,
|
||||
authorUserId,
|
||||
envSlug,
|
||||
actor,
|
||||
actorOrgId,
|
||||
@@ -300,8 +300,8 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
const policies = await accessApprovalPolicyDAL.find({ projectId: project.id });
|
||||
let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id));
|
||||
|
||||
if (authorProjectMembershipId) {
|
||||
requests = requests.filter((request) => request.requestedByUserId === actorId);
|
||||
if (authorUserId) {
|
||||
requests = requests.filter((request) => request.requestedByUserId === authorUserId);
|
||||
}
|
||||
|
||||
if (envSlug) {
|
||||
|
@@ -31,7 +31,7 @@ export type TCreateAccessApprovalRequestDTO = {
|
||||
|
||||
export type TListApprovalRequestsDTO = {
|
||||
projectSlug: string;
|
||||
authorProjectMembershipId?: string;
|
||||
authorUserId?: string;
|
||||
envSlug?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
|
@@ -3,9 +3,43 @@ import { Knex } from "knex";
|
||||
import { TDbClient } from "@app/db";
|
||||
import { DynamicSecretLeasesSchema, TableName } from "@app/db/schemas";
|
||||
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) => {
|
||||
const orm = ormify(db, TableName.DynamicSecretLease);
|
||||
|
@@ -21,7 +21,12 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = {
|
||||
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 = ({
|
||||
queueService,
|
||||
@@ -30,55 +35,48 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
dynamicSecretLeaseDAL,
|
||||
kmsService,
|
||||
folderDAL
|
||||
}: TDynamicSecretLeaseQueueServiceFactoryDep) => {
|
||||
}: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => {
|
||||
const pruneDynamicSecret = async (dynamicSecretCfgId: string) => {
|
||||
await queueService.queue(
|
||||
QueueName.DynamicSecretRevocation,
|
||||
await queueService.queuePg<QueueName.DynamicSecretRevocation>(
|
||||
QueueJobs.DynamicSecretPruning,
|
||||
{ dynamicSecretCfgId },
|
||||
{
|
||||
jobId: dynamicSecretCfgId,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnFail: {
|
||||
count: 3
|
||||
},
|
||||
removeOnComplete: true
|
||||
singletonKey: dynamicSecretCfgId,
|
||||
retryLimit: 3,
|
||||
retryBackoff: true
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setLeaseRevocation = async (leaseId: string, expiry: number) => {
|
||||
await queueService.queue(
|
||||
QueueName.DynamicSecretRevocation,
|
||||
const setLeaseRevocation = async (leaseId: string, expiryAt: Date) => {
|
||||
await queueService.queuePg<QueueName.DynamicSecretRevocation>(
|
||||
QueueJobs.DynamicSecretRevocation,
|
||||
{ leaseId },
|
||||
{
|
||||
jobId: leaseId,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
delay: expiry,
|
||||
removeOnFail: {
|
||||
count: 3
|
||||
},
|
||||
removeOnComplete: true
|
||||
id: leaseId,
|
||||
singletonKey: leaseId,
|
||||
startAfter: expiryAt,
|
||||
retryLimit: 3,
|
||||
retryBackoff: true,
|
||||
retentionDays: 2
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unsetLeaseRevocation = async (leaseId: string) => {
|
||||
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 {
|
||||
if (job.name === QueueJobs.DynamicSecretRevocation) {
|
||||
const { leaseId } = job.data as { leaseId: string };
|
||||
logger.info("Dynamic secret lease revocation started: ", leaseId, job.id);
|
||||
if (jobName === QueueJobs.DynamicSecretRevocation) {
|
||||
const { leaseId } = data as { leaseId: string };
|
||||
logger.info("Dynamic secret lease revocation started: ", leaseId, jobId);
|
||||
|
||||
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId);
|
||||
if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" });
|
||||
@@ -107,9 +105,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name === QueueJobs.DynamicSecretPruning) {
|
||||
const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string };
|
||||
logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, job.id);
|
||||
if (jobName === QueueJobs.DynamicSecretPruning) {
|
||||
const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string };
|
||||
logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, jobId);
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId);
|
||||
if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" });
|
||||
if ((dynamicSecretCfg.status as DynamicSecretStatus) !== DynamicSecretStatus.Deleting)
|
||||
@@ -150,38 +148,68 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
|
||||
await dynamicSecretDAL.deleteById(dynamicSecretCfgId);
|
||||
}
|
||||
logger.info("Finished dynamic secret job", job.id);
|
||||
logger.info("Finished dynamic secret job", jobId);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
||||
if (job?.name === QueueJobs.DynamicSecretPruning) {
|
||||
const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string };
|
||||
if (jobName === QueueJobs.DynamicSecretPruning) {
|
||||
const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string };
|
||||
await dynamicSecretDAL.updateById(dynamicSecretCfgId, {
|
||||
status: DynamicSecretStatus.FailedDeletion,
|
||||
statusDetails: (error as Error)?.message?.slice(0, 255)
|
||||
});
|
||||
}
|
||||
|
||||
if (job?.name === QueueJobs.DynamicSecretRevocation) {
|
||||
const { leaseId } = job.data as { leaseId: string };
|
||||
if (jobName === QueueJobs.DynamicSecretRevocation) {
|
||||
const { leaseId } = data as { leaseId: string };
|
||||
await dynamicSecretLeaseDAL.updateById(leaseId, {
|
||||
status: DynamicSecretStatus.FailedDeletion,
|
||||
statusDetails: (error as Error)?.message?.slice(0, 255)
|
||||
});
|
||||
}
|
||||
if (error instanceof DisableRotationErrors) {
|
||||
if (job.id) {
|
||||
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id);
|
||||
if (jobId) {
|
||||
await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId);
|
||||
await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId);
|
||||
}
|
||||
}
|
||||
// propogate to next part
|
||||
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 {
|
||||
pruneDynamicSecret,
|
||||
setLeaseRevocation,
|
||||
unsetLeaseRevocation
|
||||
unsetLeaseRevocation,
|
||||
init
|
||||
};
|
||||
};
|
||||
|
@@ -26,12 +26,8 @@ import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
|
||||
import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue";
|
||||
import {
|
||||
DynamicSecretLeaseStatus,
|
||||
TCreateDynamicSecretLeaseDTO,
|
||||
TDeleteDynamicSecretLeaseDTO,
|
||||
TDetailsDynamicSecretLeaseDTO,
|
||||
TDynamicSecretLeaseConfig,
|
||||
TListDynamicSecretLeasesDTO,
|
||||
TRenewDynamicSecretLeaseDTO
|
||||
TDynamicSecretLeaseServiceFactory
|
||||
} from "./dynamic-secret-lease-types";
|
||||
|
||||
type TDynamicSecretLeaseServiceFactoryDep = {
|
||||
@@ -48,8 +44,6 @@ type TDynamicSecretLeaseServiceFactoryDep = {
|
||||
identityDAL: TIdentityDALFactory;
|
||||
};
|
||||
|
||||
export type TDynamicSecretLeaseServiceFactory = ReturnType<typeof dynamicSecretLeaseServiceFactory>;
|
||||
|
||||
export const dynamicSecretLeaseServiceFactory = ({
|
||||
dynamicSecretLeaseDAL,
|
||||
dynamicSecretProviders,
|
||||
@@ -62,14 +56,14 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
kmsService,
|
||||
userDAL,
|
||||
identityDAL
|
||||
}: TDynamicSecretLeaseServiceFactoryDep) => {
|
||||
}: TDynamicSecretLeaseServiceFactoryDep): TDynamicSecretLeaseServiceFactory => {
|
||||
const extractEmailUsername = (email: string) => {
|
||||
const regex = new RE2(/^([^@]+)/);
|
||||
const match = email.match(regex);
|
||||
return match ? match[1] : email;
|
||||
};
|
||||
|
||||
const create = async ({
|
||||
const create: TDynamicSecretLeaseServiceFactory["create"] = async ({
|
||||
environmentSlug,
|
||||
path,
|
||||
name,
|
||||
@@ -80,7 +74,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
ttl,
|
||||
config
|
||||
}: TCreateDynamicSecretLeaseDTO) => {
|
||||
}) => {
|
||||
const appCfg = getConfig();
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
@@ -184,11 +178,11 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
config
|
||||
});
|
||||
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt);
|
||||
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
|
||||
};
|
||||
|
||||
const renewLease = async ({
|
||||
const renewLease: TDynamicSecretLeaseServiceFactory["renewLease"] = async ({
|
||||
ttl,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
@@ -198,7 +192,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
path,
|
||||
environmentSlug,
|
||||
leaseId
|
||||
}: TRenewDynamicSecretLeaseDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
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.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt);
|
||||
const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, {
|
||||
expireAt,
|
||||
externalEntityId: entityId
|
||||
@@ -286,7 +280,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
return updatedDynamicSecretLease;
|
||||
};
|
||||
|
||||
const revokeLease = async ({
|
||||
const revokeLease: TDynamicSecretLeaseServiceFactory["revokeLease"] = async ({
|
||||
leaseId,
|
||||
environmentSlug,
|
||||
path,
|
||||
@@ -296,7 +290,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
isForced
|
||||
}: TDeleteDynamicSecretLeaseDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
@@ -376,7 +370,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
return deletedDynamicSecretLease;
|
||||
};
|
||||
|
||||
const listLeases = async ({
|
||||
const listLeases: TDynamicSecretLeaseServiceFactory["listLeases"] = async ({
|
||||
path,
|
||||
name,
|
||||
actor,
|
||||
@@ -385,7 +379,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
actorOrgId,
|
||||
environmentSlug,
|
||||
actorAuthMethod
|
||||
}: TListDynamicSecretLeasesDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
@@ -424,7 +418,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
return dynamicSecretLeases;
|
||||
};
|
||||
|
||||
const getLeaseDetails = async ({
|
||||
const getLeaseDetails: TDynamicSecretLeaseServiceFactory["getLeaseDetails"] = async ({
|
||||
projectSlug,
|
||||
actorOrgId,
|
||||
path,
|
||||
@@ -433,7 +427,7 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
actorId,
|
||||
leaseId,
|
||||
actorAuthMethod
|
||||
}: TDetailsDynamicSecretLeaseDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
|
@@ -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 {
|
||||
FailedDeletion = "Failed to delete"
|
||||
@@ -48,3 +49,40 @@ export type 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;
|
||||
}>;
|
||||
};
|
||||
|
@@ -10,17 +10,35 @@ import {
|
||||
selectAllTableCols,
|
||||
sqlNestRelationships,
|
||||
TFindFilter,
|
||||
TFindOpt
|
||||
TFindOpt,
|
||||
TOrmify
|
||||
} 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";
|
||||
|
||||
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 findOne = async (filter: TFindFilter<TDynamicSecrets>, tx?: Knex) => {
|
||||
const findOne: TDynamicSecretDALFactory["findOne"] = async (filter, tx) => {
|
||||
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
|
||||
.leftJoin(
|
||||
TableName.ResourceMetadata,
|
||||
@@ -55,9 +73,9 @@ export const dynamicSecretDALFactory = (db: TDbClient) => {
|
||||
return docs[0];
|
||||
};
|
||||
|
||||
const findWithMetadata = async (
|
||||
filter: TFindFilter<TDynamicSecrets>,
|
||||
{ offset, limit, sort, tx }: TFindOpt<TDynamicSecrets> = {}
|
||||
const findWithMetadata: TDynamicSecretDALFactory["findWithMetadata"] = async (
|
||||
filter,
|
||||
{ offset, limit, sort, tx } = {}
|
||||
) => {
|
||||
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
|
||||
.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)
|
||||
const listDynamicSecretsByFolderIds = async (
|
||||
{
|
||||
folderIds,
|
||||
search,
|
||||
limit,
|
||||
offset = 0,
|
||||
orderBy = SecretsOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC
|
||||
}: {
|
||||
folderIds: string[];
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
},
|
||||
tx?: Knex
|
||||
const listDynamicSecretsByFolderIds: TDynamicSecretDALFactory["listDynamicSecretsByFolderIds"] = async (
|
||||
{ folderIds, search, limit, offset = 0, orderBy = SecretsOrderBy.Name, orderDirection = OrderByDirection.ASC },
|
||||
tx
|
||||
) => {
|
||||
try {
|
||||
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
|
||||
|
@@ -8,7 +8,7 @@ import {
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
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 { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
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 { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TDynamicSecretDALFactory } from "./dynamic-secret-dal";
|
||||
import {
|
||||
DynamicSecretStatus,
|
||||
TCreateDynamicSecretDTO,
|
||||
TDeleteDynamicSecretDTO,
|
||||
TDetailsDynamicSecretDTO,
|
||||
TGetDynamicSecretsCountDTO,
|
||||
TListDynamicSecretsByFolderMappingsDTO,
|
||||
TListDynamicSecretsDTO,
|
||||
TListDynamicSecretsMultiEnvDTO,
|
||||
TUpdateDynamicSecretDTO
|
||||
} from "./dynamic-secret-types";
|
||||
import { DynamicSecretStatus, TDynamicSecretServiceFactory } from "./dynamic-secret-types";
|
||||
import { AzureEntraIDProvider } from "./providers/azure-entra-id";
|
||||
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
|
||||
|
||||
@@ -51,8 +41,6 @@ type TDynamicSecretServiceFactoryDep = {
|
||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||
};
|
||||
|
||||
export type TDynamicSecretServiceFactory = ReturnType<typeof dynamicSecretServiceFactory>;
|
||||
|
||||
export const dynamicSecretServiceFactory = ({
|
||||
dynamicSecretDAL,
|
||||
dynamicSecretLeaseDAL,
|
||||
@@ -65,8 +53,8 @@ export const dynamicSecretServiceFactory = ({
|
||||
kmsService,
|
||||
gatewayDAL,
|
||||
resourceMetadataDAL
|
||||
}: TDynamicSecretServiceFactoryDep) => {
|
||||
const create = async ({
|
||||
}: TDynamicSecretServiceFactoryDep): TDynamicSecretServiceFactory => {
|
||||
const create: TDynamicSecretServiceFactory["create"] = async ({
|
||||
path,
|
||||
actor,
|
||||
name,
|
||||
@@ -80,7 +68,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
metadata,
|
||||
usernameTemplate
|
||||
}: TCreateDynamicSecretDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
@@ -188,7 +176,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
return dynamicSecretCfg;
|
||||
};
|
||||
|
||||
const updateByName = async ({
|
||||
const updateByName: TDynamicSecretServiceFactory["updateByName"] = async ({
|
||||
name,
|
||||
maxTTL,
|
||||
defaultTTL,
|
||||
@@ -203,7 +191,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
metadata,
|
||||
usernameTemplate
|
||||
}: TUpdateDynamicSecretDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
@@ -345,7 +333,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
return updatedDynamicCfg;
|
||||
};
|
||||
|
||||
const deleteByName = async ({
|
||||
const deleteByName: TDynamicSecretServiceFactory["deleteByName"] = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
@@ -355,7 +343,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
path,
|
||||
environmentSlug,
|
||||
isForced
|
||||
}: TDeleteDynamicSecretDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
@@ -413,7 +401,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
return deletedDynamicSecretCfg;
|
||||
};
|
||||
|
||||
const getDetails = async ({
|
||||
const getDetails: TDynamicSecretServiceFactory["getDetails"] = async ({
|
||||
name,
|
||||
projectSlug,
|
||||
path,
|
||||
@@ -422,7 +410,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
actorOrgId,
|
||||
actorId,
|
||||
actor
|
||||
}: TDetailsDynamicSecretDTO) => {
|
||||
}) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
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
|
||||
const getCountMultiEnv = async ({
|
||||
const getCountMultiEnv: TDynamicSecretServiceFactory["getCountMultiEnv"] = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
@@ -490,7 +478,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
environmentSlugs,
|
||||
search,
|
||||
isInternal
|
||||
}: TListDynamicSecretsMultiEnvDTO) => {
|
||||
}) => {
|
||||
if (!isInternal) {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
@@ -526,7 +514,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
};
|
||||
|
||||
// get dynamic secret count for a single env
|
||||
const getDynamicSecretCount = async ({
|
||||
const getDynamicSecretCount: TDynamicSecretServiceFactory["getDynamicSecretCount"] = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
@@ -535,7 +523,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
environmentSlug,
|
||||
search,
|
||||
projectId
|
||||
}: TGetDynamicSecretsCountDTO) => {
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@@ -561,7 +549,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
return Number(dynamicSecretCfg[0]?.count ?? 0);
|
||||
};
|
||||
|
||||
const listDynamicSecretsByEnv = async ({
|
||||
const listDynamicSecretsByEnv: TDynamicSecretServiceFactory["listDynamicSecretsByEnv"] = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
@@ -575,7 +563,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
search,
|
||||
...params
|
||||
}: TListDynamicSecretsDTO) => {
|
||||
}) => {
|
||||
let { projectId } = params;
|
||||
|
||||
if (!projectId) {
|
||||
@@ -619,9 +607,9 @@ export const dynamicSecretServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const listDynamicSecretsByFolderIds = async (
|
||||
{ folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO,
|
||||
actor: OrgServiceActor
|
||||
const listDynamicSecretsByFolderIds: TDynamicSecretServiceFactory["listDynamicSecretsByFolderIds"] = async (
|
||||
{ folderMappings, filters, projectId },
|
||||
actor
|
||||
) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
@@ -657,7 +645,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
};
|
||||
|
||||
// get dynamic secrets for multiple envs
|
||||
const listDynamicSecretsByEnvs = async ({
|
||||
const listDynamicSecretsByEnvs: TDynamicSecretServiceFactory["listDynamicSecretsByEnvs"] = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
@@ -667,7 +655,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
projectId,
|
||||
isInternal,
|
||||
...params
|
||||
}: TListDynamicSecretsMultiEnvDTO) => {
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@@ -700,14 +688,10 @@ export const dynamicSecretServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAzureEntraIdUsers = async ({
|
||||
const fetchAzureEntraIdUsers: TDynamicSecretServiceFactory["fetchAzureEntraIdUsers"] = async ({
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
}: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
}) => {
|
||||
const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers(
|
||||
tenantId,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
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 { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
@@ -83,3 +84,27 @@ export type TListDynamicSecretsMultiEnvDTO = Omit<
|
||||
export type TGetDynamicSecretsCountDTO = Omit<TListDynamicSecretsDTO, "projectSlug" | "projectId"> & {
|
||||
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 }>>;
|
||||
};
|
||||
|
@@ -52,9 +52,8 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: string;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
caCert?: string;
|
||||
httpsAgent?: https.Agent;
|
||||
reviewTokenThroughGateway: boolean;
|
||||
enableSsl: boolean;
|
||||
},
|
||||
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
|
||||
): Promise<T> => {
|
||||
@@ -85,10 +84,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
key: relayDetails.privateKey.toString()
|
||||
},
|
||||
// we always pass this, because its needed for both tcp and http protocol
|
||||
httpsAgent: new https.Agent({
|
||||
ca: inputs.caCert,
|
||||
rejectUnauthorized: inputs.enableSsl
|
||||
})
|
||||
httpsAgent: inputs.httpsAgent
|
||||
}
|
||||
);
|
||||
|
||||
@@ -311,6 +307,14 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const k8sHost = `${url.protocol}//${url.hostname}`;
|
||||
|
||||
try {
|
||||
const httpsAgent =
|
||||
providerInputs.ca && providerInputs.sslEnabled
|
||||
? new https.Agent({
|
||||
ca: providerInputs.ca,
|
||||
rejectUnauthorized: true
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (providerInputs.gatewayId) {
|
||||
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
|
||||
await $gatewayProxyWrapper(
|
||||
@@ -318,8 +322,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sHost,
|
||||
targetPort: k8sPort,
|
||||
enableSsl: providerInputs.sslEnabled,
|
||||
caCert: providerInputs.ca,
|
||||
httpsAgent,
|
||||
reviewTokenThroughGateway: true
|
||||
},
|
||||
providerInputs.credentialType === KubernetesCredentialType.Static
|
||||
@@ -332,8 +335,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sGatewayHost,
|
||||
targetPort: k8sPort,
|
||||
enableSsl: providerInputs.sslEnabled,
|
||||
caCert: providerInputs.ca,
|
||||
httpsAgent,
|
||||
reviewTokenThroughGateway: false
|
||||
},
|
||||
providerInputs.credentialType === KubernetesCredentialType.Static
|
||||
@@ -342,9 +344,9 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
);
|
||||
}
|
||||
} else if (providerInputs.credentialType === KubernetesCredentialType.Static) {
|
||||
await serviceAccountStaticCallback(k8sHost, k8sPort);
|
||||
await serviceAccountStaticCallback(k8sHost, k8sPort, httpsAgent);
|
||||
} else {
|
||||
await serviceAccountDynamicCallback(k8sHost, k8sPort);
|
||||
await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -546,6 +548,15 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
|
||||
try {
|
||||
let tokenData;
|
||||
|
||||
const httpsAgent =
|
||||
providerInputs.ca && providerInputs.sslEnabled
|
||||
? new https.Agent({
|
||||
ca: providerInputs.ca,
|
||||
rejectUnauthorized: true
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (providerInputs.gatewayId) {
|
||||
if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) {
|
||||
tokenData = await $gatewayProxyWrapper(
|
||||
@@ -553,8 +564,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sHost,
|
||||
targetPort: k8sPort,
|
||||
enableSsl: providerInputs.sslEnabled,
|
||||
caCert: providerInputs.ca,
|
||||
httpsAgent,
|
||||
reviewTokenThroughGateway: true
|
||||
},
|
||||
providerInputs.credentialType === KubernetesCredentialType.Static
|
||||
@@ -567,8 +577,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sGatewayHost,
|
||||
targetPort: k8sPort,
|
||||
enableSsl: providerInputs.sslEnabled,
|
||||
caCert: providerInputs.ca,
|
||||
httpsAgent,
|
||||
reviewTokenThroughGateway: false
|
||||
},
|
||||
providerInputs.credentialType === KubernetesCredentialType.Static
|
||||
@@ -579,8 +588,8 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
} else {
|
||||
tokenData =
|
||||
providerInputs.credentialType === KubernetesCredentialType.Static
|
||||
? await tokenRequestStaticCallback(k8sHost, k8sPort)
|
||||
: await serviceAccountDynamicCallback(k8sHost, k8sPort);
|
||||
? await tokenRequestStaticCallback(k8sHost, k8sPort, httpsAgent)
|
||||
: await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -684,6 +693,14 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const k8sPort = url.port ? Number(url.port) : 443;
|
||||
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.authMethod === KubernetesAuthMethod.Gateway) {
|
||||
await $gatewayProxyWrapper(
|
||||
@@ -691,8 +708,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sHost,
|
||||
targetPort: k8sPort,
|
||||
enableSsl: providerInputs.sslEnabled,
|
||||
caCert: providerInputs.ca,
|
||||
httpsAgent,
|
||||
reviewTokenThroughGateway: true
|
||||
},
|
||||
serviceAccountDynamicCallback
|
||||
@@ -703,15 +719,14 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
gatewayId: providerInputs.gatewayId,
|
||||
targetHost: k8sGatewayHost,
|
||||
targetPort: k8sPort,
|
||||
enableSsl: providerInputs.sslEnabled,
|
||||
caCert: providerInputs.ca,
|
||||
httpsAgent,
|
||||
reviewTokenThroughGateway: false
|
||||
},
|
||||
serviceAccountDynamicCallback
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await serviceAccountDynamicCallback(k8sHost, k8sPort);
|
||||
await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -24,6 +24,7 @@ type TFindQueryFilter = {
|
||||
committer?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
@@ -314,7 +315,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
.where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
|
||||
.orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
|
||||
)
|
||||
.andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null))
|
||||
.select("status", `${TableName.SecretApprovalRequest}.id`)
|
||||
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
|
||||
.count("status")
|
||||
@@ -340,13 +340,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
};
|
||||
|
||||
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
|
||||
) => {
|
||||
try {
|
||||
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
|
||||
// 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.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||
.join(
|
||||
@@ -435,7 +435,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
|
||||
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)
|
||||
.with("w", query)
|
||||
@@ -443,6 +466,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", offset)
|
||||
.andWhere("w.rank", "<", offset + limit);
|
||||
|
||||
// @ts-expect-error knex does not infer
|
||||
const totalCount = Number(docs[0]?.total_count || 0);
|
||||
|
||||
const formattedDoc = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "id",
|
||||
@@ -504,23 +531,26 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
]
|
||||
});
|
||||
return formattedDoc.map((el) => ({
|
||||
...el,
|
||||
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
|
||||
}));
|
||||
return {
|
||||
approvals: formattedDoc.map((el) => ({
|
||||
...el,
|
||||
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
|
||||
})),
|
||||
totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindSAR" });
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
) => {
|
||||
try {
|
||||
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
|
||||
// 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.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||
.join(
|
||||
@@ -609,14 +639,42 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
|
||||
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)
|
||||
.with("w", query)
|
||||
.select("*")
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", offset)
|
||||
.andWhere("w.rank", "<", offset + limit);
|
||||
.where("w.rank", ">=", rankOffset)
|
||||
.andWhere("w.rank", "<", rankOffset + limit);
|
||||
|
||||
// @ts-expect-error knex does not infer
|
||||
const totalCount = Number(docs[0]?.total_count || 0);
|
||||
|
||||
const formattedDoc = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "id",
|
||||
@@ -682,10 +740,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
]
|
||||
});
|
||||
return formattedDoc.map((el) => ({
|
||||
...el,
|
||||
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
|
||||
}));
|
||||
return {
|
||||
approvals: formattedDoc.map((el) => ({
|
||||
...el,
|
||||
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
|
||||
})),
|
||||
totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindSAR" });
|
||||
}
|
||||
|
@@ -194,7 +194,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
environment,
|
||||
committer,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
search
|
||||
}: TListApprovalsDTO) => {
|
||||
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);
|
||||
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({
|
||||
projectId,
|
||||
@@ -216,19 +218,21 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
status,
|
||||
userId: actorId,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
search
|
||||
});
|
||||
}
|
||||
const approvals = await secretApprovalRequestDAL.findByProjectId({
|
||||
|
||||
return secretApprovalRequestDAL.findByProjectId({
|
||||
projectId,
|
||||
committer,
|
||||
environment,
|
||||
status,
|
||||
userId: actorId,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
search
|
||||
});
|
||||
return approvals;
|
||||
};
|
||||
|
||||
const getSecretApprovalDetails = async ({
|
||||
|
@@ -93,6 +93,7 @@ export type TListApprovalsDTO = {
|
||||
committer?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TSecretApprovalDetailsDTO = {
|
||||
|
@@ -72,6 +72,7 @@ type TWaitTillReady = {
|
||||
export type TKeyStoreFactory = {
|
||||
setItem: (key: string, value: string | number | Buffer, prefix?: string) => Promise<"OK">;
|
||||
getItem: (key: string, prefix?: string) => Promise<string | null>;
|
||||
getItems: (keys: string[], prefix?: string) => Promise<(string | null)[]>;
|
||||
setExpiry: (key: string, expiryInSeconds: number) => Promise<number>;
|
||||
setItemWithExpiry: (
|
||||
key: string,
|
||||
@@ -80,6 +81,7 @@ export type TKeyStoreFactory = {
|
||||
prefix?: string
|
||||
) => Promise<"OK">;
|
||||
deleteItem: (key: string) => Promise<number>;
|
||||
deleteItemsByKeyIn: (keys: string[]) => Promise<number>;
|
||||
deleteItems: (arg: TDeleteItems) => Promise<number>;
|
||||
incrementBy: (key: string, value: number) => Promise<number>;
|
||||
acquireLock(
|
||||
@@ -88,6 +90,7 @@ export type TKeyStoreFactory = {
|
||||
settings?: Partial<Settings>
|
||||
): Promise<{ release: () => Promise<ExecutionResult> }>;
|
||||
waitTillReady: ({ key, waitingCb, keyCheckCb, waitIteration, delay, jitter }: TWaitTillReady) => Promise<void>;
|
||||
getKeysByPattern: (pattern: string, limit?: number) => Promise<string[]>;
|
||||
};
|
||||
|
||||
export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => {
|
||||
@@ -99,6 +102,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
|
||||
|
||||
const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key);
|
||||
|
||||
const getItems = async (keys: string[], prefix?: string) =>
|
||||
redis.mget(keys.map((key) => (prefix ? `${prefix}:${key}` : key)));
|
||||
|
||||
const setItemWithExpiry = async (
|
||||
key: string,
|
||||
expiryInSeconds: number | string,
|
||||
@@ -108,6 +114,11 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
|
||||
|
||||
const deleteItem = async (key: string) => redis.del(key);
|
||||
|
||||
const deleteItemsByKeyIn = async (keys: string[]) => {
|
||||
if (keys.length === 0) return 0;
|
||||
return redis.del(keys);
|
||||
};
|
||||
|
||||
const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => {
|
||||
let cursor = "0";
|
||||
let totalDeleted = 0;
|
||||
@@ -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 {
|
||||
setItem,
|
||||
getItem,
|
||||
@@ -174,6 +203,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
|
||||
acquireLock(resources: string[], duration: number, settings?: Partial<Settings>) {
|
||||
return redisLock.acquire(resources, duration, settings);
|
||||
},
|
||||
waitTillReady
|
||||
waitTillReady,
|
||||
getKeysByPattern,
|
||||
deleteItemsByKeyIn,
|
||||
getItems
|
||||
};
|
||||
};
|
||||
|
@@ -8,6 +8,8 @@ import { TKeyStoreFactory } from "./keystore";
|
||||
|
||||
export const inMemoryKeyStore = (): TKeyStoreFactory => {
|
||||
const store: Record<string, string | number | Buffer> = {};
|
||||
const getRegex = (pattern: string) =>
|
||||
new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
||||
|
||||
return {
|
||||
setItem: async (key, value) => {
|
||||
@@ -24,7 +26,7 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => {
|
||||
return 1;
|
||||
},
|
||||
deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => {
|
||||
const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
||||
const regex = getRegex(pattern);
|
||||
let totalDeleted = 0;
|
||||
const keys = Object.keys(store);
|
||||
|
||||
@@ -59,6 +61,27 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => {
|
||||
release: () => {}
|
||||
}) as Promise<Lock>;
|
||||
},
|
||||
waitTillReady: async () => {}
|
||||
waitTillReady: async () => {},
|
||||
getKeysByPattern: async (pattern) => {
|
||||
const regex = getRegex(pattern);
|
||||
const keys = Object.keys(store);
|
||||
return keys.filter((key) => regex.test(key));
|
||||
},
|
||||
deleteItemsByKeyIn: async (keys) => {
|
||||
for (const key of keys) {
|
||||
delete store[key];
|
||||
}
|
||||
return keys.length;
|
||||
},
|
||||
getItems: async (keys) => {
|
||||
const values = keys.map((key) => {
|
||||
const value = store[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@@ -19,3 +19,5 @@ export const getMinExpiresIn = (exp1: string | number, exp2: string | number): s
|
||||
|
||||
return ms1 <= ms2 ? exp1 : exp2;
|
||||
};
|
||||
|
||||
export const convertMsToSecond = (time: number) => time / 1000;
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { TDynamicSecrets } from "@app/db/schemas";
|
||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||
|
||||
export type TGenericPermission = {
|
||||
@@ -84,3 +85,7 @@ export enum QueueWorkerProfile {
|
||||
Standard = "standard",
|
||||
SecretScanning = "secret-scanning"
|
||||
}
|
||||
|
||||
export interface TDynamicSecretWithMetadata extends TDynamicSecrets {
|
||||
metadata: { id: string; key: string; value: string }[];
|
||||
}
|
||||
|
@@ -62,7 +62,8 @@ export enum QueueName {
|
||||
SecretRotationV2 = "secret-rotation-v2",
|
||||
FolderTreeCheckpoint = "folder-tree-checkpoint",
|
||||
InvalidateCache = "invalidate-cache",
|
||||
SecretScanningV2 = "secret-scanning-v2"
|
||||
SecretScanningV2 = "secret-scanning-v2",
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events"
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -101,7 +102,8 @@ export enum QueueJobs {
|
||||
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
|
||||
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
|
||||
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
|
||||
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal"
|
||||
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events"
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -292,6 +294,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.PkiSubscriberDailyAutoRenewal;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.TelemetryAggregatedEvents]: {
|
||||
name: QueueJobs.TelemetryAggregatedEvents;
|
||||
payload: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
const SECRET_SCANNING_JOBS = [
|
||||
@@ -377,6 +383,7 @@ export type TQueueServiceFactory = {
|
||||
stopRepeatableJobByKey: <T extends QueueName>(name: T, repeatJobKey: string) => Promise<boolean>;
|
||||
clearQueue: (name: QueueName) => Promise<void>;
|
||||
stopJobById: <T extends QueueName>(name: T, jobId: string) => Promise<void | undefined>;
|
||||
stopJobByIdPg: <T extends QueueName>(name: T, jobId: string) => Promise<void | undefined>;
|
||||
getRepeatableJobs: (
|
||||
name: QueueName,
|
||||
startOffset?: number,
|
||||
@@ -542,6 +549,10 @@ export const queueServiceFactory = (
|
||||
return q.removeRepeatableByKey(repeatJobKey);
|
||||
};
|
||||
|
||||
const stopJobByIdPg: TQueueServiceFactory["stopJobByIdPg"] = async (name, jobId) => {
|
||||
await pgBoss.deleteJob(name, jobId);
|
||||
};
|
||||
|
||||
const stopJobById: TQueueServiceFactory["stopJobById"] = async (name, jobId) => {
|
||||
const q = queueContainer[name];
|
||||
const job = await q.getJob(jobId);
|
||||
@@ -568,6 +579,7 @@ export const queueServiceFactory = (
|
||||
stopRepeatableJobByKey,
|
||||
clearQueue,
|
||||
stopJobById,
|
||||
stopJobByIdPg,
|
||||
getRepeatableJobs,
|
||||
startPg,
|
||||
queuePg,
|
||||
|
@@ -686,7 +686,8 @@ export const registerRoutes = async (
|
||||
const telemetryQueue = telemetryQueueServiceFactory({
|
||||
keyStore,
|
||||
telemetryDAL,
|
||||
queueService
|
||||
queueService,
|
||||
telemetryService
|
||||
});
|
||||
|
||||
const invalidateCacheQueue = invalidateCacheQueueFactory({
|
||||
@@ -1903,6 +1904,7 @@ export const registerRoutes = async (
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await kmsService.startService();
|
||||
await microsoftTeamsService.start();
|
||||
await dynamicSecretQueueService.init();
|
||||
|
||||
// inject all services
|
||||
server.decorate<FastifyZodProvider["services"]>("services", {
|
||||
|
@@ -722,6 +722,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.InvalidateCache,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
...req.auditLogInfo
|
||||
|
@@ -692,6 +692,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
caId: ca.id,
|
||||
@@ -786,6 +787,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SignCert,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
caId: ca.id,
|
||||
|
@@ -266,6 +266,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
caId: req.body.caId,
|
||||
certificateTemplateId: req.body.certificateTemplateId,
|
||||
@@ -442,6 +443,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SignCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
caId: req.body.caId,
|
||||
certificateTemplateId: req.body.certificateTemplateId,
|
||||
|
@@ -475,6 +475,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secretCountFromEnv,
|
||||
workspaceId: projectId,
|
||||
@@ -979,6 +980,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secretCount,
|
||||
workspaceId: projectId,
|
||||
@@ -1144,6 +1146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secretCountForEnv,
|
||||
workspaceId: projectId,
|
||||
@@ -1336,6 +1339,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: projectId,
|
||||
|
@@ -85,6 +85,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.MachineIdentityCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
orgId: req.body.organizationId,
|
||||
name: identity.name,
|
||||
|
@@ -103,6 +103,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IntegrationCreated,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
...createIntegrationEventProperty,
|
||||
|
@@ -64,6 +64,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.UserOrgInvitation,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
inviteeEmails: req.body.inviteeEmails,
|
||||
organizationRoleSlug: req.body.organizationRoleSlug,
|
||||
@@ -83,7 +84,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
||||
config: {
|
||||
rateLimit: smtpRateLimit({
|
||||
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",
|
||||
|
@@ -81,7 +81,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
|
||||
url: "/email/password-reset",
|
||||
config: {
|
||||
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: {
|
||||
@@ -107,7 +107,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => {
|
||||
method: "POST",
|
||||
url: "/email/password-reset-verify",
|
||||
config: {
|
||||
rateLimit: authRateLimit
|
||||
rateLimit: smtpRateLimit({
|
||||
keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) || req.realIp
|
||||
})
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
|
@@ -331,6 +331,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
subscriberId: subscriber.id,
|
||||
@@ -399,6 +400,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
subscriberId: subscriber.id,
|
||||
commonName: subscriber.commonName,
|
||||
@@ -471,6 +473,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SignCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
subscriberId: subscriber.id,
|
||||
commonName: subscriber.commonName,
|
||||
|
@@ -165,6 +165,7 @@ export const registerSecretRequestsRouter = async (server: FastifyZodProvider) =
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretRequestDeleted,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
secretRequestId: req.params.id,
|
||||
organizationId: req.permission.orgId,
|
||||
@@ -256,6 +257,7 @@ export const registerSecretRequestsRouter = async (server: FastifyZodProvider) =
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretRequestCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
secretRequestId: shareRequest.id,
|
||||
organizationId: req.permission.orgId,
|
||||
|
@@ -198,6 +198,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.ProjectCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
orgId: project.orgId,
|
||||
name: project.name,
|
||||
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
||||
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 { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
|
||||
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
|
||||
@@ -13,7 +13,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
url: "/me/emails/code",
|
||||
config: {
|
||||
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: {
|
||||
@@ -34,7 +34,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
method: "POST",
|
||||
url: "/me/emails/verify",
|
||||
config: {
|
||||
rateLimit: authRateLimit
|
||||
rateLimit: smtpRateLimit({
|
||||
keyGenerator: (req) => (req.body as { username?: string })?.username?.trim().substring(0, 100) || req.realIp
|
||||
})
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
|
@@ -339,6 +339,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId,
|
||||
@@ -484,6 +485,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
organizationId: req.permission.orgId,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
@@ -600,6 +602,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -725,6 +728,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretUpdated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -815,6 +819,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretDeleted,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -922,6 +927,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: req.query.workspaceId,
|
||||
@@ -1001,6 +1007,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.query.workspaceId,
|
||||
@@ -1172,6 +1179,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -1361,6 +1369,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretUpdated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -1484,6 +1493,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretDeleted,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -1667,6 +1677,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -1793,6 +1804,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretUpdated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -1911,6 +1923,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretDeleted,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: req.body.workspaceId,
|
||||
@@ -2019,6 +2032,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: secrets[0].workspace,
|
||||
@@ -2174,6 +2188,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretUpdated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: secrets[0].workspace,
|
||||
@@ -2272,6 +2287,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretDeleted,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
organizationId: req.permission.orgId,
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: secrets[0].workspace,
|
||||
|
@@ -14,7 +14,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
||||
method: "POST",
|
||||
config: {
|
||||
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: {
|
||||
@@ -55,7 +55,9 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
||||
url: "/email/verify",
|
||||
method: "POST",
|
||||
config: {
|
||||
rateLimit: authRateLimit
|
||||
rateLimit: smtpRateLimit({
|
||||
keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) || req.realIp
|
||||
})
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
|
@@ -7,13 +7,18 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { getServerCfg } from "../super-admin/super-admin-service";
|
||||
import { TTelemetryDALFactory } from "./telemetry-dal";
|
||||
import { TELEMETRY_SECRET_OPERATIONS_KEY, TELEMETRY_SECRET_PROCESSED_KEY } from "./telemetry-service";
|
||||
import {
|
||||
TELEMETRY_SECRET_OPERATIONS_KEY,
|
||||
TELEMETRY_SECRET_PROCESSED_KEY,
|
||||
TTelemetryServiceFactory
|
||||
} from "./telemetry-service";
|
||||
import { PostHogEventTypes } from "./telemetry-types";
|
||||
|
||||
type TTelemetryQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem" | "deleteItem">;
|
||||
telemetryDAL: TTelemetryDALFactory;
|
||||
telemetryService: TTelemetryServiceFactory;
|
||||
};
|
||||
|
||||
export type TTelemetryQueueServiceFactory = ReturnType<typeof telemetryQueueServiceFactory>;
|
||||
@@ -21,7 +26,8 @@ export type TTelemetryQueueServiceFactory = ReturnType<typeof telemetryQueueServ
|
||||
export const telemetryQueueServiceFactory = ({
|
||||
queueService,
|
||||
keyStore,
|
||||
telemetryDAL
|
||||
telemetryDAL,
|
||||
telemetryService
|
||||
}: TTelemetryQueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
const postHog =
|
||||
@@ -48,6 +54,10 @@ export const telemetryQueueServiceFactory = ({
|
||||
await keyStore.deleteItem(TELEMETRY_SECRET_OPERATIONS_KEY);
|
||||
});
|
||||
|
||||
queueService.start(QueueName.TelemetryAggregatedEvents, async () => {
|
||||
await telemetryService.processAggregatedEvents();
|
||||
});
|
||||
|
||||
// every day at midnight a telemetry job executes on self-hosted instances
|
||||
// this sends some telemetry information like instance id secrets operated etc
|
||||
const startTelemetryCheck = async () => {
|
||||
@@ -60,11 +70,26 @@ export const telemetryQueueServiceFactory = ({
|
||||
{ pattern: "0 0 * * *", utc: true },
|
||||
QueueName.TelemetryInstanceStats // just a job id
|
||||
);
|
||||
|
||||
// clear previous aggregated events job
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.TelemetryAggregatedEvents,
|
||||
QueueJobs.TelemetryAggregatedEvents,
|
||||
{ pattern: "*/5 * * * *", utc: true },
|
||||
QueueName.TelemetryAggregatedEvents // just a job id
|
||||
);
|
||||
|
||||
if (postHog) {
|
||||
await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, {
|
||||
jobId: QueueName.TelemetryInstanceStats,
|
||||
repeat: { pattern: "0 0 * * *", utc: true }
|
||||
});
|
||||
|
||||
// Start aggregated events job (runs every five minutes)
|
||||
await queueService.queue(QueueName.TelemetryAggregatedEvents, QueueJobs.TelemetryAggregatedEvents, undefined, {
|
||||
jobId: QueueName.TelemetryAggregatedEvents,
|
||||
repeat: { pattern: "*/5 * * * *", utc: true }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,6 +97,10 @@ export const telemetryQueueServiceFactory = ({
|
||||
logger.error(err?.failedReason, `${QueueName.TelemetryInstanceStats}: failed`);
|
||||
});
|
||||
|
||||
queueService.listen(QueueName.TelemetryAggregatedEvents, "failed", (err) => {
|
||||
logger.error(err?.failedReason, `${QueueName.TelemetryAggregatedEvents}: failed`);
|
||||
});
|
||||
|
||||
return {
|
||||
startTelemetryCheck
|
||||
};
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
@@ -12,12 +13,49 @@ import { PostHogEventTypes, TPostHogEvent, TSecretModifiedEvent } from "./teleme
|
||||
export const TELEMETRY_SECRET_PROCESSED_KEY = "telemetry-secret-processed";
|
||||
export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations";
|
||||
|
||||
export const POSTHOG_AGGREGATED_EVENTS = [PostHogEventTypes.SecretPulled];
|
||||
const TELEMETRY_AGGREGATED_KEY_EXP = 900; // 15mins
|
||||
|
||||
// Bucket configuration
|
||||
const TELEMETRY_BUCKET_COUNT = 30;
|
||||
const TELEMETRY_BUCKET_NAMES = Array.from(
|
||||
{ length: TELEMETRY_BUCKET_COUNT },
|
||||
(_, i) => `bucket-${i.toString().padStart(2, "0")}`
|
||||
);
|
||||
|
||||
type AggregatedEventData = Record<string, unknown>;
|
||||
type SingleEventData = {
|
||||
distinctId: string;
|
||||
event: string;
|
||||
properties: unknown;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type TTelemetryServiceFactory = ReturnType<typeof telemetryServiceFactory>;
|
||||
export type TTelemetryServiceFactoryDep = {
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem" | "incrementBy">;
|
||||
keyStore: Pick<
|
||||
TKeyStoreFactory,
|
||||
"incrementBy" | "deleteItemsByKeyIn" | "setItemWithExpiry" | "getKeysByPattern" | "getItems"
|
||||
>;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getInstanceType">;
|
||||
};
|
||||
|
||||
const getBucketForDistinctId = (distinctId: string): string => {
|
||||
// Use SHA-256 hash for consistent distribution
|
||||
const hash = createHash("sha256").update(distinctId).digest("hex");
|
||||
|
||||
// Take first 8 characters and convert to number for better distribution
|
||||
const hashNumber = parseInt(hash.substring(0, 8), 16);
|
||||
const bucketIndex = hashNumber % TELEMETRY_BUCKET_COUNT;
|
||||
|
||||
return TELEMETRY_BUCKET_NAMES[bucketIndex];
|
||||
};
|
||||
|
||||
export const createTelemetryEventKey = (event: string, distinctId: string): string => {
|
||||
const bucketId = getBucketForDistinctId(distinctId);
|
||||
return `telemetry-event-${event}-${bucketId}-${distinctId}-${randomUUID()}`;
|
||||
};
|
||||
|
||||
export const telemetryServiceFactory = ({ keyStore, licenseService }: TTelemetryServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
@@ -64,11 +102,33 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
|
||||
const instanceType = licenseService.getInstanceType();
|
||||
// capture posthog only when its cloud or signup event happens in self-hosted
|
||||
if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) {
|
||||
postHog.capture({
|
||||
event: event.event,
|
||||
distinctId: event.distinctId,
|
||||
properties: event.properties
|
||||
});
|
||||
if (event.organizationId) {
|
||||
try {
|
||||
postHog.groupIdentify({ groupType: "organization", groupKey: event.organizationId });
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to identify PostHog organization");
|
||||
}
|
||||
}
|
||||
if (POSTHOG_AGGREGATED_EVENTS.includes(event.event)) {
|
||||
const eventKey = createTelemetryEventKey(event.event, event.distinctId);
|
||||
await keyStore.setItemWithExpiry(
|
||||
eventKey,
|
||||
TELEMETRY_AGGREGATED_KEY_EXP,
|
||||
JSON.stringify({
|
||||
distinctId: event.distinctId,
|
||||
event: event.event,
|
||||
properties: event.properties,
|
||||
organizationId: event.organizationId
|
||||
})
|
||||
);
|
||||
} else {
|
||||
postHog.capture({
|
||||
event: event.event,
|
||||
distinctId: event.distinctId,
|
||||
properties: event.properties,
|
||||
...(event.organizationId ? { groups: { organization: event.organizationId } } : {})
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,6 +149,160 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
|
||||
}
|
||||
};
|
||||
|
||||
const aggregateGroupProperties = (events: SingleEventData[]): AggregatedEventData => {
|
||||
const aggregatedData: AggregatedEventData = {};
|
||||
|
||||
// Set the total count
|
||||
aggregatedData.count = events.length;
|
||||
|
||||
events.forEach((event) => {
|
||||
if (!event.properties) return;
|
||||
|
||||
Object.entries(event.properties as Record<string, unknown>).forEach(([key, value]: [string, unknown]) => {
|
||||
if (Array.isArray(value)) {
|
||||
// For arrays, count occurrences of each item
|
||||
const existingCounts =
|
||||
aggregatedData[key] &&
|
||||
typeof aggregatedData[key] === "object" &&
|
||||
aggregatedData[key]?.constructor === Object
|
||||
? (aggregatedData[key] as Record<string, number>)
|
||||
: {};
|
||||
|
||||
value.forEach((item) => {
|
||||
const itemKey = typeof item === "object" ? JSON.stringify(item) : String(item);
|
||||
existingCounts[itemKey] = (existingCounts[itemKey] || 0) + 1;
|
||||
});
|
||||
|
||||
aggregatedData[key] = existingCounts;
|
||||
} else if (typeof value === "object" && value?.constructor === Object) {
|
||||
// For objects, count occurrences of each field value
|
||||
const existingCounts =
|
||||
aggregatedData[key] &&
|
||||
typeof aggregatedData[key] === "object" &&
|
||||
aggregatedData[key]?.constructor === Object
|
||||
? (aggregatedData[key] as Record<string, number>)
|
||||
: {};
|
||||
|
||||
if (value) {
|
||||
Object.values(value).forEach((fieldValue) => {
|
||||
const valueKey = typeof fieldValue === "object" ? JSON.stringify(fieldValue) : String(fieldValue);
|
||||
existingCounts[valueKey] = (existingCounts[valueKey] || 0) + 1;
|
||||
});
|
||||
}
|
||||
aggregatedData[key] = existingCounts;
|
||||
} else if (typeof value === "number") {
|
||||
// For numbers, add to existing sum
|
||||
aggregatedData[key] = ((aggregatedData[key] as number) || 0) + value;
|
||||
} else if (value !== undefined && value !== null) {
|
||||
// For other types (strings, booleans, etc.), count occurrences
|
||||
const stringValue = String(value);
|
||||
const existingValue = aggregatedData[key];
|
||||
|
||||
if (!existingValue) {
|
||||
aggregatedData[key] = { [stringValue]: 1 };
|
||||
} else if (existingValue && typeof existingValue === "object" && existingValue.constructor === Object) {
|
||||
const countObject = existingValue as Record<string, number>;
|
||||
countObject[stringValue] = (countObject[stringValue] || 0) + 1;
|
||||
} else {
|
||||
const oldValue = String(existingValue);
|
||||
aggregatedData[key] = {
|
||||
[oldValue]: 1,
|
||||
[stringValue]: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return aggregatedData;
|
||||
};
|
||||
|
||||
const processBucketEvents = async (eventType: string, bucketId: string) => {
|
||||
if (!postHog) return 0;
|
||||
|
||||
try {
|
||||
const bucketPattern = `telemetry-event-${eventType}-${bucketId}-*`;
|
||||
const bucketKeys = await keyStore.getKeysByPattern(bucketPattern);
|
||||
|
||||
if (bucketKeys.length === 0) return 0;
|
||||
|
||||
const bucketEvents = await keyStore.getItems(bucketKeys);
|
||||
let bucketEventsParsed: SingleEventData[] = [];
|
||||
|
||||
try {
|
||||
bucketEventsParsed = bucketEvents
|
||||
.filter((event) => event !== null)
|
||||
.map((event) => JSON.parse(event as string) as SingleEventData);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to parse bucket events for ${eventType} in ${bucketId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const eventsGrouped = new Map<string, SingleEventData[]>();
|
||||
|
||||
bucketEventsParsed.forEach((event) => {
|
||||
const key = JSON.stringify({ id: event.distinctId, org: event.organizationId });
|
||||
if (!eventsGrouped.has(key)) {
|
||||
eventsGrouped.set(key, []);
|
||||
}
|
||||
eventsGrouped.get(key)!.push(event);
|
||||
});
|
||||
|
||||
if (eventsGrouped.size === 0) return 0;
|
||||
|
||||
for (const [eventsKey, events] of eventsGrouped) {
|
||||
const key = JSON.parse(eventsKey) as { id: string; org?: string };
|
||||
if (key.org) {
|
||||
try {
|
||||
postHog.groupIdentify({ groupType: "organization", groupKey: key.org });
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to identify PostHog organization");
|
||||
}
|
||||
}
|
||||
const properties = aggregateGroupProperties(events);
|
||||
|
||||
postHog.capture({
|
||||
event: `${eventType} aggregated`,
|
||||
distinctId: key.id,
|
||||
properties,
|
||||
...(key.org ? { groups: { organization: key.org } } : {})
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up processed data for this bucket
|
||||
await keyStore.deleteItemsByKeyIn(bucketKeys);
|
||||
|
||||
logger.info(`Processed ${bucketEventsParsed.length} events from bucket ${bucketId} for ${eventType}`);
|
||||
return bucketEventsParsed.length;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to process bucket ${bucketId} for ${eventType}`);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const processAggregatedEvents = async () => {
|
||||
if (!postHog) return;
|
||||
|
||||
for (const eventType of POSTHOG_AGGREGATED_EVENTS) {
|
||||
let totalProcessed = 0;
|
||||
|
||||
logger.info(`Starting bucket processing for ${eventType}`);
|
||||
|
||||
// Process each bucket sequentially to control memory usage
|
||||
for (const bucketId of TELEMETRY_BUCKET_NAMES) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const processed = await processBucketEvents(eventType, bucketId);
|
||||
totalProcessed += processed;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to process bucket ${bucketId} for ${eventType}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Completed processing ${totalProcessed} total events for ${eventType}`);
|
||||
}
|
||||
};
|
||||
|
||||
const flushAll = async () => {
|
||||
if (postHog) {
|
||||
await postHog.shutdownAsync();
|
||||
@@ -98,6 +312,8 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
|
||||
return {
|
||||
sendLoopsEvent,
|
||||
sendPostHogEvents,
|
||||
flushAll
|
||||
processAggregatedEvents,
|
||||
flushAll,
|
||||
getBucketForDistinctId
|
||||
};
|
||||
};
|
||||
|
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
IdentityActor,
|
||||
KmipClientActor,
|
||||
PlatformActor,
|
||||
ScimClientActor,
|
||||
ServiceActor,
|
||||
UnknownUserActor,
|
||||
UserActor
|
||||
} from "@app/ee/services/audit-log/audit-log-types";
|
||||
|
||||
export enum PostHogEventTypes {
|
||||
SecretPush = "secrets pushed",
|
||||
SecretPulled = "secrets pulled",
|
||||
@@ -40,6 +50,14 @@ export type TSecretModifiedEvent = {
|
||||
secretPath: string;
|
||||
channel?: string;
|
||||
userAgent?: string;
|
||||
actor?:
|
||||
| UserActor
|
||||
| IdentityActor
|
||||
| ServiceActor
|
||||
| ScimClientActor
|
||||
| PlatformActor
|
||||
| UnknownUserActor
|
||||
| KmipClientActor;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -214,7 +232,7 @@ export type TInvalidateCacheEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TPostHogEvent = { distinctId: string } & (
|
||||
export type TPostHogEvent = { distinctId: string; organizationId?: string } & (
|
||||
| TSecretModifiedEvent
|
||||
| TAdminInitEvent
|
||||
| TUserSignedUpEvent
|
||||
|
2230
docs/docs.json
Normal file
2230
docs/docs.json
Normal file
File diff suppressed because it is too large
Load Diff
128
docs/internals/architecture/cloud.mdx
Normal file
128
docs/internals/architecture/cloud.mdx
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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
|
2215
docs/mint.json
2215
docs/mint.json
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#navbar .max-w-8xl {
|
||||
max-width: 100%;
|
||||
border-bottom: 1px solid #ebebeb;
|
||||
@@ -26,24 +30,20 @@
|
||||
}
|
||||
|
||||
#sidebar li > div.mt-2 {
|
||||
border-radius: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#sidebar li > a.text-primary {
|
||||
border-radius: 0;
|
||||
background-color: #FBFFCC;
|
||||
border-left: 4px solid #EFFF33;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#sidebar li > a.mt-2 {
|
||||
border-radius: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#sidebar li > a.leading-6 {
|
||||
border-radius: 0;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
@@ -68,65 +68,26 @@
|
||||
}
|
||||
|
||||
#content-area .mt-8 .block{
|
||||
border-radius: 0;
|
||||
border-width: 1px;
|
||||
background-color: #FCFBFA;
|
||||
border-color: #ebebeb;
|
||||
}
|
||||
|
||||
/* #content-area:hover .mt-8 .block:hover{
|
||||
border-radius: 0;
|
||||
|
||||
border-width: 1px;
|
||||
background-color: #FDFFE5;
|
||||
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{
|
||||
border-radius: 0;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
#content-area div.flex-1 {
|
||||
/* text-transform: uppercase; */
|
||||
/* #content-area div.flex-1 {
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#content-area button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#content-area a {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#content-area .not-prose {
|
||||
border-radius: 0;
|
||||
}
|
||||
} */
|
||||
|
||||
/* .eyebrow {
|
||||
text-transform: uppercase;
|
||||
|
@@ -94,7 +94,7 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
|
||||
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",
|
||||
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}>
|
||||
|
@@ -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";
|
||||
|
||||
export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
|
||||
export const policyDetails: Record<
|
||||
PolicyType,
|
||||
{ name: string; className: string; icon: IconDefinition }
|
||||
> = {
|
||||
[PolicyType.AccessPolicy]: {
|
||||
className: "bg-lime-900 text-lime-100",
|
||||
name: "Access Policy"
|
||||
className: "bg-green/20 text-green",
|
||||
name: "Access Policy",
|
||||
icon: faArrowRightToBracket
|
||||
},
|
||||
[PolicyType.ChangePolicy]: {
|
||||
className: "bg-indigo-900 text-indigo-100",
|
||||
name: "Change Policy"
|
||||
className: "bg-yellow/20 text-yellow",
|
||||
name: "Change Policy",
|
||||
icon: faEdit
|
||||
}
|
||||
};
|
||||
|
@@ -65,11 +65,11 @@ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequests
|
||||
const fetchApprovalRequests = async ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId
|
||||
authorUserId
|
||||
}: TGetAccessApprovalRequestsDTO) => {
|
||||
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
|
||||
"/api/v1/access-approvals/requests",
|
||||
{ params: { projectSlug, envSlug, authorProjectMembershipId } }
|
||||
{ params: { projectSlug, envSlug, authorUserId } }
|
||||
);
|
||||
|
||||
return data.requests.map((request) => ({
|
||||
@@ -109,12 +109,12 @@ export const useGetAccessRequestsCount = ({
|
||||
export const useGetAccessApprovalPolicies = ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId,
|
||||
authorUserId,
|
||||
options = {}
|
||||
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
|
||||
useQuery({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
|
||||
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }),
|
||||
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorUserId }),
|
||||
...options,
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||
});
|
||||
@@ -122,16 +122,13 @@ export const useGetAccessApprovalPolicies = ({
|
||||
export const useGetAccessApprovalRequests = ({
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId,
|
||||
authorUserId,
|
||||
options = {}
|
||||
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
|
||||
useQuery({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envSlug,
|
||||
authorProjectMembershipId
|
||||
),
|
||||
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, authorUserId),
|
||||
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorUserId }),
|
||||
...options,
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||
enabled: Boolean(projectSlug) && (options?.enabled ?? true),
|
||||
placeholderData: (previousData) => previousData
|
||||
});
|
||||
|
@@ -148,7 +148,7 @@ export type TCreateAccessRequestDTO = {
|
||||
export type TGetAccessApprovalRequestsDTO = {
|
||||
projectSlug: string;
|
||||
envSlug?: string;
|
||||
authorProjectMembershipId?: string;
|
||||
authorUserId?: string;
|
||||
};
|
||||
|
||||
export type TGetAccessPolicyApprovalCountDTO = {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
@@ -25,10 +25,11 @@ export const secretApprovalRequestKeys = {
|
||||
status,
|
||||
committer,
|
||||
offset,
|
||||
limit
|
||||
limit,
|
||||
search
|
||||
}: TGetSecretApprovalRequestList) =>
|
||||
[
|
||||
{ workspaceId, environment, status, committer, offset, limit },
|
||||
{ workspaceId, environment, status, committer, offset, limit, search },
|
||||
"secret-approval-requests"
|
||||
] as const,
|
||||
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
|
||||
@@ -118,23 +119,25 @@ const fetchSecretApprovalRequestList = async ({
|
||||
committer,
|
||||
status = "open",
|
||||
limit = 20,
|
||||
offset
|
||||
offset = 0,
|
||||
search = ""
|
||||
}: TGetSecretApprovalRequestList) => {
|
||||
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>(
|
||||
"/api/v1/secret-approval-requests",
|
||||
{
|
||||
params: {
|
||||
workspaceId,
|
||||
environment,
|
||||
committer,
|
||||
status,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
const { data } = await apiRequest.get<{
|
||||
approvals: TSecretApprovalRequest[];
|
||||
totalCount: number;
|
||||
}>("/api/v1/secret-approval-requests", {
|
||||
params: {
|
||||
workspaceId,
|
||||
environment,
|
||||
committer,
|
||||
status,
|
||||
limit,
|
||||
offset,
|
||||
search
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return data.approvals;
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGetSecretApprovalRequests = ({
|
||||
@@ -143,31 +146,32 @@ export const useGetSecretApprovalRequests = ({
|
||||
options = {},
|
||||
status,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
search,
|
||||
committer
|
||||
}: TGetSecretApprovalRequestList & TReactQueryOptions) =>
|
||||
useInfiniteQuery({
|
||||
initialPageParam: 0,
|
||||
useQuery({
|
||||
queryKey: secretApprovalRequestKeys.list({
|
||||
workspaceId,
|
||||
environment,
|
||||
committer,
|
||||
status
|
||||
status,
|
||||
limit,
|
||||
search,
|
||||
offset
|
||||
}),
|
||||
queryFn: ({ pageParam }) =>
|
||||
queryFn: () =>
|
||||
fetchSecretApprovalRequestList({
|
||||
workspaceId,
|
||||
environment,
|
||||
status,
|
||||
committer,
|
||||
limit,
|
||||
offset: pageParam
|
||||
offset,
|
||||
search
|
||||
}),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (lastPage.length && lastPage.length < limit) return undefined;
|
||||
|
||||
return lastPage?.length !== 0 ? pages.length * limit : undefined;
|
||||
}
|
||||
placeholderData: (previousData) => previousData
|
||||
});
|
||||
|
||||
const fetchSecretApprovalRequestDetails = async ({
|
||||
|
@@ -113,6 +113,7 @@ export type TGetSecretApprovalRequestList = {
|
||||
committer?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type TGetSecretApprovalRequestCount = {
|
||||
|
@@ -352,9 +352,9 @@ export const ProjectLayout = () => {
|
||||
secretApprovalReqCount?.open ||
|
||||
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}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
@@ -19,41 +19,38 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
const taxIDTypes = [
|
||||
{ label: "Australia ABN", value: "au_abn" },
|
||||
{ label: "Australia ARN", value: "au_arn" },
|
||||
{ label: "Bulgaria UIC", value: "bg_uic" },
|
||||
{ label: "Brazil CNPJ", value: "br_cnpj" },
|
||||
{ label: "Brazil CPF", value: "br_cpf" },
|
||||
{ label: "Bulgaria UIC", value: "bg_uic" },
|
||||
{ label: "Canada BN", value: "ca_bn" },
|
||||
{ label: "Canada GST/HST", value: "ca_gst_hst" },
|
||||
{ label: "Canada PST BC", value: "ca_pst_bc" },
|
||||
{ label: "Canada PST MB", value: "ca_pst_mb" },
|
||||
{ label: "Canada PST SK", value: "ca_pst_sk" },
|
||||
{ label: "Canada QST", value: "ca_qst" },
|
||||
{ label: "Switzerland VAT", value: "ch_vat" },
|
||||
{ label: "Chile TIN", value: "cl_tin" },
|
||||
{ label: "Egypt TIN", value: "eg_tin" },
|
||||
{ label: "Spain CIF", value: "es_cif" },
|
||||
{ label: "EU OSS VAT", value: "eu_oss_vat" },
|
||||
{ label: "EU VAT", value: "eu_vat" },
|
||||
{ label: "GB VAT", value: "gb_vat" },
|
||||
{ label: "Georgia VAT", value: "ge_vat" },
|
||||
{ label: "Hong Kong BR", value: "hk_br" },
|
||||
{ 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: "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 RN", value: "jp_rn" },
|
||||
{ label: "Japan TRN", value: "jp_trn" },
|
||||
{ label: "Kenya PIN", value: "ke_pin" },
|
||||
{ label: "South Korea BRN", value: "kr_brn" },
|
||||
{ label: "Liechtenstein UID", value: "li_uid" },
|
||||
{ label: "Mexico RFC", value: "mx_rfc" },
|
||||
{ label: "Malaysia FRP", value: "my_frp" },
|
||||
{ label: "Malaysia ITN", value: "my_itn" },
|
||||
{ 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: "Norway VAT", value: "no_vat" },
|
||||
{ label: "Philippines TIN", value: "ph_tin" },
|
||||
{ label: "Russia INN", value: "ru_inn" },
|
||||
{ label: "Russia KPP", value: "ru_kpp" },
|
||||
@@ -61,12 +58,15 @@ const taxIDTypes = [
|
||||
{ label: "Singapore GST", value: "sg_gst" },
|
||||
{ label: "Singapore UEN", value: "sg_uen" },
|
||||
{ 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: "Turkey TIN", value: "tr_tin" },
|
||||
{ label: "Taiwan VAT", value: "tw_vat" },
|
||||
{ label: "Ukraine VAT", value: "ua_vat" },
|
||||
{ label: "US EIN", value: "us_ein" },
|
||||
{ label: "South Africa VAT", value: "za_vat" }
|
||||
{ label: "Ukraine VAT", value: "ua_vat" }
|
||||
];
|
||||
|
||||
const schema = z
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
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 { Badge } from "@app/components/v2/Badge";
|
||||
@@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => {
|
||||
<PageHeader
|
||||
title="Approval Workflows"
|
||||
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}>
|
||||
<TabList>
|
||||
<Tab value={TabSection.SecretApprovalRequests}>
|
||||
|
@@ -2,15 +2,25 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBan,
|
||||
faBookOpen,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faClipboardCheck,
|
||||
faLock,
|
||||
faPlus
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faStopwatch,
|
||||
faUser,
|
||||
IconDefinition
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import {
|
||||
@@ -21,6 +31,8 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Pagination,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
@@ -32,7 +44,12 @@ import {
|
||||
useUser,
|
||||
useWorkspace
|
||||
} 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 {
|
||||
accessApprovalKeys,
|
||||
@@ -48,28 +65,21 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { RequestAccessModal } from "./components/RequestAccessModal";
|
||||
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
|
||||
|
||||
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => {
|
||||
const generateRequestText = (request: TAccessApprovalRequest) => {
|
||||
const { isTemporary } = request;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
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}
|
||||
</code>
|
||||
in
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
</code>{" "}
|
||||
in{" "}
|
||||
<code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
|
||||
{request.environmentName}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
{request.requestedByUserId === userId && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<Badge className="ml-1">Requested By You</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -120,30 +130,64 @@ export const AccessApprovalRequest = ({
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({
|
||||
const {
|
||||
data: requests,
|
||||
refetch: refetchRequests,
|
||||
isPending: areRequestsPending
|
||||
} = useGetAccessApprovalRequests({
|
||||
projectSlug,
|
||||
authorProjectMembershipId: requestedByFilter,
|
||||
authorUserId: requestedByFilter,
|
||||
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(() => {
|
||||
let accessRequests: typeof requests;
|
||||
|
||||
if (statusFilter === "open")
|
||||
return requests?.filter(
|
||||
accessRequests = requests?.filter(
|
||||
(request) =>
|
||||
!request.policy.deletedAt &&
|
||||
!request.isApproved &&
|
||||
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
if (statusFilter === "close")
|
||||
return requests?.filter(
|
||||
accessRequests = requests?.filter(
|
||||
(request) =>
|
||||
request.policy.deletedAt ||
|
||||
request.isApproved ||
|
||||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
|
||||
return requests;
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||
return (
|
||||
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(
|
||||
(request: TAccessApprovalRequest) => {
|
||||
@@ -162,9 +206,15 @@ export const AccessApprovalRequest = ({
|
||||
const canBypass =
|
||||
!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: "",
|
||||
type: "primary"
|
||||
type: "primary",
|
||||
icon: null
|
||||
};
|
||||
|
||||
const isExpired =
|
||||
@@ -172,20 +222,42 @@ export const AccessApprovalRequest = ({
|
||||
request.isApproved &&
|
||||
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
|
||||
|
||||
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
|
||||
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
|
||||
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
|
||||
if (isExpired)
|
||||
displayData = {
|
||||
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) {
|
||||
displayData = {
|
||||
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
|
||||
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
|
||||
}`,
|
||||
type: "primary"
|
||||
type: "primary",
|
||||
icon: faClipboardCheck
|
||||
};
|
||||
} else if (!isReviewedByUser)
|
||||
displayData = {
|
||||
label: "Review Required",
|
||||
type: "primary"
|
||||
type: "primary",
|
||||
icon: faClipboardCheck
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -225,47 +297,71 @@ export const AccessApprovalRequest = ({
|
||||
[generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Request access to secrets in sensitive environments and folders.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||
isDisabled={policiesLoading || !!policies?.length}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("requestAccess");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={policiesLoading || !policies?.length}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
const isFiltered = Boolean(search || envFilter || requestedByFilter);
|
||||
|
||||
<AnimatePresence>
|
||||
<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="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||
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 className="flex items-start gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
|
||||
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">
|
||||
Request and review access to secrets in sensitive environments and folders
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||
isDisabled={policiesLoading || !!policies?.length}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("requestAccess");
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={policiesLoading || !policies?.length}
|
||||
>
|
||||
Request Access
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search approval requests by requesting user or environment..."
|
||||
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
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -273,17 +369,19 @@ export const AccessApprovalRequest = ({
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{!!requestCount && requestCount?.pendingCount} Pending
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
@@ -292,7 +390,7 @@ export const AccessApprovalRequest = ({
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{!!requestCount && requestCount.finalizedCount} Completed
|
||||
{!!requestCount && requestCount.finalizedCount} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
@@ -300,14 +398,20 @@ export const AccessApprovalRequest = ({
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-bunker-300"
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
<DropdownMenuContent
|
||||
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 }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
@@ -337,15 +441,27 @@ export const AccessApprovalRequest = ({
|
||||
Requested By
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
<DropdownMenuContent
|
||||
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 }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setRequestedByFilter((state) => (state === id ? undefined : id))
|
||||
setRequestedByFilter((state) =>
|
||||
state === membershipUser.id ? undefined : membershipUser.id
|
||||
)
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
icon={
|
||||
requestedByFilter === membershipUser.id && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{membershipUser.username}
|
||||
@@ -357,19 +473,26 @@ export const AccessApprovalRequest = ({
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
{!!filteredRequests?.length &&
|
||||
filteredRequests?.map((request) => {
|
||||
filteredRequests?.slice(offset, perPage * page).map((request) => {
|
||||
const details = generateRequestDetails(request);
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
tabIndex={0}
|
||||
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="mb-1 flex w-full items-center">
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{generateRequestText(request, user.id)}
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
size="xs"
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
/>
|
||||
{generateRequestText(request)}
|
||||
</div>
|
||||
<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 && (
|
||||
<>
|
||||
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
|
||||
@@ -397,61 +524,86 @@ export const AccessApprovalRequest = ({
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
<Badge variant={details.displayData.type}>
|
||||
{details.displayData.label}
|
||||
<Badge
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Boolean(filteredRequests.length) && (
|
||||
<Pagination
|
||||
className="border-none"
|
||||
count={filteredRequests.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{!!policies && (
|
||||
<RequestAccessModal
|
||||
policies={policies}
|
||||
isOpen={popUp.requestAccess.isOpen}
|
||||
onOpenChange={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envFilter,
|
||||
requestedByFilter
|
||||
)
|
||||
});
|
||||
handlePopUpClose("requestAccess");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!policies && (
|
||||
<RequestAccessModal
|
||||
policies={policies}
|
||||
isOpen={popUp.requestAccess.isOpen}
|
||||
onOpenChange={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envFilter,
|
||||
requestedByFilter
|
||||
)
|
||||
});
|
||||
handlePopUpClose("requestAccess");
|
||||
}}
|
||||
{!!selectedRequest && (
|
||||
<ReviewAccessRequestModal
|
||||
selectedEnvSlug={envFilter}
|
||||
policies={policies || []}
|
||||
selectedRequester={requestedByFilter}
|
||||
projectSlug={projectSlug}
|
||||
request={selectedRequest}
|
||||
members={members || []}
|
||||
isOpen={popUp.reviewRequest.isOpen}
|
||||
onOpenChange={() => {
|
||||
handlePopUpClose("reviewRequest");
|
||||
setSelectedRequest(null);
|
||||
refetchRequests();
|
||||
}}
|
||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradePlanModal
|
||||
text="You need to upgrade your plan to access this feature"
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!selectedRequest && (
|
||||
<ReviewAccessRequestModal
|
||||
selectedEnvSlug={envFilter}
|
||||
policies={policies || []}
|
||||
selectedRequester={requestedByFilter}
|
||||
projectSlug={projectSlug}
|
||||
request={selectedRequest}
|
||||
members={members || []}
|
||||
isOpen={popUp.reviewRequest.isOpen}
|
||||
onOpenChange={() => {
|
||||
handlePopUpClose("reviewRequest");
|
||||
setSelectedRequest(null);
|
||||
refetchRequests();
|
||||
}}
|
||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradePlanModal
|
||||
text="You need to upgrade your plan to access this feature"
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
@@ -1,11 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faFileShield,
|
||||
faPlus
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
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 { createNotification } from "@app/components/notifications";
|
||||
@@ -19,8 +27,9 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Modal,
|
||||
ModalContent,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -38,7 +47,12 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
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 {
|
||||
useDeleteAccessApprovalPolicy,
|
||||
useDeleteSecretApprovalPolicy,
|
||||
@@ -47,6 +61,7 @@ import {
|
||||
useListWorkspaceGroups
|
||||
} from "@app/hooks/api";
|
||||
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 { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
|
||||
|
||||
@@ -57,6 +72,18 @@ interface IProps {
|
||||
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 { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
|
||||
{
|
||||
@@ -112,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
currentWorkspace
|
||||
);
|
||||
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<PolicyFilters>({
|
||||
type: null,
|
||||
environmentIds: []
|
||||
});
|
||||
|
||||
const filteredPolicies = useMemo(() => {
|
||||
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies;
|
||||
}, [policies, filterType]);
|
||||
const {
|
||||
search,
|
||||
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: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
|
||||
@@ -151,144 +246,288 @@ 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 (
|
||||
<div>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Implement granular policies for access requests and secrets management.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("policyForm");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Environment</Th>
|
||||
<Th>Secret Path</Th>
|
||||
<Th>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-xs font-semibold uppercase text-bunker-300"
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Type
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(null)}
|
||||
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(PolicyType.AccessPolicy)}
|
||||
icon={
|
||||
filterType === PolicyType.AccessPolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Access Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(PolicyType.ChangePolicy)}
|
||||
icon={
|
||||
filterType === PolicyType.ChangePolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Change Policy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && (
|
||||
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
|
||||
)}
|
||||
{!isPoliciesLoading && !filteredPolicies?.length && (
|
||||
<Tr>
|
||||
<Td colSpan={6}>
|
||||
<EmptyState title="No policies found" icon={faFileShield} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies?.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
policy={policy}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onEdit={() => handlePopUpOpen("policyForm", policy)}
|
||||
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Modal
|
||||
isOpen={popUp.policyForm.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
|
||||
<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"
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-3xl"
|
||||
title={
|
||||
popUp.policyForm.data
|
||||
? `Edit ${popUp?.policyForm?.data?.name || "Policy"}`
|
||||
: "Create Policy"
|
||||
}
|
||||
id="policy-form"
|
||||
>
|
||||
<AccessPolicyForm
|
||||
projectId={currentWorkspace.id}
|
||||
projectSlug={currentWorkspace.slug}
|
||||
isOpen={popUp.policyForm.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
|
||||
members={members}
|
||||
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<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">Policies</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">
|
||||
Implement granular policies for access requests and secrets management
|
||||
</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("policyForm");
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search policies by name, type, environment or secret path..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter findings"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuLabel>Policy Type</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: null
|
||||
}))
|
||||
}
|
||||
icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: PolicyType.AccessPolicy
|
||||
}))
|
||||
}
|
||||
icon={
|
||||
filters.type === PolicyType.AccessPolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Access Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: PolicyType.ChangePolicy
|
||||
}))
|
||||
}
|
||||
icon={
|
||||
filters.type === PolicyType.ChangePolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Change Policy
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && (
|
||||
<TableSkeleton
|
||||
columns={5}
|
||||
innerKey="secret-policies"
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
)}
|
||||
{!isPoliciesLoading && !policies?.length && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState title="No Policies Found" icon={faFileShield} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies
|
||||
?.slice(offset, perPage * page)
|
||||
.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
policy={policy}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onEdit={() => handlePopUpOpen("policyForm", policy)}
|
||||
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</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>
|
||||
</div>
|
||||
</motion.div>
|
||||
<AccessPolicyForm
|
||||
projectId={currentWorkspace.id}
|
||||
projectSlug={currentWorkspace.slug}
|
||||
isOpen={popUp.policyForm.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
|
||||
members={members}
|
||||
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePolicy.isOpen}
|
||||
deleteKey="remove"
|
||||
@@ -301,6 +540,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
@@ -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 { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
Switch,
|
||||
@@ -110,20 +112,20 @@ const formSchema = z
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const AccessPolicyForm = ({
|
||||
isOpen,
|
||||
const Form = ({
|
||||
onToggle,
|
||||
members = [],
|
||||
projectId,
|
||||
projectSlug,
|
||||
editValues
|
||||
}: Props) => {
|
||||
editValues,
|
||||
modalContainer,
|
||||
isEditMode
|
||||
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
|
||||
const [draggedItem, setDraggedItem] = useState<number | null>(null);
|
||||
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
@@ -188,13 +190,8 @@ export const AccessPolicyForm = ({
|
||||
const { data: groups } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const isEditMode = Boolean(editValues);
|
||||
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isEditMode) reset({});
|
||||
}, [isOpen, isEditMode]);
|
||||
|
||||
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
|
||||
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
|
||||
|
||||
@@ -387,6 +384,7 @@ export const AccessPolicyForm = ({
|
||||
setDraggedItem(null);
|
||||
setDragOverItem(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-3">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
@@ -572,7 +570,7 @@ export const AccessPolicyForm = ({
|
||||
className="flex-grow"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={document.getElementById("policy-form")}
|
||||
menuPortalTarget={modalContainer.current}
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members..."
|
||||
@@ -602,7 +600,7 @@ export const AccessPolicyForm = ({
|
||||
className="flex-grow"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={document.getElementById("policy-form")}
|
||||
menuPortalTarget={modalContainer.current}
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups..."
|
||||
@@ -813,3 +811,27 @@ export const AccessPolicyForm = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@@ -1,5 +1,5 @@
|
||||
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 { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
Td,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
@@ -80,11 +82,11 @@ export const ApprovalPolicyRow = ({
|
||||
userLabels: members
|
||||
?.filter((member) => el.user.find((i) => i.id === member.user.id))
|
||||
.map((member) => getMemberLabel(member))
|
||||
.join(","),
|
||||
.join(", "),
|
||||
groupLabels: groups
|
||||
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
|
||||
.map(({ group }) => group.name)
|
||||
.join(","),
|
||||
.join(", "),
|
||||
approvals: el.approvals
|
||||
};
|
||||
});
|
||||
@@ -102,36 +104,47 @@ export const ApprovalPolicyRow = ({
|
||||
}}
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{policy.environment.slug}</Td>
|
||||
<Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
|
||||
<Td>{policy.environment.name}</Td>
|
||||
<Td>{policy.secretPath || "*"}</Td>
|
||||
<Td>
|
||||
<Badge className={policyDetails[policy.policyType].className}>
|
||||
{policyDetails[policy.policyType].name}
|
||||
<Badge
|
||||
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>
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<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">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
|
||||
<DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
>
|
||||
Edit Policy
|
||||
</DropdownMenuItem>
|
||||
@@ -143,16 +156,12 @@ export const ApprovalPolicyRow = ({
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Policy
|
||||
</DropdownMenuItem>
|
||||
@@ -162,45 +171,41 @@ export const ApprovalPolicyRow = ({
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isExpanded && (
|
||||
<Tr>
|
||||
<Td colSpan={5} className="rounded bg-mineshaft-900">
|
||||
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
|
||||
{labels?.map((el, index) => (
|
||||
<div
|
||||
key={`approval-list-${index + 1}`}
|
||||
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
|
||||
<div className="text-lg">{index + 1}</div>
|
||||
<Tr>
|
||||
<Td colSpan={6} className="!border-none p-0">
|
||||
<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) => (
|
||||
<div
|
||||
key={`approval-list-${index + 1}`}
|
||||
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
|
||||
<div>{index + 1}</div>
|
||||
</div>
|
||||
{index !== labels.length - 1 && (
|
||||
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
|
||||
<div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
|
||||
)}
|
||||
{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>
|
||||
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
|
||||
<div>{el.userLabels || "-"}</div>
|
||||
</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 className="grid flex-grow grid-cols-3">
|
||||
<GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faBookOpen,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faCodeBranch
|
||||
faCodeBranch,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -18,6 +23,8 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Pagination,
|
||||
Skeleton
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
@@ -28,6 +35,12 @@ import {
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import {
|
||||
useGetSecretApprovalRequestCount,
|
||||
useGetSecretApprovalRequests,
|
||||
@@ -52,18 +65,41 @@ export const SecretApprovalRequest = () => {
|
||||
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
|
||||
|
||||
const {
|
||||
data: secretApprovalRequests,
|
||||
isFetchingNextPage: isFetchingNextApprovalRequest,
|
||||
fetchNextPage: fetchNextApprovalRequest,
|
||||
hasNextPage: hasNextApprovalPage,
|
||||
debouncedSearch: debouncedSearchFilter,
|
||||
search: searchFilter,
|
||||
setSearch: setSearchFilter,
|
||||
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,
|
||||
refetch
|
||||
} = useGetSecretApprovalRequests({
|
||||
workspaceId,
|
||||
status: statusFilter,
|
||||
environment: envFilter,
|
||||
committer: committerFilter
|
||||
committer: committerFilter,
|
||||
search: debouncedSearchFilter,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
const totalApprovalCount = data?.totalCount ?? 0;
|
||||
const secretApprovalRequests = data?.approvals ?? [];
|
||||
|
||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||
useGetSecretApprovalRequestCount({ workspaceId });
|
||||
const { user: userSession } = useUser();
|
||||
@@ -88,8 +124,9 @@ export const SecretApprovalRequest = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
const isRequestListEmpty =
|
||||
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
|
||||
const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0;
|
||||
|
||||
const isFiltered = Boolean(searchFilter || envFilter || committerFilter);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -116,178 +153,233 @@ export const SecretApprovalRequest = () => {
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
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
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
<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"
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(
|
||||
ProjectPermissionMemberActions.Read,
|
||||
ProjectPermissionSub.Member
|
||||
) && (
|
||||
<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
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"font-medium",
|
||||
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Author
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuContent
|
||||
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 }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={
|
||||
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(
|
||||
ProjectPermissionMemberActions.Read,
|
||||
ProjectPermissionSub.Member
|
||||
) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Author
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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 }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={
|
||||
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{isRequestListEmpty && !isFiltered && (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{secretApprovalRequests.map((secretApproval) => {
|
||||
const {
|
||||
id: reqId,
|
||||
commits,
|
||||
createdAt,
|
||||
reviewers,
|
||||
status,
|
||||
committerUser
|
||||
} = secretApproval;
|
||||
const isReviewed = reviewers.some(
|
||||
({ status: reviewStatus, userId }) =>
|
||||
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={reqId}
|
||||
className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedApprovalId(secretApproval.id)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 text-sm">
|
||||
<FontAwesomeIcon
|
||||
icon={faCodeBranch}
|
||||
size="sm"
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
/>
|
||||
{secretApproval.isReplicated
|
||||
? `${commits.length} secret pending import`
|
||||
: generateCommitText(commits)}
|
||||
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs leading-3 text-gray-500">
|
||||
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
|
||||
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
|
||||
{committerUser?.email})
|
||||
{!isReviewed && status === "open" && " - Review required"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Boolean(
|
||||
!secretApprovalRequests.length && isFiltered && !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>
|
||||
{Array.apply(0, Array(3)).map((_x, index) => (
|
||||
<div
|
||||
key={`approval-request-loading-${index + 1}`}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
<Skeleton className="w-1/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{isRequestListEmpty && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more requests pending." />
|
||||
</div>
|
||||
)}
|
||||
{secretApprovalRequests?.pages?.map((group, i) => (
|
||||
<Fragment key={`secret-approval-request-${i + 1}`}>
|
||||
{group?.map((secretApproval) => {
|
||||
const {
|
||||
id: reqId,
|
||||
commits,
|
||||
createdAt,
|
||||
reviewers,
|
||||
status,
|
||||
committerUser
|
||||
} = secretApproval;
|
||||
const isReviewed = reviewers.some(
|
||||
({ status: reviewStatus, userId }) =>
|
||||
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={reqId}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedApprovalId(secretApproval.id)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{secretApproval.isReplicated
|
||||
? `${commits.length} secret pending import`
|
||||
: generateCommitText(commits)}
|
||||
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
|
||||
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
|
||||
{committerUser?.email})
|
||||
{!isReviewed && status === "open" && " - Review required"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
|
||||
<div>
|
||||
{Array.apply(0, Array(3)).map((_x, index) => (
|
||||
<div
|
||||
key={`approval-request-loading-${index + 1}`}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
<Skeleton className="w-1/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasNextApprovalPage && (
|
||||
<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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
@@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
|
||||
if (score[CommitType.CREATE])
|
||||
text.push(
|
||||
<span key="created-commit">
|
||||
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
|
||||
<span style={{ color: "#60DD00" }}> created</span>
|
||||
{score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
|
||||
<span className="text-green-600"> Created</span>
|
||||
</span>
|
||||
);
|
||||
if (score[CommitType.UPDATE])
|
||||
text.push(
|
||||
<span key="updated-commit">
|
||||
{Boolean(text.length) && ","}
|
||||
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span style={{ color: "#F8EB30" }} className="text-orange-600">
|
||||
{" "}
|
||||
updated
|
||||
</span>
|
||||
{Boolean(text.length) && ", "}
|
||||
{score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span className="text-yellow-600"> Updated</span>
|
||||
</span>
|
||||
);
|
||||
if (score[CommitType.DELETE])
|
||||
text.push(
|
||||
<span className="deleted-commit">
|
||||
{Boolean(text.length) && "and"}
|
||||
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span style={{ color: "#F83030" }}> deleted</span>
|
||||
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
|
||||
<span className="text-red-600"> Deleted</span>
|
||||
</span>
|
||||
);
|
||||
return text;
|
||||
|
@@ -36,10 +36,13 @@ const formSchema = z.object({
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) })
|
||||
)
|
||||
.optional()
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().trim().min(1).max(128),
|
||||
value: z.string().trim().min(1).max(256)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
|
||||
@@ -51,10 +54,13 @@ const formSchema = z.object({
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) })
|
||||
)
|
||||
.optional()
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().trim().min(1).max(128),
|
||||
value: z.string().trim().min(1).max(256)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
|
@@ -25,8 +25,8 @@ const formSchema = z.object({
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
|
||||
.optional(),
|
||||
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
|
||||
.optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
|
||||
@@ -38,8 +38,8 @@ const formSchema = z.object({
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: z
|
||||
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
|
||||
.optional()
|
||||
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
|
||||
.optional()
|
||||
})
|
||||
]),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
@@ -97,7 +97,7 @@ export const EditDynamicSecretAwsIamForm = ({
|
||||
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"])
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey;
|
||||
@@ -125,8 +125,7 @@ export const EditDynamicSecretAwsIamForm = ({
|
||||
defaultTTL,
|
||||
inputs,
|
||||
newName: newName === dynamicSecret.name ? undefined : newName,
|
||||
usernameTemplate:
|
||||
!usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
|
||||
usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
|
Reference in New Issue
Block a user