diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a4c3eea7b..b3e9d9952 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -3,6 +3,7 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-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 { TGroupServiceFactory } from "@app/ee/services/group/group-service"; @@ -120,6 +121,7 @@ declare module "fastify" { scim: TScimServiceFactory; ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; + auditLogStream: TAuditLogStreamServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 8845c1d01..a7d76e944 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -7,6 +7,9 @@ import { TApiKeysUpdate, TAuditLogs, TAuditLogsInsert, + TAuditLogStreams, + TAuditLogStreamsInsert, + TAuditLogStreamsUpdate, TAuditLogsUpdate, TAuthTokens, TAuthTokenSessions, @@ -404,6 +407,11 @@ declare module "knex/types/tables" { [TableName.LdapGroupMap]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; [TableName.AuditLog]: Knex.CompositeTableType; + [TableName.AuditLogStream]: Knex.CompositeTableType< + TAuditLogStreams, + TAuditLogStreamsInsert, + TAuditLogStreamsUpdate + >; [TableName.GitAppInstallSession]: Knex.CompositeTableType< TGitAppInstallSessions, TGitAppInstallSessionsInsert, diff --git a/backend/src/db/migrations/20240503101144_audit-log-stream.ts b/backend/src/db/migrations/20240503101144_audit-log-stream.ts new file mode 100644 index 000000000..210ee1bfa --- /dev/null +++ b/backend/src/db/migrations/20240503101144_audit-log-stream.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AuditLogStream))) { + await knex.schema.createTable(TableName.AuditLogStream, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("url").notNullable(); + t.text("encryptedHeadersCiphertext"); + t.text("encryptedHeadersIV"); + t.text("encryptedHeadersTag"); + t.string("encryptedHeadersAlgorithm"); + t.string("encryptedHeadersKeyEncoding"); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.AuditLogStream); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.AuditLogStream); + await knex.schema.dropTableIfExists(TableName.AuditLogStream); +} diff --git a/backend/src/db/schemas/audit-log-streams.ts b/backend/src/db/schemas/audit-log-streams.ts new file mode 100644 index 000000000..901dd8d27 --- /dev/null +++ b/backend/src/db/schemas/audit-log-streams.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AuditLogStreamsSchema = z.object({ + id: z.string().uuid(), + url: z.string(), + encryptedHeadersCiphertext: z.string().nullable().optional(), + encryptedHeadersIV: z.string().nullable().optional(), + encryptedHeadersTag: z.string().nullable().optional(), + encryptedHeadersAlgorithm: z.string().nullable().optional(), + encryptedHeadersKeyEncoding: z.string().nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAuditLogStreams = z.infer; +export type TAuditLogStreamsInsert = Omit, TImmutableDBKeys>; +export type TAuditLogStreamsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 30d6208b8..0eb9b1986 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,4 +1,5 @@ export * from "./api-keys"; +export * from "./audit-log-streams"; export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ea70dccdb..3baa7f40d 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -62,6 +62,7 @@ export enum TableName { LdapConfig = "ldap_configs", LdapGroupMap = "ldap_group_maps", AuditLog = "audit_logs", + AuditLogStream = "audit_log_streams", GitAppInstallSession = "git_app_install_sessions", GitAppOrg = "git_app_org", SecretScanningGitRisk = "secret_scanning_git_risks", diff --git a/backend/src/ee/routes/v1/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-router.ts new file mode 100644 index 000000000..17bd9e64b --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-router.ts @@ -0,0 +1,215 @@ +import { z } from "zod"; + +import { AUDIT_LOG_STREAMS } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedAuditLogStreamSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "Create an Audit Log Stream.", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + url: z.string().min(1).describe(AUDIT_LOG_STREAMS.CREATE.url), + headers: z + .object({ + key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.key), + value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.value) + }) + .describe(AUDIT_LOG_STREAMS.CREATE.headers.desc) + .array() + .optional() + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + url: req.body.url, + headers: req.body.headers + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + description: "Update an Audit Log Stream by ID.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().describe(AUDIT_LOG_STREAMS.UPDATE.id) + }), + body: z.object({ + url: z.string().optional().describe(AUDIT_LOG_STREAMS.UPDATE.url), + headers: z + .object({ + key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.key), + value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.value) + }) + .describe(AUDIT_LOG_STREAMS.UPDATE.headers.desc) + .array() + .optional() + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.updateById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.id, + url: req.body.url, + headers: req.body.headers + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + description: "Delete an Audit Log Stream by ID.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().describe(AUDIT_LOG_STREAMS.DELETE.id) + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.deleteById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.id + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get an Audit Log Stream by ID.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().describe(AUDIT_LOG_STREAMS.GET_BY_ID.id) + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema.extend({ + headers: z + .object({ + key: z.string(), + value: z.string() + }) + .array() + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.getById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.id + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List Audit Log Streams.", + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: z.object({ + auditLogStreams: SanitizedAuditLogStreamSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStreams = await server.services.auditLogStream.list({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { auditLogStreams }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 6860098fd..cf325b2e3 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -55,6 +56,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerGroupRouter, { prefix: "/groups" }); + await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); await server.register( async (privilegeRouter) => { await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts new file mode 100644 index 000000000..436821ae9 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAuditLogStreamDALFactory = ReturnType; + +export const auditLogStreamDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.AuditLogStream); + + return orm; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts new file mode 100644 index 000000000..0e313b59b --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -0,0 +1,233 @@ +import { ForbiddenError } from "@casl/ability"; +import { RawAxiosRequestHeaders } from "axios"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { request } from "@app/lib/config/request"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { validateLocalIps } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../audit-log/audit-log-queue"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; +import { + LogStreamHeaders, + TCreateAuditLogStreamDTO, + TDeleteAuditLogStreamDTO, + TGetDetailsAuditLogStreamDTO, + TListAuditLogStreamDTO, + TUpdateAuditLogStreamDTO +} from "./audit-log-stream-types"; + +type TAuditLogStreamServiceFactoryDep = { + auditLogStreamDAL: TAuditLogStreamDALFactory; + permissionService: Pick; + licenseService: Pick; +}; + +export type TAuditLogStreamServiceFactory = ReturnType; + +export const auditLogStreamServiceFactory = ({ + auditLogStreamDAL, + permissionService, + licenseService +}: TAuditLogStreamServiceFactoryDep) => { + const create = async ({ + url, + actor, + headers = [], + actorId, + actorOrgId, + actorAuthMethod + }: TCreateAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.auditLogStreams) + throw new BadRequestError({ + message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." + }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + validateLocalIps(url); + + const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + if (totalStreams.length >= plan.auditLogStreamLimit) { + throw new BadRequestError({ + message: + "Failed to create audit log streams due to plan limit reached. Kindly contact Infisical to add more streams." + }); + } + + // testing connection first + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + await request + .post( + url, + { ping: "ok" }, + { + headers: streamHeaders, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); + }); + const encryptedHeaders = headers ? infisicalSymmetricEncypt(JSON.stringify(headers)) : undefined; + const logStream = await auditLogStreamDAL.create({ + orgId: actorOrgId, + url, + ...(encryptedHeaders + ? { + encryptedHeadersCiphertext: encryptedHeaders.ciphertext, + encryptedHeadersIV: encryptedHeaders.iv, + encryptedHeadersTag: encryptedHeaders.tag, + encryptedHeadersAlgorithm: encryptedHeaders.algorithm, + encryptedHeadersKeyEncoding: encryptedHeaders.encoding + } + : {}) + }); + return logStream; + }; + + const updateById = async ({ + id, + url, + actor, + headers = [], + actorId, + actorOrgId, + actorAuthMethod + }: TUpdateAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.auditLogStreams) + throw new BadRequestError({ + message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." + }); + + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + if (url) validateLocalIps(url); + + // testing connection first + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + + await request + .post( + url || logStream.url, + { ping: "ok" }, + { + headers: streamHeaders, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); + }); + + const encryptedHeaders = headers ? infisicalSymmetricEncypt(JSON.stringify(headers)) : undefined; + const updatedLogStream = await auditLogStreamDAL.updateById(id, { + url, + ...(encryptedHeaders + ? { + encryptedHeadersCiphertext: encryptedHeaders.ciphertext, + encryptedHeadersIV: encryptedHeaders.iv, + encryptedHeadersTag: encryptedHeaders.tag, + encryptedHeadersAlgorithm: encryptedHeaders.algorithm, + encryptedHeadersKeyEncoding: encryptedHeaders.encoding + } + : {}) + }); + return updatedLogStream; + }; + + const deleteById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TDeleteAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); + + const deletedLogStream = await auditLogStreamDAL.deleteById(id); + return deletedLogStream; + }; + + const getById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TGetDetailsAuditLogStreamDTO) => { + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const headers = + logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + tag: logStream.encryptedHeadersTag, + iv: logStream.encryptedHeadersIV, + ciphertext: logStream.encryptedHeadersCiphertext, + keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding + }) + ) as LogStreamHeaders[]) + : undefined; + + return { ...logStream, headers }; + }; + + const list = async ({ actor, actorId, actorOrgId, actorAuthMethod }: TListAuditLogStreamDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + return logStreams; + }; + + return { + create, + updateById, + deleteById, + getById, + list + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts new file mode 100644 index 000000000..3c22251d7 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts @@ -0,0 +1,27 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type LogStreamHeaders = { + key: string; + value: string; +}; + +export type TCreateAuditLogStreamDTO = Omit & { + url: string; + headers?: LogStreamHeaders[]; +}; + +export type TUpdateAuditLogStreamDTO = Omit & { + id: string; + url?: string; + headers?: LogStreamHeaders[]; +}; + +export type TDeleteAuditLogStreamDTO = Omit & { + id: string; +}; + +export type TListAuditLogStreamDTO = Omit; + +export type TGetDetailsAuditLogStreamDTO = Omit & { + id: string; +}; diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index afffd463d..6c563b573 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,13 +1,21 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { request } from "@app/lib/config/request"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TAuditLogStreamDALFactory } from "../audit-log-stream/audit-log-stream-dal"; +import { LogStreamHeaders } from "../audit-log-stream/audit-log-stream-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TCreateAuditLogDTO } from "./audit-log-types"; type TAuditLogQueueServiceFactoryDep = { auditLogDAL: TAuditLogDALFactory; + auditLogStreamDAL: Pick; queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; @@ -15,11 +23,15 @@ type TAuditLogQueueServiceFactoryDep = { export type TAuditLogQueueServiceFactory = ReturnType; +// keep this timeout 5s it must be fast because else the queue will take time to finish +// audit log is a crowded queue thus needs to be fast +export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000; export const auditLogQueueServiceFactory = ({ auditLogDAL, queueService, projectDAL, - licenseService + licenseService, + auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep) => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -47,7 +59,7 @@ export const auditLogQueueServiceFactory = ({ // skip inserting if audit log retention is 0 meaning its not supported if (ttl === 0) return; - await auditLogDAL.create({ + const auditLog = await auditLogDAL.create({ actor: actor.type, actorMetadata: actor.metadata, userAgent, @@ -59,6 +71,46 @@ export const auditLogQueueServiceFactory = ({ eventMetadata: event.metadata, userAgentType }); + + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; + + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + } + ) + ); }); queueService.start(QueueName.AuditLogPrune, async () => { diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8a4de57f1..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,6 +24,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, + auditLogStreams: false, + auditLogStreamLimit: 3, samlSSO: false, scim: false, ldap: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 1cea39a83..a2379ddaa 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -40,6 +40,8 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; + auditLogStreams: false; + auditLogStreamLimit: 3; samlSSO: false; scim: false; ldap: false; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 04b7509ca..39728ba7b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -614,3 +614,29 @@ export const INTEGRATION = { integrationId: "The ID of the integration object." } }; + +export const AUDIT_LOG_STREAMS = { + CREATE: { + url: "The HTTP URL to push logs to.", + headers: { + desc: "The HTTP headers attached for the external prrovider requests.", + key: "The HTTP header key name.", + value: "The HTTP header value." + } + }, + UPDATE: { + id: "The ID of the audit log stream to update.", + url: "The HTTP URL to push logs to.", + headers: { + desc: "The HTTP headers attached for the external prrovider requests.", + key: "The HTTP header key name.", + value: "The HTTP header value." + } + }, + DELETE: { + id: "The ID of the audit log stream to delete." + }, + GET_BY_ID: { + id: "The ID of the audit log stream to get details." + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 4d3d55ffd..f9bbb1b46 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -119,6 +119,7 @@ const envSchema = z }) .transform((data) => ({ ...data, + isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), isRedisConfigured: Boolean(data.REDIS_URL), isDevelopmentMode: data.NODE_ENV === "development", diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index b3b46e739..2c41f4d23 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -17,7 +17,7 @@ export type TOrgPermission = { actorId: string; orgId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }; export type TProjectPermission = { diff --git a/backend/src/lib/validator/index.ts b/backend/src/lib/validator/index.ts index 6bc415680..6a70d8571 100644 --- a/backend/src/lib/validator/index.ts +++ b/backend/src/lib/validator/index.ts @@ -1 +1,2 @@ export { isDisposableEmail } from "./validate-email"; +export { validateLocalIps } from "./validate-url"; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts new file mode 100644 index 000000000..9a953be1a --- /dev/null +++ b/backend/src/lib/validator/validate-url.ts @@ -0,0 +1,18 @@ +import { getConfig } from "../config/env"; +import { BadRequestError } from "../errors"; + +export const validateLocalIps = (url: string) => { + const validUrl = new URL(url); + const appCfg = getConfig(); + // on cloud local ips are not allowed + if ( + appCfg.isCloud && + (validUrl.host === "host.docker.internal" || + validUrl.host.match(/^10\.\d+\.\d+\.\d+/) || + validUrl.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Local IPs not allowed as URL" }); + + if (validUrl.host === "localhost" || validUrl.host === "127.0.0.1") + throw new BadRequestError({ message: "Localhost not allowed" }); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4cb56a222..25e807cc2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -5,6 +5,8 @@ import { registerV1EERoutes } from "@app/ee/routes/v1"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; +import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; @@ -193,6 +195,7 @@ export const registerRoutes = async ( const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const auditLogDAL = auditLogDALFactory(db); + const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); @@ -243,9 +246,15 @@ export const registerRoutes = async ( auditLogDAL, queueService, projectDAL, - licenseService + licenseService, + auditLogStreamDAL }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); + const auditLogStreamService = auditLogStreamServiceFactory({ + licenseService, + permissionService, + auditLogStreamDAL + }); const sapService = secretApprovalPolicyServiceFactory({ projectMembershipDAL, projectEnvDAL, @@ -715,6 +724,7 @@ export const registerRoutes = async ( saml: samlService, ldap: ldapService, auditLog: auditLogService, + auditLogStream: auditLogStreamService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index eaae4149c..a0b792789 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -69,3 +69,10 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ keyEncoding: true, algorithm: true }); + +export const SanitizedAuditLogStreamSchema = z.object({ + id: z.string(), + url: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); diff --git a/docs/documentation/platform/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams.mdx new file mode 100644 index 000000000..2a69780bc --- /dev/null +++ b/docs/documentation/platform/audit-log-streams.mdx @@ -0,0 +1,82 @@ +--- +title: "Audit Log Streams" +description: "Learn how to stream Infisical Audit Logs to external logging providers." +--- + + + Audit log streams is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. + +The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing. + + +## Overview + + + + + ![stream create](../../images/platform/audit-log-streams/stream-create.png) + + + ![stream create](../../images/platform/audit-log-streams/stream-inputs.png) + + Provide the following values + + The HTTPS endpoint URL of the logging provider that collects the JSON stream. + + + The HTTP headers for the logging provider for identification and authentication. + + + + +![stream listt](../../images/platform/audit-log-streams/stream-list.png) +Your Audit Logs are now ready to be streamed. + +## Example Providers + +### Better Stack + + + + ![better stack connect source](../../images/platform/audit-log-streams/betterstack-create-source.png) + + + + ![better stack connect](../../images/platform/audit-log-streams/betterstack-source-details.png) + + 1. Copy the **endpoint** from Better Stack to the **Endpoint URL** field. + 3. Create a new header with key **Authorization** and set the value as **Bearer \**. + + + +### Datadog + + + + ![api key create](../../images/platform/audit-log-streams/datadog-api-sidebar.png) + + + ![api key form](../../images/platform/audit-log-streams/data-create-api-key.png) + ![api key form](../../images/platform/audit-log-streams/data-dog-api-key.png) + + + ![datadog url](../../images/platform/audit-log-streams/datadog-logging-endpoint.png) + + 1. Navigate to the [Datadog Send Logs API documentation](https://docs.datadoghq.com/api/latest/logs/?code-lang=curl&site=us5#send-logs). + 2. Pick your Datadog account region. + 3. Obtain your Datadog logging endpoint URL. + + + ![datadog api key details](../../images/platform/audit-log-streams/datadog-source-details.png) + + 1. Copy the **logging endpoint** from Datadog to the **Endpoint URL** field. + 2. Copy the **API Key** from previous step + 3. Create a new header with key **DD-API-KEY** and set the value as **API Key**. + + diff --git a/docs/images/platform/audit-log-streams/betterstack-create-source.png b/docs/images/platform/audit-log-streams/betterstack-create-source.png new file mode 100644 index 000000000..bee4513ea Binary files /dev/null and b/docs/images/platform/audit-log-streams/betterstack-create-source.png differ diff --git a/docs/images/platform/audit-log-streams/betterstack-source-details.png b/docs/images/platform/audit-log-streams/betterstack-source-details.png new file mode 100644 index 000000000..d67980ae8 Binary files /dev/null and b/docs/images/platform/audit-log-streams/betterstack-source-details.png differ diff --git a/docs/images/platform/audit-log-streams/data-create-api-key.png b/docs/images/platform/audit-log-streams/data-create-api-key.png new file mode 100644 index 000000000..d25a2c64e Binary files /dev/null and b/docs/images/platform/audit-log-streams/data-create-api-key.png differ diff --git a/docs/images/platform/audit-log-streams/data-dog-api-key.png b/docs/images/platform/audit-log-streams/data-dog-api-key.png new file mode 100644 index 000000000..8e49e89e7 Binary files /dev/null and b/docs/images/platform/audit-log-streams/data-dog-api-key.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-api-sidebar.png b/docs/images/platform/audit-log-streams/datadog-api-sidebar.png new file mode 100644 index 000000000..d95cb9b2d Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-api-sidebar.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png new file mode 100644 index 000000000..7960b1145 Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-source-details.png b/docs/images/platform/audit-log-streams/datadog-source-details.png new file mode 100644 index 000000000..5ae25b0b3 Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-source-details.png differ diff --git a/docs/images/platform/audit-log-streams/stream-create.png b/docs/images/platform/audit-log-streams/stream-create.png new file mode 100644 index 000000000..949278e3d Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-create.png differ diff --git a/docs/images/platform/audit-log-streams/stream-inputs.png b/docs/images/platform/audit-log-streams/stream-inputs.png new file mode 100644 index 000000000..6b9d7c57b Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-inputs.png differ diff --git a/docs/images/platform/audit-log-streams/stream-list.png b/docs/images/platform/audit-log-streams/stream-list.png new file mode 100644 index 000000000..c5cc5598b Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-list.png differ diff --git a/docs/mint.json b/docs/mint.json index 293ec9713..0bb1ae9bf 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -143,7 +143,8 @@ "documentation/platform/dynamic-secrets/aws-iam" ] }, - "documentation/platform/groups" + "documentation/platform/groups", + "documentation/platform/audit-log-streams" ] }, { diff --git a/frontend/src/hooks/api/auditLogStreams/index.tsx b/frontend/src/hooks/api/auditLogStreams/index.tsx new file mode 100644 index 000000000..72b1fba1a --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateAuditLogStream, + useDeleteAuditLogStream, + useUpdateAuditLogStream +} from "./mutations"; +export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries"; diff --git a/frontend/src/hooks/api/auditLogStreams/mutations.tsx b/frontend/src/hooks/api/auditLogStreams/mutations.tsx new file mode 100644 index 000000000..2d99f57c4 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/mutations.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { auditLogStreamKeys } from "./queries"; +import { + TAuditLogStream, + TCreateAuditLogStreamDTO, + TDeleteAuditLogStreamDTO, + TUpdateAuditLogStreamDTO +} from "./types"; + +export const useCreateAuditLogStream = () => { + const queryClient = useQueryClient(); + + return useMutation<{ auditLogStream: TAuditLogStream }, {}, TCreateAuditLogStreamDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ auditLogStream: TAuditLogStream }>( + "/api/v1/audit-log-streams", + dto + ); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(auditLogStreamKeys.list(orgId)); + } + }); +}; + +export const useUpdateAuditLogStream = () => { + const queryClient = useQueryClient(); + + return useMutation<{ auditLogStream: TAuditLogStream }, {}, TUpdateAuditLogStreamDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch<{ auditLogStream: TAuditLogStream }>( + `/api/v1/audit-log-streams/${dto.id}`, + dto + ); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(auditLogStreamKeys.list(orgId)); + } + }); +}; + +export const useDeleteAuditLogStream = () => { + const queryClient = useQueryClient(); + + return useMutation<{ auditLogStream: TAuditLogStream }, {}, TDeleteAuditLogStreamDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete<{ auditLogStream: TAuditLogStream }>( + `/api/v1/audit-log-streams/${dto.id}` + ); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(auditLogStreamKeys.list(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/auditLogStreams/queries.tsx b/frontend/src/hooks/api/auditLogStreams/queries.tsx new file mode 100644 index 000000000..f86ca0dce --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TAuditLogStream } from "./types"; + +export const auditLogStreamKeys = { + list: (orgId: string) => ["audit-log-stream", { orgId }], + getById: (id: string) => ["audit-log-stream-details", { id }] +}; + +const fetchAuditLogStreams = async () => { + const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( + "/api/v1/audit-log-streams" + ); + + return data.auditLogStreams; +}; + +export const useGetAuditLogStreams = (orgId: string) => + useQuery({ + queryKey: auditLogStreamKeys.list(orgId), + queryFn: () => fetchAuditLogStreams(), + enabled: Boolean(orgId) + }); + +const fetchAuditLogStreamDetails = async (id: string) => { + const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStream }>( + `/api/v1/audit-log-streams/${id}` + ); + + return data.auditLogStream; +}; + +export const useGetAuditLogStreamDetails = (id: string) => + useQuery({ + queryKey: auditLogStreamKeys.getById(id), + queryFn: () => fetchAuditLogStreamDetails(id), + enabled: Boolean(id) + }); diff --git a/frontend/src/hooks/api/auditLogStreams/types.ts b/frontend/src/hooks/api/auditLogStreams/types.ts new file mode 100644 index 000000000..8e21a3209 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types.ts @@ -0,0 +1,28 @@ +export type LogStreamHeaders = { + key: string; + value: string; +}; + +export type TAuditLogStream = { + id: string; + url: string; + headers?: LogStreamHeaders[]; +}; + +export type TCreateAuditLogStreamDTO = { + url: string; + headers?: LogStreamHeaders[]; + orgId: string; +}; + +export type TUpdateAuditLogStreamDTO = { + id: string; + url?: string; + headers?: LogStreamHeaders[]; + orgId: string; +}; + +export type TDeleteAuditLogStreamDTO = { + id: string; + orgId: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index b2df27f2d..574da5a31 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,6 +1,7 @@ export * from "./admin"; export * from "./apiKeys"; export * from "./auditLogs"; +export * from "./auditLogStreams"; export * from "./auth"; export * from "./bots"; export * from "./dynamicSecret"; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 46facedd3..45414292d 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -5,6 +5,8 @@ export type SubscriptionPlan = { auditLogs: boolean; dynamicSecret: boolean; auditLogsRetentionDays: number; + auditLogStreamLimit: number; + auditLogStreams: boolean; customAlerts: boolean; customRateLimits: boolean; pitRecovery: boolean; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index d6b43d674..49949d88e 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,5 +1,6 @@ import { ZodIssue } from "zod"; +export type { TAuditLogStream } from "./auditLogStreams/types"; export type { GetAuthTokenAPI } from "./auth/types"; export type { IncidentContact } from "./incidentContacts/types"; export type { IntegrationAuth } from "./integrationAuth/types"; @@ -48,13 +49,13 @@ export enum ApiErrorTypes { export type TApiErrors = | { - error: ApiErrorTypes.ValidationError; - message: ZodIssue[]; - statusCode: 403; - } + error: ApiErrorTypes.ValidationError; + message: ZodIssue[]; + statusCode: 403; + } | { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 } | { - statusCode: 400; - message: string; - error: ApiErrorTypes.BadRequestError; - }; + statusCode: 400; + message: string; + error: ApiErrorTypes.BadRequestError; + }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx new file mode 100644 index 000000000..18bfa6340 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx @@ -0,0 +1,206 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, FormLabel, IconButton, Input, Spinner } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useCreateAuditLogStream, + useGetAuditLogStreamDetails, + useUpdateAuditLogStream +} from "@app/hooks/api"; + +type Props = { + id?: string; + onClose: () => void; +}; + +const formSchema = z.object({ + url: z.string().url().min(1), + headers: z + .object({ + key: z.string(), + value: z.string() + }) + .array() + .optional() +}); +type TForm = z.infer; + +export const AuditLogStreamForm = ({ id = "", onClose }: Props) => { + const isEdit = Boolean(id); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const auditLogStream = useGetAuditLogStreamDetails(id); + const createAuditLogStream = useCreateAuditLogStream(); + const updateAuditLogStream = useUpdateAuditLogStream(); + + const { + handleSubmit, + control, + setValue, + getValues, + formState: { isSubmitting } + } = useForm({ + values: auditLogStream?.data, + defaultValues: { + headers: [{ key: "", value: "" }] + } + }); + + const headerFields = useFieldArray({ + control, + name: "headers" + }); + + const handleAuditLogStreamEdit = async ({ headers, url }: TForm) => { + if (!id) return; + try { + await updateAuditLogStream.mutateAsync({ + id, + orgId, + headers, + url + }); + createNotification({ + type: "success", + text: "Successfully updated stream" + }); + onClose(); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update stream" + }); + } + }; + + const handleFormSubmit = async ({ headers = [], url }: TForm) => { + if (isSubmitting) return; + const sanitizedHeaders = headers.filter(({ key, value }) => Boolean(key) && Boolean(value)); + const streamHeaders = sanitizedHeaders.length ? sanitizedHeaders : undefined; + if (isEdit) { + await handleAuditLogStreamEdit({ headers: streamHeaders, url }); + return; + } + try { + await createAuditLogStream.mutateAsync({ + orgId, + headers: streamHeaders, + url + }); + createNotification({ + type: "success", + text: "Successfully created stream" + }); + onClose(); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create stream" + }); + } + }; + + if (isEdit && auditLogStream.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ ( + + + + )} + /> + + {headerFields.fields.map(({ id: headerFieldId }, i) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + { + const header = getValues("headers"); + if (header && header?.length > 1) { + headerFields.remove(i); + } else { + setValue("headers", [{ key: "", value: "" }]); + } + }} + > + + +
+ ))} +
+ +
+
+
+ + +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx new file mode 100644 index 000000000..7590d4002 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx @@ -0,0 +1,197 @@ +import { faPlug, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + THead, + Tr, + UpgradePlanModal +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; +import { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { useDeleteAuditLogStream, useGetAuditLogStreams } from "@app/hooks/api"; + +import { AuditLogStreamForm } from "./AuditLogStreamForm"; + +export const AuditLogStreamsTab = withPermission( + () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "auditLogStreamForm", + "deleteAuditLogStream", + "upgradePlan" + ] as const); + const { subscription } = useSubscription(); + + const { data: auditLogStreams, isLoading: isAuditLogStreamsLoading } = + useGetAuditLogStreams(orgId); + + // mutation + const { mutateAsync: deleteAuditLogStream } = useDeleteAuditLogStream(); + + const handleAuditLogStreamDelete = async () => { + try { + const auditLogStreamId = popUp?.deleteAuditLogStream?.data as string; + await deleteAuditLogStream({ + id: auditLogStreamId, + orgId + }); + handlePopUpClose("deleteAuditLogStream"); + createNotification({ + type: "success", + text: "Successfully deleted stream" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete stream" + }); + } + }; + + return ( +
+
+

Audit Log Streams

+ + {(isAllowed) => ( + + )} + +
+

+ Send audit logs from Infisical to external logging providers via HTTP +

+
+ + + + + + + + + + {isAuditLogStreamsLoading && ( + + )} + {!isAuditLogStreamsLoading && auditLogStreams && auditLogStreams?.length === 0 && ( + + + + )} + {!isAuditLogStreamsLoading && + auditLogStreams?.map(({ id, url }) => ( + + + + + ))} + +
URLAction
+ +
+ {url} + +
+ + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
+
+
+
+ { + handlePopUpToggle("auditLogStreamForm", isModalOpen); + }} + > + + handlePopUpToggle("auditLogStreamForm")} + /> + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add audit log streams if you switch to Infisical's Enterprise plan." + /> + handlePopUpToggle("deleteAuditLogStream", isOpen)} + onClose={() => handlePopUpClose("deleteAuditLogStream")} + onDeleteApproved={handleAuditLogStreamDelete} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Settings } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/index.tsx new file mode 100644 index 000000000..ecce21f3b --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AuditLogStreamTab/index.tsx @@ -0,0 +1 @@ +export { AuditLogStreamsTab } from "./AuditLogStreamTab"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index de6bf0453..fdf541c54 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -1,12 +1,14 @@ import { Fragment } from "react"; import { Tab } from "@headlessui/react"; +import { AuditLogStreamsTab } from "../AuditLogStreamTab"; import { OrgAuthTab } from "../OrgAuthTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; const tabs = [ { name: "General", key: "tab-org-general" }, - { name: "Security", key: "tab-org-security" } + { name: "Security", key: "tab-org-security" }, + { name: "Audit Log Streams", key: "tag-audit-log-streams" } ]; export const OrgTabGroup = () => { return ( @@ -17,9 +19,8 @@ export const OrgTabGroup = () => { {({ selected }) => ( @@ -34,6 +35,9 @@ export const OrgTabGroup = () => { + + + ); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index da87da755..1d453b872 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -7,7 +7,7 @@ import { WebhooksTab } from "./components/WebhooksTab"; const tabs = [ { name: "General", key: "tab-project-general" }, - { name: "Webhooks", key: "tab-project-webhooks" } + { name: "Webhooks", key: "tab-project-webhooks" }, ]; export const ProjectSettingsPage = () => { @@ -25,9 +25,8 @@ export const ProjectSettingsPage = () => { {({ selected }) => (