diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index b3cff0322..51a04b783 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -146,12 +146,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - projectId: req.params.workspaceId, - ...req.query, - endDate: req.query.endDate, - startDate: req.query.startDate || getLastMidnightDateISO(), - auditLogActor: req.query.actor, - actor: req.permission.type + actor: req.permission.type, + + filter: { + ...req.query, + projectId: req.params.workspaceId, + endDate: req.query.endDate, + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActorId: req.query.actor, + eventType: req.query.eventType ? [req.query.eventType] : undefined + } }); return { auditLogs }; } diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 3021beb0d..ce345766b 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -6,6 +6,9 @@ import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; +import { ActorType } from "@app/services/auth/auth-type"; + +import { EventType } from "./audit-log-types"; export type TAuditLogDALFactory = ReturnType; @@ -25,7 +28,24 @@ export const auditLogDALFactory = (db: TDbClient) => { const auditLogOrm = ormify(db, TableName.AuditLog); const find = async ( - { orgId, projectId, userAgentType, startDate, endDate, limit = 20, offset = 0, actor, eventType }: TFindQuery, + { + orgId, + projectId, + userAgentType, + startDate, + endDate, + limit = 20, + offset = 0, + actorId, + actorType, + eventType, + eventMetadata + }: Omit & { + actorId?: string; + actorType?: ActorType; + eventType?: EventType[]; + eventMetadata?: Record; + }, tx?: Knex ) => { try { @@ -34,7 +54,6 @@ export const auditLogDALFactory = (db: TDbClient) => { stripUndefinedInWhere({ projectId, [`${TableName.AuditLog}.orgId`]: orgId, - eventType, userAgentType }) ) @@ -52,8 +71,22 @@ export const auditLogDALFactory = (db: TDbClient) => { .offset(offset) .orderBy(`${TableName.AuditLog}.createdAt`, "desc"); - if (actor) { - void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actor]); + if (actorId) { + void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]); + } + + if (eventMetadata && Object.keys(eventMetadata).length) { + Object.entries(eventMetadata).forEach(([key, value]) => { + void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]); + }); + } + + if (actorType) { + void sqlQuery.where("actor", actorType); + } + + if (eventType?.length) { + void sqlQuery.whereIn("eventType", eventType); } if (startDate) { diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 11159c37b..a93b2a6e1 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -23,25 +23,12 @@ export const auditLogServiceFactory = ({ auditLogQueue, permissionService }: TAuditLogServiceFactoryDep) => { - const listAuditLogs = async ({ - userAgentType, - eventType, - offset, - limit, - endDate, - startDate, - actor, - actorId, - actorOrgId, - actorAuthMethod, - projectId, - auditLogActor - }: TListProjectAuditLogDTO) => { - if (projectId) { + const listAuditLogs = async ({ actorAuthMethod, actorId, actorOrgId, actor, filter }: TListProjectAuditLogDTO) => { + if (filter.projectId) { const { permission } = await permissionService.getProjectPermission( actor, actorId, - projectId, + filter.projectId, actorAuthMethod, actorOrgId ); @@ -65,14 +52,16 @@ export const auditLogServiceFactory = ({ // If project ID is not provided, then we need to return all the audit logs for the organization itself. const auditLogs = await auditLogDAL.find({ - startDate, - endDate, - limit, - offset, - eventType, - userAgentType, - actor: auditLogActor, - ...(projectId ? { projectId } : { orgId: actorOrgId }) + startDate: filter.startDate, + endDate: filter.endDate, + limit: filter.limit, + offset: filter.offset, + eventType: filter.eventType, + userAgentType: filter.userAgentType, + actorId: filter.auditLogActorId, + actorType: filter.actorType, + eventMetadata: filter.eventMetadata, + ...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId }) }); return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({ diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 3b3a5b107..542471fac 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -5,19 +5,23 @@ import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; export type TListProjectAuditLogDTO = { - auditLogActor?: string; - projectId?: string; - eventType?: string; - startDate?: string; - endDate?: string; - userAgentType?: string; - limit?: number; - offset?: number; + filter: { + userAgentType?: UserAgentType; + eventType?: EventType[]; + offset?: number; + limit: number; + endDate?: string; + startDate?: string; + projectId?: string; + auditLogActorId?: string; + actorType?: ActorType; + eventMetadata?: Record; + }; } & Omit; export type TCreateAuditLogDTO = { event: Event; - actor: UserActor | IdentityActor | ServiceActor | ScimClientActor; + actor: UserActor | IdentityActor | ServiceActor | ScimClientActor | PlatformActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -177,7 +181,8 @@ export enum EventType { UPDATE_SLACK_INTEGRATION = "update-slack-integration", DELETE_SLACK_INTEGRATION = "delete-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", - UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config" + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + INTEGRATION_SYNCED = "integration-synced" } interface UserActorMetadata { @@ -198,6 +203,8 @@ interface IdentityActorMetadata { interface ScimClientActorMetadata {} +interface PlatformActorMetadata {} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -208,6 +215,11 @@ export interface ServiceActor { metadata: ServiceActorMetadata; } +export interface PlatformActor { + type: ActorType.PLATFORM; + metadata: PlatformActorMetadata; +} + export interface IdentityActor { type: ActorType.IDENTITY; metadata: IdentityActorMetadata; @@ -218,7 +230,7 @@ export interface ScimClientActor { metadata: ScimClientActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor; +export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -1518,6 +1530,16 @@ interface GetProjectSlackConfig { id: string; }; } +interface IntegrationSyncedEvent { + type: EventType.INTEGRATION_SYNCED; + metadata: { + integrationId: string; + lastSyncJobId: string; + lastUsed: Date; + syncMessage: string; + isSynced: boolean; + }; +} export type Event = | GetSecretsEvent @@ -1657,4 +1679,5 @@ export type Event = | DeleteSlackIntegration | GetSlackIntegration | UpdateProjectSlackConfig - | GetProjectSlackConfig; + | GetProjectSlackConfig + | IntegrationSyncedEvent; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index bf036ba84..68fcb0db2 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -91,6 +91,8 @@ export type TQueueJobTypes = { [QueueName.IntegrationSync]: { name: QueueJobs.IntegrationSync; payload: { + isManual?: boolean; + actorId?: string; projectId: string; environment: string; secretPath: string; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 60e84203f..3eb6b0031 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -810,6 +810,8 @@ export const registerRoutes = async ( projectEnvDAL, webhookDAL, orgDAL, + auditLogService, + userDAL, projectMembershipDAL, smtpService, projectDAL, diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 6526dd940..f08bb7e3b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -4,7 +4,7 @@ import { IntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { INTEGRATION } from "@app/lib/api-docs"; import { removeTrailingSlash, shake } from "@app/lib/fn"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -154,6 +154,48 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:integrationId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get an integration by integration id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationId: z.string().trim().describe(INTEGRATION.UPDATE.integrationId) + }), + response: { + 200: z.object({ + integration: IntegrationsSchema.extend({ + environment: z.object({ + slug: z.string().trim(), + name: z.string().trim(), + id: z.string().trim() + }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const integration = await server.services.integration.getIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationId + }); + + return { integration }; + } + }); + server.route({ method: "DELETE", url: "/:integrationId", diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 68a1dba45..bb12a5151 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -14,7 +14,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ @@ -74,8 +74,35 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { description: "Get all audit logs for an organization", querystring: z.object({ - eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + projectId: z.string().optional(), + actorType: z.nativeEnum(ActorType).optional(), + // eventType is split with , for multiple values, we need to transform it to array + eventType: z + .string() + .optional() + .transform((val) => (val ? val.split(",") : undefined)), userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + eventMetadata: z + .string() + .optional() + .transform((val) => { + if (!val) { + return undefined; + } + + const pairs = val.split(","); + + return pairs.reduce( + (acc, pair) => { + const [key, value] = pair.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + }), startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), @@ -114,13 +141,19 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const auditLogs = await server.services.auditLog.listAuditLogs({ + filter: { + ...req.query, + endDate: req.query.endDate, + projectId: req.query.projectId, + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActorId: req.query.actor, + actorType: req.query.actorType, + eventType: req.query.eventType as EventType[] | undefined + }, + actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - ...req.query, - endDate: req.query.endDate, - startDate: req.query.startDate || getLastMidnightDateISO(), - auditLogActor: req.query.actor, actor: req.permission.type }); return { auditLogs }; diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 9210093ab..87522a803 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -34,6 +34,7 @@ export enum AuthMode { } export enum ActorType { // would extend to AWS, Azure, ... + PLATFORM = "platform", // Useful for when we want to perform logging on automated actions such as integration syncs. USER = "user", // userIdentity SERVICE = "service", IDENTITY = "identity", diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 02e520c6e..029825baa 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectPermission } from "@app/lib/types"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; @@ -19,6 +19,7 @@ import { TIntegrationDALFactory } from "./integration-dal"; import { TCreateIntegrationDTO, TDeleteIntegrationDTO, + TGetIntegrationDTO, TSyncIntegrationDTO, TUpdateIntegrationDTO } from "./integration-types"; @@ -180,6 +181,27 @@ export const integrationServiceFactory = ({ return updatedIntegration; }; + const getIntegration = async ({ id, actor, actorAuthMethod, actorId, actorOrgId }: TGetIntegrationDTO) => { + const integration = await integrationDAL.findById(id); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration?.projectId || "", + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + + if (!integration) { + throw new NotFoundError({ + message: "Integration not found" + }); + } + + return { ...integration, envId: integration.environment.id }; + }; + const deleteIntegration = async ({ actorId, id, @@ -276,6 +298,8 @@ export const integrationServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); await secretQueueService.syncIntegrations({ + isManual: true, + actorId, environment: integration.environment.slug, secretPath: integration.secretPath, projectId: integration.projectId @@ -289,6 +313,7 @@ export const integrationServiceFactory = ({ updateIntegration, deleteIntegration, listIntegrationByProject, + getIntegration, syncIntegration }; }; diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 0df8edc4a..5c76159de 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -39,6 +39,10 @@ export type TCreateIntegrationDTO = { }; } & Omit; +export type TGetIntegrationDTO = { + id: string; +} & Omit; + export type TUpdateIntegrationDTO = { id: string; app?: string; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 0c63eb147..07cb0923d 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -2,6 +2,8 @@ import { AxiosError } from "axios"; import { ProjectUpgradeStatus, ProjectVersion, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { Actor, EventType } from "@app/ee/services/audit-log/audit-log-types"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; @@ -21,6 +23,7 @@ import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { ActorType } from "../auth/auth-type"; import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; @@ -40,6 +43,7 @@ import { expandSecretReferencesFactory, getAllNestedSecretReferences } from "../ import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TUserDALFactory } from "../user/user-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { TSecretDALFactory } from "./secret-dal"; @@ -71,6 +75,7 @@ type TSecretQueueFactoryDep = { secretVersionDAL: TSecretVersionDALFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; secretTagDAL: TSecretTagDALFactory; + userDAL: Pick; secretVersionTagDAL: TSecretVersionTagDALFactory; kmsService: Pick; secretV2BridgeDAL: TSecretV2BridgeDALFactory; @@ -81,6 +86,7 @@ type TSecretQueueFactoryDep = { snapshotDAL: Pick; snapshotSecretV2BridgeDAL: Pick; keyStore: Pick; + auditLogService: Pick; }; export type TGetSecrets = { @@ -106,6 +112,7 @@ export const secretQueueFactory = ({ secretDAL, secretImportDAL, folderDAL, + userDAL, webhookDAL, projectEnvDAL, orgDAL, @@ -125,7 +132,8 @@ export const secretQueueFactory = ({ snapshotDAL, snapshotSecretV2BridgeDAL, secretApprovalRequestDAL, - keyStore + keyStore, + auditLogService }: TSecretQueueFactoryDep) => { const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); @@ -430,7 +438,9 @@ export const secretQueueFactory = ({ return content; }; - const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record }) => { + const syncIntegrations = async ( + dto: TGetSecrets & { isManual?: boolean; actorId?: string; deDupeQueue?: Record } + ) => { await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { attempts: 3, delay: 1000, @@ -528,7 +538,7 @@ export const secretQueueFactory = ({ } } ); - await syncIntegrations({ secretPath, projectId, environment, deDupeQueue }); + await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false }); if (!excludeReplication) { await replicateSecrets({ _deDupeReplicationQueue: deDupeReplicationQueue, @@ -544,7 +554,7 @@ export const secretQueueFactory = ({ }); queueService.start(QueueName.IntegrationSync, async (job) => { - const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; + const { environment, actorId, isManual, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; if (depth > MAX_SYNC_SECRET_DEPTH) return; const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); @@ -693,6 +703,30 @@ export const secretQueueFactory = ({ }); } + const generateActor = async (): Promise => { + if (isManual && actorId) { + const user = await userDAL.findById(actorId); + + if (!user) { + throw new Error("User not found"); + } + + return { + type: ActorType.USER, + metadata: { + email: user.email, + username: user.username, + userId: user.id + } + }; + } + + return { + type: ActorType.PLATFORM, + metadata: {} + }; + }; + // akhilmhdh: this try catch is for lock release try { const secrets = shouldUseSecretV2Bridge @@ -778,6 +812,21 @@ export const secretQueueFactory = ({ } }); + await auditLogService.createAuditLog({ + projectId, + actor: await generateActor(), + event: { + type: EventType.INTEGRATION_SYNCED, + metadata: { + integrationId: integration.id, + isSynced: response?.isSynced ?? true, + lastSyncJobId: job?.id ?? "", + lastUsed: new Date(), + syncMessage: response?.syncMessage ?? "" + } + } + }); + await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, lastUsed: new Date(), @@ -794,9 +843,23 @@ export const secretQueueFactory = ({ (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || "Unknown error occurred."; + await auditLogService.createAuditLog({ + projectId, + actor: await generateActor(), + event: { + type: EventType.INTEGRATION_SYNCED, + metadata: { + integrationId: integration.id, + isSynced: false, + lastSyncJobId: job?.id ?? "", + lastUsed: new Date(), + syncMessage: message + } + } + }); + await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, - lastUsed: new Date(), syncMessage: message, isSynced: false }); diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index cc72cfa86..404592908 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -79,7 +79,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Update certificate template EST configuration", [EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration", - [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration" + [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration", + [EventType.INTEGRATION_SYNCED]: "Integration sync" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b110e330b..1db55d739 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -1,4 +1,5 @@ export enum ActorType { + PLATFORM = "platform", USER = "user", SERVICE = "service", IDENTITY = "identity" @@ -91,5 +92,6 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", - GET_PROJECT_SLACK_CONFIG = "get-project-slack-config" + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + INTEGRATION_SYNCED = "integration-synced" } diff --git a/frontend/src/hooks/api/auditLogs/queries.tsx b/frontend/src/hooks/api/auditLogs/queries.tsx index 1788b8f08..1c74a79ba 100644 --- a/frontend/src/hooks/api/auditLogs/queries.tsx +++ b/frontend/src/hooks/api/auditLogs/queries.tsx @@ -1,35 +1,58 @@ -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, UseInfiniteQueryOptions, useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { Actor, AuditLog, AuditLogFilters } from "./types"; +import { Actor, AuditLog, TGetAuditLogsFilter } from "./types"; export const auditLogKeys = { - getAuditLogs: (workspaceId: string | null, filters: AuditLogFilters) => + getAuditLogs: (workspaceId: string | null, filters: TGetAuditLogsFilter) => [{ workspaceId, filters }, "audit-logs"] as const, getAuditLogActorFilterOpts: (workspaceId: string) => [{ workspaceId }, "audit-log-actor-filters"] as const }; -export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string | null) => { +export const useGetAuditLogs = ( + filters: TGetAuditLogsFilter, + projectId: string | null, + options: Omit< + UseInfiniteQueryOptions< + AuditLog[], + unknown, + AuditLog[], + AuditLog[], + ReturnType + >, + "queryFn" | "queryKey" | "getNextPageParam" + > = {} +) => { return useInfiniteQuery({ - queryKey: auditLogKeys.getAuditLogs(workspaceId, filters), + queryKey: auditLogKeys.getAuditLogs(projectId, filters), queryFn: async ({ pageParam }) => { - const auditLogEndpoint = workspaceId - ? `/api/v1/workspace/${workspaceId}/audit-logs` - : "/api/v1/organization/audit-logs"; - const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>(auditLogEndpoint, { - params: { - ...filters, - offset: pageParam, - startDate: filters?.startDate?.toISOString(), - endDate: filters?.endDate?.toISOString() + const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>( + "/api/v1/organization/audit-logs", + { + params: { + ...filters, + offset: pageParam, + startDate: filters?.startDate?.toISOString(), + endDate: filters?.endDate?.toISOString(), + ...(filters.eventMetadata && Object.keys(filters.eventMetadata).length + ? { + eventMetadata: Object.entries(filters.eventMetadata) + .map(([key, value]) => `${key}=${value}`) + .join(",") + } + : {}), + ...(filters.eventType?.length ? { eventType: filters.eventType.join(",") } : {}), + ...(projectId ? { projectId } : {}) + } } - }); + ); return data.auditLogs; }, getNextPageParam: (lastPage, pages) => - lastPage.length !== 0 ? pages.length * filters.limit : undefined + lastPage.length !== 0 ? pages.length * filters.limit : undefined, + ...options }); }; diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 890327e0e..fc616321a 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -3,6 +3,17 @@ import { IdentityTrustedIp } from "../identities/types"; import { PkiItemType } from "../pkiCollections/constants"; import { ActorType, EventType, UserAgentType } from "./enums"; +export type TGetAuditLogsFilter = { + eventType?: EventType[]; + userAgentType?: UserAgentType; + eventMetadata?: Record; + actorType?: ActorType; + actorId?: string; // user ID format + startDate?: Date; + endDate?: Date; + limit: number; +}; + interface UserActorMetadata { userId: string; email: string; @@ -33,7 +44,13 @@ export interface IdentityActor { metadata: IdentityActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor; +export interface PlatformActorMetadata {} +export interface PlatformActor { + type: ActorType.PLATFORM; + metadata: PlatformActorMetadata; +} + +export type Actor = UserActor | ServiceActor | IdentityActor | PlatformActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -761,6 +778,22 @@ interface GetProjectSlackConfig { }; } +export enum IntegrationSyncedEventTrigger { + MANUAL = "manual", + AUTO = "auto" +} + +interface IntegrationSyncedEvent { + type: EventType.INTEGRATION_SYNCED; + metadata: { + integrationId: string; + lastSyncJobId: string; + lastUsed: Date; + syncMessage: string; + isSynced: boolean; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -838,7 +871,8 @@ export type Event = | CreateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig | UpdateProjectSlackConfig - | GetProjectSlackConfig; + | GetProjectSlackConfig + | IntegrationSyncedEvent; export type AuditLog = { id: string; @@ -856,12 +890,3 @@ export type AuditLog = { slug: string; }; }; - -export type AuditLogFilters = { - eventType?: EventType; - userAgentType?: UserAgentType; - actor?: string; - limit: number; - startDate?: Date; - endDate?: Date; -}; diff --git a/frontend/src/hooks/api/integrations/index.tsx b/frontend/src/hooks/api/integrations/index.tsx index f91d85644..9d43c33ad 100644 --- a/frontend/src/hooks/api/integrations/index.tsx +++ b/frontend/src/hooks/api/integrations/index.tsx @@ -1 +1,6 @@ -export { useCreateIntegration, useDeleteIntegration, useGetCloudIntegrations } from "./queries"; +export { + useCreateIntegration, + useDeleteIntegration, + useGetCloudIntegrations, + useGetIntegration +} from "./queries"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index f07e33e60..56131f641 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -1,13 +1,14 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query"; import { createNotification } from "@app/components/notifications"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; -import { TCloudIntegration } from "./types"; +import { TCloudIntegration, TIntegrationWithEnv } from "./types"; export const integrationQueryKeys = { - getIntegrations: () => ["integrations"] as const + getIntegrations: () => ["integrations"] as const, + getIntegration: (id: string) => ["integration", id] as const }; const fetchIntegrations = async () => { @@ -18,6 +19,14 @@ const fetchIntegrations = async () => { return data.integrationOptions; }; +const fetchIntegration = async (id: string) => { + const { data } = await apiRequest.get<{ integration: TIntegrationWithEnv }>( + `/api/v1/integration/${id}` + ); + + return data.integration; +}; + export const useGetCloudIntegrations = () => useQuery({ queryKey: integrationQueryKeys.getIntegrations(), @@ -128,6 +137,26 @@ export const useDeleteIntegration = () => { }); }; +export const useGetIntegration = ( + integrationId: string, + options?: Omit< + UseQueryOptions< + TIntegrationWithEnv, + unknown, + TIntegrationWithEnv, + ReturnType + >, + "queryFn" | "queryKey" + > +) => { + return useQuery({ + ...options, + enabled: Boolean(integrationId && options?.enabled === undefined ? true : options?.enabled), + queryKey: integrationQueryKeys.getIntegration(integrationId), + queryFn: () => fetchIntegration(integrationId) + }); +}; + export const useSyncIntegration = () => { return useMutation<{}, {}, { id: string; workspaceId: string; lastUsed: string }>({ mutationFn: ({ id }) => apiRequest.post(`/api/v1/integration/${id}/sync`), diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index f8c7ce244..1a434b497 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -36,14 +36,34 @@ export type TIntegration = { metadata?: { githubVisibility?: string; githubVisibilityRepoIds?: string[]; + shouldAutoRedeploy?: boolean; + secretAWSTag?: { + key: string; + value: string; + }[]; + kmsKeyId?: string; secretSuffix?: string; + secretPrefix?: string; syncBehavior?: IntegrationSyncBehavior; mappingBehavior?: IntegrationMappingBehavior; scope: string; org: string; project: string; environment: string; + + shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; + }; +}; + +export type TIntegrationWithEnv = TIntegration & { + environment: { + id: string; + name: string; + slug: string; }; }; diff --git a/frontend/src/pages/integrations/details/[integrationId].tsx b/frontend/src/pages/integrations/details/[integrationId].tsx new file mode 100644 index 000000000..f0db57472 --- /dev/null +++ b/frontend/src/pages/integrations/details/[integrationId].tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IntegrationDetailsPage } from "@app/views/IntegrationsPage/IntegrationDetailsPage"; + +export default function IntegrationsDetailsPage() { + const { t } = useTranslation(); + + return ( + <> + + Integration Details | Infisical + + + + + + + + ); +} + +IntegrationsDetailsPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/IntegrationDetailsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/IntegrationDetailsPage.tsx new file mode 100644 index 000000000..6fe2a1562 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/IntegrationDetailsPage.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { integrationSlugNameMapping } from "public/data/frequentConstants"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useUser, + useWorkspace +} from "@app/context"; +import { useGetIntegration } from "@app/hooks/api"; +import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; + +import { IntegrationAuditLogsSection } from "./components/IntegrationAuditLogsSection"; +import { IntegrationConnectionSection } from "./components/IntegrationConnectionSection"; +import { IntegrationDetailsSection } from "./components/IntegrationDetailsSection"; +import { IntegrationSettingsSection } from "./components/IntegrationSettingsSection"; + +export const IntegrationDetailsPage = () => { + const router = useRouter(); + const integrationId = router.query.integrationId as string; + + const { data: integration } = useGetIntegration(integrationId, { + refetchInterval: 4000 + }); + + const projectId = useWorkspace().currentWorkspace?.id; + const { mutateAsync: syncIntegration } = useSyncIntegration(); + const { currentOrg } = useOrganization(); + + return integration ? ( +
+
+ +
+

+ {integrationSlugNameMapping[integration.integration]} Integration +

+ + +
+ + + +
+
+ + { + await syncIntegration({ + id: integration.id, + lastUsed: integration.lastUsed!, + workspaceId: projectId! + }); + }} + > +
+ + Manually Sync +
+
+ + {(isAllowed) => ( + {}} + disabled={!isAllowed} + > +
+ + Delete Integration +
+
+ )} +
+
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ) : null; +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationAuditLogsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationAuditLogsSection.tsx new file mode 100644 index 000000000..c90f5fef7 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationAuditLogsSection.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; + +import { EmptyState } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { EventType } from "@app/hooks/api/auditLogs/enums"; +import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; +import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; + +// Add more events if needed +const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED]; + +type Props = { + integration: TIntegrationWithEnv; + orgId: string; +}; + +export const IntegrationAuditLogsSection = ({ integration, orgId }: Props) => { + const { subscription, isLoading } = useSubscription(); + + const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30; + + // eslint-disable-next-line no-nested-ternary + return subscription?.auditLogs ? ( +
+
+

Integration Logs

+

+ Displaying audit logs from the last {auditLogsRetentionDays} days +

+
+ +
+ ) : !isLoading ? ( +
+
+

Integration Logs

+
+ +

+ Please{" "} + + + upgrade your subscription + + {" "} + to view integration logs +

+
+ } + /> + + ) : null; +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx new file mode 100644 index 000000000..7aa862593 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx @@ -0,0 +1,194 @@ +import { integrationSlugNameMapping } from "public/data/frequentConstants"; + +import { FormLabel } from "@app/components/v2"; +import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; + +type Props = { + integration: TIntegrationWithEnv; +}; + +export const IntegrationConnectionSection = ({ integration }: Props) => { + const specifcQoveryDetails = () => { + if (integration.integration !== "qovery") return null; + + return ( +
+
+ +
{integration?.owner || "-"}
+
+
+ +
{integration?.targetService || "-"}
+
+
+ +
{integration?.targetEnvironment || "-"}
+
+
+ ); + }; + + const isNotAwsManagerOneToOneDetails = () => { + const isAwsSecretManagerOneToOne = + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE; + + if (isAwsSecretManagerOneToOne) { + return null; + } + + const formLabel = () => { + switch (integration.integration) { + case "qovery": + return integration.scope; + case "circleci": + case "terraform-cloud": + return "Project"; + case "aws-secret-manager": + return "Secret"; + case "aws-parameter-store": + case "rundeck": + return "Path"; + case "github": + if (["github-env", "github-repo"].includes(integration.scope!)) { + return "Repository"; + } + return "Organization"; + + default: + return "App"; + } + }; + + const contents = () => { + switch (integration.integration) { + case "hashicorp-vault": + return `${integration.app} - path: ${integration.path}`; + case "github": + if (integration.scope === "github-org") { + return `${integration.owner}`; + } + return `${integration.owner}/${integration.app}`; + + case "aws-parameter-store": + case "rundeck": + return `${integration.path}`; + + default: + return `${integration.app}`; + } + }; + + return ( +
+ +
{contents()}
+
+ ); + }; + + const targetEnvironmentDetails = () => { + if ( + ["vercel", "netlify", "railway", "gitlab", "teamcity", "bitbucket"].includes( + integration.integration + ) || + (integration.integration === "github" && integration.scope === "github-env") + ) { + return ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ ); + } + + return null; + }; + + const generalIntegrationSpecificDetails = () => { + if (integration.integration === "checkly" && integration.targetService) { + return ( +
+ +
{integration.targetService}
+
+ ); + } + + if (integration.integration === "circleci" && integration.owner) { + return ( +
+ +
{integration.owner}
+
+ ); + } + + if (integration.integration === "terraform-cloud" && integration.targetService) { + return ( +
+ +
{integration.targetService}
+
+ ); + } + + if (integration.integration === "checkly" || integration.integration === "github") { + return ( +
+ +
+ {integration?.metadata?.secretSuffix || "-"} +
+
+ ); + } + + return null; + }; + + return ( +
+
+

Connection

+
+ +
+ + +
+
+ +
{integration.environment.name}
+
+
+ +
{integration.secretPath}
+
+
+ + +
+ +
+ {integrationSlugNameMapping[integration.integration]} +
+ + {specifcQoveryDetails()} + {isNotAwsManagerOneToOneDetails()} + {targetEnvironmentDetails()} + {generalIntegrationSpecificDetails()} +
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationDetailsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationDetailsSection.tsx new file mode 100644 index 000000000..d2ffeb906 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationDetailsSection.tsx @@ -0,0 +1,69 @@ +import { faCalendarCheck, faCheckCircle, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { integrationSlugNameMapping } from "public/data/frequentConstants"; +import { twMerge } from "tailwind-merge"; + +import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; + +type Props = { + integration: TIntegrationWithEnv; +}; + +export const IntegrationDetailsSection = ({ integration }: Props) => { + return ( +
+
+
+

Integration Details

+
+
+
+
+

Name

+

+ {integrationSlugNameMapping[integration.integration]} +

+
+
+

Sync Status

+
+

+ {integration.isSynced ? "Synced" : "Not Synced"} +

+ +
+
+ {integration.lastUsed && ( +
+

Latest Successful Sync

+
+ {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} + +
+
+ )} + +
+ {!integration.isSynced && integration.syncMessage && ( + <> +

Latest Sync Error

+

{integration.syncMessage}

+ + )} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx new file mode 100644 index 000000000..50c638b66 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx @@ -0,0 +1,89 @@ +import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; + +type Props = { + integration: TIntegrationWithEnv; +}; + +type Metadata = NonNullable; +type MetadataKey = keyof Metadata; +type MetadataValue = Metadata[K]; + +const metadataMappings: Record, string> = { + githubVisibility: "Github Visibility", + githubVisibilityRepoIds: "Github Visibility Repo Ids", + shouldAutoRedeploy: "Auto Redeploy Target Application When Secrets Change", + secretAWSTag: "Tags For Secrets Stored In AWS", + kmsKeyId: "AWS KMS Key ID", + secretSuffix: "Secret Suffix", + secretPrefix: "Secret Prefix", + syncBehavior: "Secrets Sync behavior", + mappingBehavior: "Secrets Mapping Behavior", + scope: "Scope", + org: "Organization", + project: "Project", + environment: "Environment", + shouldDisableDelete: "AWS Secret Deletion Disabled", + shouldMaskSecrets: "GitLab Secrets Masking Enabled", + shouldProtectSecrets: "GitLab Secret Protection Enabled", + shouldEnableDelete: "GitHub Secret Deletion Enabled" +} as const; + +export const IntegrationSettingsSection = ({ integration }: Props) => { + const renderValue = (key: K, value: MetadataValue) => { + if (!value) return null; + + // If it's a boolean, we render a generic "Yes" or "No" response. + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + // When the value is an object or array, or array of objects, we need to handle some special cases. + if (typeof value === "object") { + if (key === "secretAWSTag") { + return (value as MetadataValue<"secretAWSTag">)!.map(({ key: tagKey, value: tagValue }) => ( +

+ {tagKey}={tagValue} +

+ )); + } + + if (key === "githubVisibilityRepoIds") { + return value.join(", "); + } + } + + if (typeof value === "string") { + return value.length ? value : "N/A"; + } + + if (typeof value === "number") { + return value; + } + + return null; + }; + + if (!integration.metadata || Object.keys(integration.metadata).length === 0) { + return null; + } + + // eslint-disable-next-line no-nested-ternary + return ( +
+
+

Integration Settings

+
+
+ {integration.metadata && + Object.entries(integration.metadata).map(([key, value]) => ( +
+

+ {metadataMappings[key as keyof typeof metadataMappings]} +

+

{renderValue(key as MetadataKey, value)}

+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/index.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/index.tsx new file mode 100644 index 000000000..145a6e2e0 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/index.tsx @@ -0,0 +1 @@ +export { IntegrationDetailsPage } from "./IntegrationDetailsPage"; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx index 8558eb36a..29901e35a 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx @@ -1,6 +1,10 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { useRouter } from "next/router"; import { faArrowRight, faCalendarCheck, + faEllipsis, faRefresh, faWarning, faXmark @@ -10,7 +14,7 @@ import { format } from "date-fns"; import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, FormLabel, IconButton, Tag, Tooltip } from "@app/components/v2"; +import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; @@ -28,9 +32,12 @@ export const ConfiguredIntegrationItem = ({ onRemoveIntegration, onManualSyncIntegration }: IProps) => { + const router = useRouter(); + return (
router.push(`/integrations/details/${integration.id}`)} key={`integration-${integration?.id.toString()}`} >
@@ -168,9 +175,9 @@ export const ConfiguredIntegrationItem = ({
)}
-
+
{integration.isSynced != null && integration.lastUsed != null && ( - +
-
Last sync
+
Last successful sync
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} @@ -195,43 +202,62 @@ export const ConfiguredIntegrationItem = ({
} > -
+
{integration.isSynced ? "Synced" : "Not synced"}
{!integration.isSynced && }
- + )} -
+
- + + -
- - {(isAllowed: boolean) => ( -
+ + {(isAllowed: boolean) => ( onRemoveIntegration()} + onClick={(e) => { + e.stopPropagation(); + onRemoveIntegration(); + }} ariaLabel="delete" isDisabled={!isAllowed} colorSchema="danger" variant="star" + className="max-w-[2.5rem] border-none bg-mineshaft-500" > - + -
- )} -
+ )} + + + + + + + +
); diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx index dcfd0553c..9ed698bb1 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -42,7 +42,9 @@ export const UserAuditLogsSection = withPermission(
diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index a9f10875c..619b0503a 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -1,14 +1,29 @@ +/* eslint-disable no-nested-ternary */ import { useState } from "react"; -import { Control, Controller, UseFormReset } from "react-hook-form"; -import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { Control, Controller, UseFormReset, UseFormWatch } from "react-hook-form"; +import { + faCheckCircle, + faChevronDown, + faFilterCircleXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button, DatePicker, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + Button, + DatePicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Select, + SelectItem +} from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useGetAuditLogActorFilterOpts } from "@app/hooks/api"; import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants"; -import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums"; import { Actor } from "@app/hooks/api/auditLogs/types"; import { AuditLogFilterFormData } from "./types"; @@ -20,13 +35,17 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label })); type Props = { - presetActor?: string; + presets?: { + actorId?: string; + eventType?: EventType[]; + }; className?: string; control: Control; reset: UseFormReset; + watch: UseFormWatch; }; -export const LogsFilter = ({ presetActor, className, control, reset }: Props) => { +export const LogsFilter = ({ presets, className, control, reset, watch }: Props) => { const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); @@ -71,6 +90,8 @@ export const LogsFilter = ({ presetActor, className, control, reset }: Props) => } }; + const selectedEventTypes = watch("eventType") as EventType[] | undefined; + return (
( - - + render={({ field }) => ( + + + +
+ {selectedEventTypes?.length === 1 + ? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0]) + ?.label + : selectedEventTypes?.length === 0 + ? "Select event types" + : `${selectedEventTypes?.length} events selected`} + +
+
+ +
+ {eventTypes && eventTypes.length > 0 ? ( + eventTypes.map((eventType) => { + const isSelected = selectedEventTypes?.includes( + eventType.value as EventType + ); + + return ( + eventTypes.length > 1 && event.preventDefault()} + onClick={() => { + if (selectedEventTypes?.includes(eventType.value as EventType)) { + field.onChange( + selectedEventTypes?.filter((e: string) => e !== eventType.value) + ); + } else { + field.onChange([...(selectedEventTypes || []), eventType.value]); + } + }} + key={`event-type-${eventType.value}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {eventType.label} + + ); + }) + ) : ( +
+ )} +
+ + )} /> - {!isLoading && data && data.length > 0 && !presetActor && ( + + {!isLoading && data && data.length > 0 && !presets?.actorId && ( leftIcon={} onClick={() => reset({ - eventType: undefined, - actor: presetActor, + eventType: presets?.eventType || [], + actor: presets?.actorId, userAgentType: undefined, startDate: undefined, endDate: undefined diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx index aeb12468d..68fd354e3 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx @@ -5,24 +5,38 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { UpgradePlanModal } from "@app/components/v2"; import { useSubscription } from "@app/context"; -import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; +import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { usePopUp } from "@app/hooks/usePopUp"; import { LogsFilter } from "./LogsFilter"; -import { LogsTable } from "./LogsTable"; +import { LogsTable, TAuditLogTableHeader } from "./LogsTable"; import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types"; type Props = { - presetActor?: string; + presets?: { + actorId?: string; + eventType?: EventType[]; + actorType?: ActorType; + startDate?: Date; + endDate?: Date; + eventMetadata?: Record; + }; + showFilters?: boolean; filterClassName?: string; isOrgAuditLogs?: boolean; + showActorColumn?: boolean; + remappedHeaders?: Partial>; + refetchInterval?: number; }; export const LogsSection = ({ - presetActor, + presets, filterClassName, + remappedHeaders, isOrgAuditLogs, + showActorColumn, + refetchInterval, showFilters }: Props) => { const { subscription } = useSubscription(); @@ -33,11 +47,12 @@ export const LogsSection = ({ const { control, reset, watch } = useForm({ resolver: yupResolver(auditLogFilterFormSchema), defaultValues: { - actor: presetActor, + actor: presets?.actorId, + eventType: presets?.eventType || [], page: 1, perPage: 10, - startDate: new Date(new Date().setDate(new Date().getDate() - 1)), // day before today - endDate: new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today + startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today + endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today } }); @@ -47,7 +62,7 @@ export const LogsSection = ({ } }, [subscription]); - const eventType = watch("eventType") as EventType | undefined; + const eventType = watch("eventType") as EventType[] | undefined; const userAgentType = watch("userAgentType") as UserAgentType | undefined; const actor = watch("actor"); @@ -59,19 +74,27 @@ export const LogsSection = ({ {showFilters && ( )} >; + refetchInterval?: number; }; const AUDIT_LOG_LIMIT = 15; +const TABLE_HEADERS = ["Timestamp", "Event", "Project", "Actor", "Source", "Metadata"] as const; +export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number]; + export const LogsTable = ({ - eventType, - userAgentType, showActorColumn, - actor, - startDate, - endDate, - isOrgAuditLogs + isOrgAuditLogs, + filter, + remappedHeaders, + refetchInterval }: Props) => { const { currentWorkspace } = useWorkspace(); const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs( { - eventType, - userAgentType, - actor, - startDate, - endDate, + ...filter, limit: AUDIT_LOG_LIMIT }, - !isOrgAuditLogs ? currentWorkspace?.id ?? "" : null + !isOrgAuditLogs ? currentWorkspace?.id ?? "" : null, + { + refetchInterval + } ); const isEmpty = !isLoading && !data?.pages?.[0].length; @@ -62,18 +60,24 @@ export const LogsTable = ({ - - - {isOrgAuditLogs && } - {showActorColumn && } - - + {TABLE_HEADERS.map((header, idx) => { + if ( + (header === "Project" && !isOrgAuditLogs) || + (header === "Actor" && !showActorColumn) + ) { + return null; + } + + return ( + + ); + })} {!isLoading && data?.pages?.map((group, i) => ( - + {group.map((auditLog) => ( {`Secret Request Channels: ${event.metadata.secretRequestChannels}`}

); + + case EventType.INTEGRATION_SYNCED: + return ( +
+ ); default: return + ); + } + + // Platform / automatic syncs + return ( + + ); + } + + return ( + + ); + }; + return ( {isOrgAuditLogs && } {showActorColumn && renderActor(auditLog.actor)} - + {renderSource()} {renderMetadata(auditLog.event)} ); diff --git a/frontend/src/views/Project/AuditLogsPage/components/types.tsx b/frontend/src/views/Project/AuditLogsPage/components/types.tsx index 73d0aef47..f1165c80f 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/types.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/types.tsx @@ -4,7 +4,8 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; export const auditLogFilterFormSchema = yup .object({ - eventType: yup.string().oneOf(Object.values(EventType), "Invalid event type"), + eventMetadata: yup.object({}).optional(), + eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")), actor: yup.string(), userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"), startDate: yup.date(),
TimestampEventProjectActorSourceMetadata{remappedHeaders?.[header] || header}
+ + +

{event.metadata.isSynced ? "Successful" : "Failed"}

+
+
+
; } @@ -484,16 +499,41 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop return formattedDate; }; + const renderSource = () => { + const { event, actor } = auditLog; + + if (event.type === EventType.INTEGRATION_SYNCED) { + if (actor.type === ActorType.USER) { + return ( + +

Manually triggered by {actor.metadata.email}

+
+

Automatically synced by Infisical

+
+

{userAgentTTypeoNameMap[auditLog.userAgentType]}

+

{auditLog.ipAddress}

+
{formatDate(auditLog.createdAt)} {`${eventToNameMap[auditLog.event.type]}`}{auditLog.project.name} -

{userAgentTTypeoNameMap[auditLog.userAgentType]}

-

{auditLog.ipAddress}

-