diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 4289818eb..6795c824b 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -7,7 +7,7 @@ import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-app import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types"; import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-types"; import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; -import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types"; import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types"; diff --git a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts new file mode 100644 index 000000000..a70dcb8b9 --- /dev/null +++ b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts @@ -0,0 +1,221 @@ +import { Knex } from "knex"; + +import { inMemoryKeyStore } from "@app/keystore/memory"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; + +import { SecretKeyEncoding, TableName } from "../schemas"; +import { getMigrationEnvConfig } from "./utils/env-config"; +import { createCircularCache } from "./utils/ring-buffer"; +import { getMigrationEncryptionServices } from "./utils/services"; + +const BATCH_SIZE = 500; +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AuditLogStream)) { + const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider"); + const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + if (!hasProvider) t.string("provider").notNullable().defaultTo("custom"); + if (!hasEncryptedCredentials) t.binary("encryptedCredentials"); + + // This column will no longer be used but we're not dropping it so that we can have a backup in case the migration goes wrong + t.string("url").nullable().alter(); + }); + + if (!hasEncryptedCredentials) { + const superAdminDAL = superAdminDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL); + const keyStore = inMemoryKeyStore(); + + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const orgEncryptionRingBuffer = + createCircularCache>>(25); + + const logStreams = await knex(TableName.AuditLogStream).select( + "id", + "orgId", + + "url", + "encryptedHeadersAlgorithm", + "encryptedHeadersCiphertext", + "encryptedHeadersIV", + "encryptedHeadersKeyEncoding", + "encryptedHeadersTag" + ); + + const updatedLogStreams = await Promise.all( + logStreams.map(async (el) => { + let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId); + if (!orgKmsService) { + orgKmsService = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.Organization, + orgId: el.orgId + }, + knex + ); + orgEncryptionRingBuffer.push(el.orgId, orgKmsService); + } + + const provider = "custom"; + let credentials; + + if ( + el.encryptedHeadersTag && + el.encryptedHeadersIV && + el.encryptedHeadersCiphertext && + el.encryptedHeadersKeyEncoding + ) { + const decryptedHeaders = crypto + .encryption() + .symmetric() + .decryptWithRootEncryptionKey({ + tag: el.encryptedHeadersTag, + iv: el.encryptedHeadersIV, + ciphertext: el.encryptedHeadersCiphertext, + keyEncoding: el.encryptedHeadersKeyEncoding as SecretKeyEncoding + }); + + credentials = { + url: el.url, + headers: JSON.parse(decryptedHeaders) + }; + } else { + credentials = { + url: el.url, + headers: [] + }; + } + + const encryptedCredentials = orgKmsService.encryptor({ + plainText: Buffer.from(JSON.stringify(credentials), "utf8") + }).cipherTextBlob; + + return { + id: el.id, + orgId: el.orgId, + url: el.url, + provider, + encryptedCredentials + }; + }) + ); + + for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.AuditLogStream) + .insert(updatedLogStreams.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + t.binary("encryptedCredentials").notNullable().alter(); + }); + } + } +} + +// IMPORTANT: The down migration does not utilize the existing "url" and encrypted header columns +// because we're taking the latest data from the credentials column and re-encrypting it into relevant columns +// +// If this down migration was to fail, you can fall-back to the existing URL and encrypted header columns to retrieve +// data that was created prior to this migration + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AuditLogStream)) { + const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider"); + const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); + + if (hasEncryptedCredentials) { + const superAdminDAL = superAdminDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL); + const keyStore = inMemoryKeyStore(); + + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const orgEncryptionRingBuffer = + createCircularCache>>(25); + + const logStreamsToRevert = await knex(TableName.AuditLogStream) + .select("id", "orgId", "encryptedCredentials") + .where("provider", "custom") + .whereNotNull("encryptedCredentials"); + + const updatedLogStreams = await Promise.all( + logStreamsToRevert.map(async (el) => { + let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId); + if (!orgKmsService) { + orgKmsService = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.Organization, + orgId: el.orgId + }, + knex + ); + orgEncryptionRingBuffer.push(el.orgId, orgKmsService); + } + + const decryptedCredentials = orgKmsService + .decryptor({ + cipherTextBlob: el.encryptedCredentials + }) + .toString(); + + const credentials: { url: string; headers: { key: string; value: string }[] } = + JSON.parse(decryptedCredentials); + + const originalUrl: string = credentials.url; + + const encryptedHeadersResult = crypto + .encryption() + .symmetric() + .encryptWithRootEncryptionKey(JSON.stringify(credentials.headers), envConfig); + + const encryptedHeadersAlgorithm: string = encryptedHeadersResult.algorithm; + const encryptedHeadersCiphertext: string = encryptedHeadersResult.ciphertext; + const encryptedHeadersIV: string = encryptedHeadersResult.iv; + const encryptedHeadersKeyEncoding: string = encryptedHeadersResult.encoding; + const encryptedHeadersTag: string = encryptedHeadersResult.tag; + + return { + id: el.id, + orgId: el.orgId, + encryptedCredentials: el.encryptedCredentials, + + url: originalUrl, + encryptedHeadersAlgorithm, + encryptedHeadersCiphertext, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersTag + }; + }) + ); + + for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.AuditLogStream) + .insert(updatedLogStreams.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + + await knex(TableName.AuditLogStream) + .where((qb) => { + void qb.whereNot("provider", "custom").orWhereNull("url"); + }) + .del(); + } + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + t.string("url").notNullable().alter(); + + if (hasProvider) t.dropColumn("provider"); + if (hasEncryptedCredentials) t.dropColumn("encryptedCredentials"); + }); + } +} diff --git a/backend/src/db/schemas/audit-log-streams.ts b/backend/src/db/schemas/audit-log-streams.ts index 901dd8d27..a3f6bafba 100644 --- a/backend/src/db/schemas/audit-log-streams.ts +++ b/backend/src/db/schemas/audit-log-streams.ts @@ -5,11 +5,13 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const AuditLogStreamsSchema = z.object({ id: z.string().uuid(), - url: z.string(), + url: z.string().nullable().optional(), encryptedHeadersCiphertext: z.string().nullable().optional(), encryptedHeadersIV: z.string().nullable().optional(), encryptedHeadersTag: z.string().nullable().optional(), @@ -17,7 +19,9 @@ export const AuditLogStreamsSchema = z.object({ encryptedHeadersKeyEncoding: z.string().nullable().optional(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + provider: z.string().default("custom"), + encryptedCredentials: zodBuffer }); export type TAuditLogStreams = z.infer; diff --git a/backend/src/ee/routes/v1/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-router.ts deleted file mode 100644 index 17bd9e64b..000000000 --- a/backend/src/ee/routes/v1/audit-log-stream-router.ts +++ /dev/null @@ -1,215 +0,0 @@ -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/audit-log-stream-routers/audit-log-stream-endpoints.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts new file mode 100644 index 000000000..816fc62fb --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums"; +import { TAuditLogStream } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; +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"; + +export const registerAuditLogStreamEndpoints = ({ + server, + provider, + createSchema, + updateSchema, + sanitizedResponseSchema +}: { + server: FastifyZodProvider; + provider: LogProvider; + createSchema: z.ZodType<{ + credentials: T["credentials"]; + }>; + updateSchema: z.ZodType<{ + credentials: T["credentials"]; + }>; + sanitizedResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: "/:logStreamId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + + const auditLogStream = await server.services.auditLogStream.getById(logStreamId, provider, req.permission); + + return { auditLogStream }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: createSchema, + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { credentials } = req.body; + + const auditLogStream = await server.services.auditLogStream.create( + { + provider, + credentials + }, + req.permission + ); + + return { auditLogStream }; + } + }); + + server.route({ + method: "PATCH", + url: "/:logStreamId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + body: updateSchema, + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + const { credentials } = req.body; + + const auditLogStream = await server.services.auditLogStream.updateById( + { + logStreamId, + provider, + credentials + }, + req.permission + ); + + return { auditLogStream }; + } + }); + + server.route({ + method: "DELETE", + url: "/:logStreamId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + + const auditLogStream = await server.services.auditLogStream.deleteById(logStreamId, provider, req.permission); + + return { auditLogStream }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts new file mode 100644 index 000000000..bf10e48bf --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { + CustomProviderListItemSchema, + SanitizedCustomProviderSchema +} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas"; +import { + DatadogProviderListItemSchema, + SanitizedDatadogProviderSchema +} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas"; +import { + SanitizedSplunkProviderSchema, + SplunkProviderListItemSchema +} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SanitizedAuditLogStreamSchema = z.union([ + SanitizedCustomProviderSchema, + SanitizedDatadogProviderSchema, + SanitizedSplunkProviderSchema +]); + +const ProviderOptionsSchema = z.discriminatedUnion("provider", [ + CustomProviderListItemSchema, + DatadogProviderListItemSchema, + SplunkProviderListItemSchema +]); + +export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + providerOptions: ProviderOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const providerOptions = server.services.auditLogStream.listProviderOptions(); + + return { providerOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + 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(req.permission); + + return { auditLogStreams }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts new file mode 100644 index 000000000..ea3d48b30 --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts @@ -0,0 +1,51 @@ +import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums"; +import { + CreateCustomProviderLogStreamSchema, + SanitizedCustomProviderSchema, + UpdateCustomProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas"; +import { + CreateDatadogProviderLogStreamSchema, + SanitizedDatadogProviderSchema, + UpdateDatadogProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas"; +import { + CreateSplunkProviderLogStreamSchema, + SanitizedSplunkProviderSchema, + UpdateSplunkProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas"; + +import { registerAuditLogStreamEndpoints } from "./audit-log-stream-endpoints"; + +export * from "./audit-log-stream-router"; + +export const AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP: Record Promise> = + { + [LogProvider.Custom]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Custom, + sanitizedResponseSchema: SanitizedCustomProviderSchema, + createSchema: CreateCustomProviderLogStreamSchema, + updateSchema: UpdateCustomProviderLogStreamSchema + }); + }, + [LogProvider.Datadog]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Datadog, + sanitizedResponseSchema: SanitizedDatadogProviderSchema, + createSchema: CreateDatadogProviderLogStreamSchema, + updateSchema: UpdateDatadogProviderLogStreamSchema + }); + }, + [LogProvider.Splunk]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Splunk, + sanitizedResponseSchema: SanitizedSplunkProviderSchema, + createSchema: CreateSplunkProviderLogStreamSchema, + updateSchema: UpdateSplunkProviderLogStreamSchema + }); + } + }; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index ab9503f58..8bf066013 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -3,7 +3,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; -import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; @@ -114,7 +114,21 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerGroupRouter, { prefix: "/groups" }); - await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); + + await server.register( + async (auditLogStreamRouter) => { + await auditLogStreamRouter.register(registerAuditLogStreamRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP).map(([provider, router]) => + auditLogStreamRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/audit-log-streams" } + ); + await server.register(registerUserAdditionalPrivilegeRouter, { prefix: "/user-project-additional-privilege" }); await server.register( async (privilegeRouter) => { diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts new file mode 100644 index 000000000..9c03dc2bd --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts @@ -0,0 +1,5 @@ +export enum LogProvider { + Datadog = "datadog", + Splunk = "splunk", + Custom = "custom" +} diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts new file mode 100644 index 000000000..f70dc02a0 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts @@ -0,0 +1,13 @@ +import { LogProvider } from "./audit-log-stream-enums"; +import { TAuditLogStreamCredentials, TLogStreamFactory } from "./audit-log-stream-types"; +import { CustomProviderFactory } from "./custom/custom-provider-factory"; +import { DatadogProviderFactory } from "./datadog/datadog-provider-factory"; +import { SplunkProviderFactory } from "./splunk/splunk-provider-factory"; + +type TLogStreamFactoryImplementation = TLogStreamFactory; + +export const LOG_STREAM_FACTORY_MAP: Record = { + [LogProvider.Datadog]: DatadogProviderFactory as TLogStreamFactoryImplementation, + [LogProvider.Splunk]: SplunkProviderFactory as TLogStreamFactoryImplementation, + [LogProvider.Custom]: CustomProviderFactory as TLogStreamFactoryImplementation +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts index dc93f238e..87a7736a3 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts @@ -1,21 +1,70 @@ -export function providerSpecificPayload(url: string) { - const { hostname } = new URL(url); +import { TAuditLogStreams } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; - const payload: Record = {}; +import { TAuditLogStream, TAuditLogStreamCredentials } from "./audit-log-stream-types"; +import { getCustomProviderListItem } from "./custom/custom-provider-fns"; +import { getDatadogProviderListItem } from "./datadog/datadog-provider-fns"; +import { getSplunkProviderListItem } from "./splunk/splunk-provider-fns"; - switch (hostname) { - case "http-intake.logs.datadoghq.com": - case "http-intake.logs.us3.datadoghq.com": - case "http-intake.logs.us5.datadoghq.com": - case "http-intake.logs.datadoghq.eu": - case "http-intake.logs.ap1.datadoghq.com": - case "http-intake.logs.ddog-gov.com": - payload.ddsource = "infisical"; - payload.service = "audit-logs"; - break; - default: - break; - } +export const listProviderOptions = () => { + return [getDatadogProviderListItem(), getSplunkProviderListItem(), getCustomProviderListItem()].sort((a, b) => + a.name.localeCompare(b.name) + ); +}; - return payload; -} +export const encryptLogStreamCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TAuditLogStreamCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptLogStreamCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TAuditLogStreamCredentials; +}; + +export const decryptLogStream = async ( + logStream: TAuditLogStreams, + kmsService: Pick +) => { + return { + ...logStream, + credentials: await decryptLogStreamCredentials({ + encryptedCredentials: logStream.encryptedCredentials, + orgId: logStream.orgId, + kmsService + }) + } as TAuditLogStream; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts new file mode 100644 index 000000000..4fba79107 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts @@ -0,0 +1,14 @@ +import { AuditLogStreamsSchema } from "@app/db/schemas"; + +export const BaseProviderSchema = AuditLogStreamsSchema.omit({ + encryptedCredentials: true, + provider: true, + + // Old "archived" values + encryptedHeadersAlgorithm: true, + encryptedHeadersCiphertext: true, + encryptedHeadersIV: true, + encryptedHeadersKeyEncoding: true, + encryptedHeadersTag: true, + url: true +}); 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 index 46d2782b3..5dd0fd4ba 100644 --- 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 @@ -1,242 +1,252 @@ import { ForbiddenError } from "@casl/ability"; -import { RawAxiosRequestHeaders } from "axios"; +import { AxiosError } from "axios"; -import { SecretKeyEncoding } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; -import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { TAuditLogs } from "@app/db/schemas"; +import { + decryptLogStream, + decryptLogStreamCredentials, + encryptLogStreamCredentials, + listProviderOptions +} from "@app/ee/services/audit-log-stream/audit-log-stream-fns"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -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-types"; import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; -import { providerSpecificPayload } from "./audit-log-stream-fns"; -import { LogStreamHeaders, TAuditLogStreamServiceFactory } from "./audit-log-stream-types"; +import { LogProvider } from "./audit-log-stream-enums"; +import { LOG_STREAM_FACTORY_MAP } from "./audit-log-stream-factory"; +import { TAuditLogStream, TCreateAuditLogStreamDTO, TUpdateAuditLogStreamDTO } from "./audit-log-stream-types"; +import { TCustomProviderCredentials } from "./custom/custom-provider-types"; -type TAuditLogStreamServiceFactoryDep = { +export type TAuditLogStreamServiceFactoryDep = { auditLogStreamDAL: TAuditLogStreamDALFactory; permissionService: Pick; licenseService: Pick; + kmsService: Pick; }; +export type TAuditLogStreamServiceFactory = ReturnType; + export const auditLogStreamServiceFactory = ({ auditLogStreamDAL, permissionService, - licenseService -}: TAuditLogStreamServiceFactoryDep): TAuditLogStreamServiceFactory => { - const create: TAuditLogStreamServiceFactory["create"] = async ({ - url, - actor, - headers = [], - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - - const plan = await licenseService.getPlan(actorOrgId); + licenseService, + kmsService +}: TAuditLogStreamServiceFactoryDep) => { + const create = async ({ provider, credentials }: TCreateAuditLogStreamDTO, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); if (!plan.auditLogStreams) { throw new BadRequestError({ - message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." + message: "Failed to create Audit Log Stream: Plan restriction. Upgrade plan to continue." }); } const { permission } = await permissionService.getOrgPermission( - actor, - actorId, - actorOrgId, - actorAuthMethod, - actorOrgId + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); - - const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + const totalStreams = await auditLogStreamDAL.find({ orgId: actor.orgId }); 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." + message: "Failed to create Audit Log Stream: Plan limit reached. Contact Infisical to increase quota." }); } - // testing connection first - const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (headers.length) - headers.forEach(({ key, value }) => { - streamHeaders[key] = value; - }); + const factory = LOG_STREAM_FACTORY_MAP[provider](); + const validatedCredentials = await factory.validateCredentials({ credentials }); - await request - .post( - url, - { ...providerSpecificPayload(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 BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` }); - }); + const encryptedCredentials = await encryptLogStreamCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); - const encryptedHeaders = headers - ? crypto.encryption().symmetric().encryptWithRootEncryptionKey(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 - } - : {}) + orgId: actor.orgId, + provider, + encryptedCredentials }); - return logStream; + + return { ...logStream, credentials: validatedCredentials } as TAuditLogStream; }; - const updateById: TAuditLogStreamServiceFactory["updateById"] = async ({ - id, - url, - actor, - headers = [], - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - - const plan = await licenseService.getPlan(actorOrgId); - if (!plan.auditLogStreams) + const updateById = async ( + { logStreamId, provider, credentials }: TUpdateAuditLogStreamDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.auditLogStreams) { throw new BadRequestError({ - message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." + message: "Failed to update Audit Log Stream: Plan restriction. Upgrade plan to continue." }); + } - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const logStream = await auditLogStreamDAL.findById(logStreamId); + if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + logStream.orgId + ); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); - // testing connection first - const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (headers.length) - headers.forEach(({ key, value }) => { - streamHeaders[key] = value; - }); + const finalCredentials = { ...credentials }; - await request - .post( - url || logStream.url, - { ...providerSpecificPayload(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}`); - }); + // For the "Custom" provider, we must handle masked header values ('******'). + // These are placeholders from the frontend for secrets that haven't been changed. + // We need to replace them with the original, unmasked values from the database. + if ( + provider === LogProvider.Custom && + "headers" in finalCredentials && + Array.isArray(finalCredentials.headers) && + finalCredentials.headers.some((header) => header.value === "******") + ) { + const decryptedOldCredentials = (await decryptLogStreamCredentials({ + encryptedCredentials: logStream.encryptedCredentials, + orgId: logStream.orgId, + kmsService + })) as TCustomProviderCredentials; - const encryptedHeaders = headers - ? crypto.encryption().symmetric().encryptWithRootEncryptionKey(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 + const oldHeadersMap = decryptedOldCredentials.headers.reduce>((acc, header) => { + acc[header.key] = header.value; + return acc; + }, {}); + + const finalHeaders: { key: string; value: string }[] = []; + for (const header of finalCredentials.headers) { + if (header.value === "******") { + const oldValue = oldHeadersMap[header.key]; + if (oldValue) { + finalHeaders.push({ key: header.key, value: oldValue }); } - : {}) + } else { + finalHeaders.push(header); + } + } + finalCredentials.headers = finalHeaders; + } + + const factory = LOG_STREAM_FACTORY_MAP[provider](); + const validatedCredentials = await factory.validateCredentials({ credentials: finalCredentials }); + + const encryptedCredentials = await encryptLogStreamCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService }); - return updatedLogStream; + + const updatedLogStream = await auditLogStreamDAL.updateById(logStreamId, { + encryptedCredentials + }); + + return { ...updatedLogStream, credentials: validatedCredentials } as TAuditLogStream; }; - const deleteById: TAuditLogStreamServiceFactory["deleteById"] = async ({ - id, - actor, - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); + const deleteById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => { + const logStream = await auditLogStreamDAL.findById(logStreamId); + if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + logStream.orgId + ); - 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; + if (logStream.provider !== provider) { + throw new BadRequestError({ + message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'` + }); + } + + const deletedLogStream = await auditLogStreamDAL.deleteById(logStreamId); + + return decryptLogStream(deletedLogStream, kmsService); }; - const getById: TAuditLogStreamServiceFactory["getById"] = async ({ - id, - actor, - actorId, - actorOrgId, - actorAuthMethod - }) => { - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const getById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => { + const logStream = await auditLogStreamDAL.findById(logStreamId); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${logStreamId}' not found` }); - const headers = - logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - tag: logStream.encryptedHeadersTag, - iv: logStream.encryptedHeadersIV, - ciphertext: logStream.encryptedHeadersCiphertext, - keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding - }) - ) as LogStreamHeaders[]) - : undefined; - - return { ...logStream, headers }; - }; - - const list: TAuditLogStreamServiceFactory["list"] = async ({ actor, actorId, actorOrgId, actorAuthMethod }) => { const { permission } = await permissionService.getOrgPermission( - actor, - actorId, - actorOrgId, - actorAuthMethod, - actorOrgId + actor.type, + actor.id, + logStream.orgId, + actor.authMethod, + actor.orgId ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); - const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); - return logStreams; + if (logStream.provider !== provider) { + throw new BadRequestError({ + message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'` + }); + } + + return decryptLogStream(logStream, kmsService); + }; + + const list = async (actor: OrgServiceActor) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const logStreams = await auditLogStreamDAL.find({ orgId: actor.orgId }); + + return Promise.all(logStreams.map((stream) => decryptLogStream(stream, kmsService))); + }; + + const streamLog = async (orgId: string, auditLog: TAuditLogs) => { + const logStreams = await auditLogStreamDAL.find({ orgId }); + await Promise.allSettled( + logStreams.map(async ({ provider, encryptedCredentials }) => { + const credentials = await decryptLogStreamCredentials({ + encryptedCredentials, + orgId, + kmsService + }); + + const factory = LOG_STREAM_FACTORY_MAP[provider as LogProvider](); + + try { + await factory.streamLog({ + credentials, + auditLog + }); + } catch (error) { + logger.error( + error, + `Failed to stream audit log [auditLogId=${auditLog.id}] [provider=${provider}] [orgId=${orgId}]${error instanceof AxiosError ? `: ${error.message}` : ""}` + ); + throw error; + } + }) + ); }; return { @@ -244,6 +254,8 @@ export const auditLogStreamServiceFactory = ({ updateById, deleteById, getById, - list + list, + listProviderOptions, + streamLog }; }; 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 index 4c4a5609e..d0f78f632 100644 --- 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 @@ -1,48 +1,38 @@ -import { TAuditLogStreams } from "@app/db/schemas"; -import { TOrgPermission } from "@app/lib/types"; +import { TAuditLogs } from "@app/db/schemas"; -export type LogStreamHeaders = { - key: string; - value: string; +import { LogProvider } from "./audit-log-stream-enums"; +import { TCustomProvider, TCustomProviderCredentials } from "./custom/custom-provider-types"; +import { TDatadogProvider, TDatadogProviderCredentials } from "./datadog/datadog-provider-types"; +import { TSplunkProvider, TSplunkProviderCredentials } from "./splunk/splunk-provider-types"; + +export type TAuditLogStream = TDatadogProvider | TSplunkProvider | TCustomProvider; + +export type TAuditLogStreamCredentials = + | TDatadogProviderCredentials + | TSplunkProviderCredentials + | TCustomProviderCredentials; + +export type TCreateAuditLogStreamDTO = { + provider: LogProvider; + credentials: TAuditLogStreamCredentials; }; -export type TCreateAuditLogStreamDTO = Omit & { - url: string; - headers?: LogStreamHeaders[]; +export type TUpdateAuditLogStreamDTO = { + logStreamId: string; + provider: LogProvider; + credentials: TAuditLogStreamCredentials; }; -export type TUpdateAuditLogStreamDTO = Omit & { - id: string; - url?: string; - headers?: LogStreamHeaders[]; -}; +export type TLogStreamFactoryValidateCredentials = (input: { + credentials: C; +}) => Promise; -export type TDeleteAuditLogStreamDTO = Omit & { - id: string; -}; +export type TLogStreamFactoryStreamLog = (input: { + credentials: C; + auditLog: TAuditLogs; +}) => Promise; -export type TListAuditLogStreamDTO = Omit; - -export type TGetDetailsAuditLogStreamDTO = Omit & { - id: string; -}; - -export type TAuditLogStreamServiceFactory = { - create: (arg: TCreateAuditLogStreamDTO) => Promise; - updateById: (arg: TUpdateAuditLogStreamDTO) => Promise; - deleteById: (arg: TDeleteAuditLogStreamDTO) => Promise; - getById: (arg: TGetDetailsAuditLogStreamDTO) => Promise<{ - headers: LogStreamHeaders[] | undefined; - orgId: string; - url: string; - id: string; - createdAt: Date; - updatedAt: Date; - encryptedHeadersCiphertext?: string | null | undefined; - encryptedHeadersIV?: string | null | undefined; - encryptedHeadersTag?: string | null | undefined; - encryptedHeadersAlgorithm?: string | null | undefined; - encryptedHeadersKeyEncoding?: string | null | undefined; - }>; - list: (arg: TListAuditLogStreamDTO) => Promise; +export type TLogStreamFactory = () => { + validateCredentials: TLogStreamFactoryValidateCredentials; + streamLog: TLogStreamFactoryStreamLog; }; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts new file mode 100644 index 000000000..6e397638c --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts @@ -0,0 +1,67 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TCustomProviderCredentials } from "./custom-provider-types"; + +export const CustomProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { url, headers } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + 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, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { url, headers } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (headers.length) { + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + } + + await request.post(url, auditLog, { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts new file mode 100644 index 000000000..27b8bbf72 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getCustomProviderListItem = () => { + return { + name: "Custom" as const, + provider: LogProvider.Custom as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts new file mode 100644 index 000000000..d960d0fe9 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts @@ -0,0 +1,50 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const CustomProviderCredentialsSchema = z.object({ + url: z.string().url().trim().min(1).max(255), + headers: z + .object({ + key: z + .string() + .min(1) + .refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header keys cannot contain newlines or carriage returns"), + value: z + .string() + .min(1) + .refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header values cannot contain newlines or carriage returns") + }) + .array() +}); + +const BaseCustomProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Custom) }); + +export const CustomProviderSchema = BaseCustomProviderSchema.extend({ + credentials: CustomProviderCredentialsSchema +}); + +export const SanitizedCustomProviderSchema = BaseCustomProviderSchema.extend({ + credentials: z.object({ + url: CustomProviderCredentialsSchema.shape.url, + // Return header keys and a redacted value + headers: CustomProviderCredentialsSchema.shape.headers.transform((headers) => + headers.map((header) => ({ ...header, value: "******" })) + ) + }) +}); + +export const CustomProviderListItemSchema = z.object({ + name: z.literal("Custom"), + provider: z.literal(LogProvider.Custom) +}); + +export const CreateCustomProviderLogStreamSchema = z.object({ + credentials: CustomProviderCredentialsSchema +}); + +export const UpdateCustomProviderLogStreamSchema = z.object({ + credentials: CustomProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts new file mode 100644 index 000000000..9b2de8347 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { CustomProviderCredentialsSchema, CustomProviderSchema } from "./custom-provider-schemas"; + +export type TCustomProvider = z.infer; + +export type TCustomProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts new file mode 100644 index 000000000..ec55784da --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts @@ -0,0 +1,67 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TDatadogProviderCredentials } from "./datadog-provider-types"; + +function createPayload(event: Record) { + const appCfg = getConfig(); + + const ddtags = [`env:${appCfg.NODE_ENV || "unknown"}`].join(","); + + return { + ...event, + hostname: new URL(appCfg.SITE_URL || "http://infisical").hostname, + ddsource: "infisical", + service: "infisical", + ddtags + }; +} + +export const DatadogProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { url, token } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token }; + + await request + .post(url, createPayload({ ping: "ok" }), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with Datadog: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { url, token } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token }; + + await request.post(url, createPayload(auditLog), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts new file mode 100644 index 000000000..ec68fdb39 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getDatadogProviderListItem = () => { + return { + name: "Datadog" as const, + provider: LogProvider.Datadog as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts new file mode 100644 index 000000000..0445d79f2 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts @@ -0,0 +1,38 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const DatadogProviderCredentialsSchema = z.object({ + url: z.string().url().trim().min(1).max(255), + token: z + .string() + .trim() + .refine((val) => new RE2(/^[a-fA-F0-9]{32}$/).test(val), "Invalid Datadog API key format") +}); + +const BaseDatadogProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Datadog) }); + +export const DatadogProviderSchema = BaseDatadogProviderSchema.extend({ + credentials: DatadogProviderCredentialsSchema +}); + +export const SanitizedDatadogProviderSchema = BaseDatadogProviderSchema.extend({ + credentials: DatadogProviderCredentialsSchema.pick({ + url: true + }) +}); + +export const DatadogProviderListItemSchema = z.object({ + name: z.literal("Datadog"), + provider: z.literal(LogProvider.Datadog) +}); + +export const CreateDatadogProviderLogStreamSchema = z.object({ + credentials: DatadogProviderCredentialsSchema +}); + +export const UpdateDatadogProviderLogStreamSchema = z.object({ + credentials: DatadogProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts new file mode 100644 index 000000000..e7f6a1f28 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { DatadogProviderCredentialsSchema, DatadogProviderSchema } from "./datadog-provider-schemas"; + +export type TDatadogProvider = z.infer; + +export type TDatadogProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts new file mode 100644 index 000000000..72fc90ce2 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts @@ -0,0 +1,84 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TSplunkProviderCredentials } from "./splunk-provider-types"; + +function createPayload(event: Record) { + const appCfg = getConfig(); + + return { + time: Math.floor(Date.now() / 1000), + ...(appCfg.SITE_URL && { host: new URL(appCfg.SITE_URL).host }), + source: "infisical", + sourcetype: "_json", + event + }; +} + +async function createSplunkUrl(hostname: string) { + let parsedHostname: string; + try { + parsedHostname = new URL(`https://${hostname}`).hostname; + } catch (error) { + throw new BadRequestError({ message: `Invalid Splunk hostname provided: ${(error as Error).message}` }); + } + + await blockLocalAndPrivateIpAddresses(`https://${parsedHostname}`); + + return `https://${parsedHostname}:8088/services/collector/event`; +} + +export const SplunkProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { hostname, token } = credentials; + + const url = await createSplunkUrl(hostname); + + const streamHeaders: RawAxiosRequestHeaders = { + "Content-Type": "application/json", + Authorization: `Splunk ${token}` + }; + + await request + .post(url, createPayload({ ping: "ok" }), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with Splunk: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { hostname, token } = credentials; + + const url = await createSplunkUrl(hostname); + + const streamHeaders: RawAxiosRequestHeaders = { + "Content-Type": "application/json", + Authorization: `Splunk ${token}` + }; + + await request.post(url, createPayload(auditLog), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts new file mode 100644 index 000000000..e2ea2e316 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getSplunkProviderListItem = () => { + return { + name: "Splunk" as const, + provider: LogProvider.Splunk as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts new file mode 100644 index 000000000..ab28db616 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const SplunkProviderCredentialsSchema = z.object({ + hostname: z + .string() + .trim() + .min(1) + .max(255) + .superRefine((val, ctx) => { + if (val.includes("://")) { + ctx.addIssue({ + code: "custom", + message: "Hostname should not include protocol" + }); + return; + } + + try { + const url = new URL(`https://${val}`); + if (url.hostname !== val) { + ctx.addIssue({ + code: "custom", + message: "Must be a valid hostname without port or path" + }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid hostname" }); + } + }), + token: z.string().uuid().trim().min(1) +}); + +const BaseSplunkProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Splunk) }); + +export const SplunkProviderSchema = BaseSplunkProviderSchema.extend({ + credentials: SplunkProviderCredentialsSchema +}); + +export const SanitizedSplunkProviderSchema = BaseSplunkProviderSchema.extend({ + credentials: SplunkProviderCredentialsSchema.pick({ + hostname: true + }) +}); + +export const SplunkProviderListItemSchema = z.object({ + name: z.literal("Splunk"), + provider: z.literal(LogProvider.Splunk) +}); + +export const CreateSplunkProviderLogStreamSchema = z.object({ + credentials: SplunkProviderCredentialsSchema +}); + +export const UpdateSplunkProviderLogStreamSchema = z.object({ + credentials: SplunkProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts new file mode 100644 index 000000000..11f7c8fcb --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { SplunkProviderCredentialsSchema, SplunkProviderSchema } from "./splunk-provider-schemas"; + +export type TSplunkProvider = z.infer; + +export type TSplunkProviderCredentials = z.infer; 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 0914b8f6b..6b286c2ec 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,22 +1,14 @@ -import { AxiosError, RawAxiosRequestHeaders } from "axios"; - -import { SecretKeyEncoding } from "@app/db/schemas"; -import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { logger } from "@app/lib/logger"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; 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 { providerSpecificPayload } from "../audit-log-stream/audit-log-stream-fns"; -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; + auditLogStreamService: Pick; queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; @@ -35,7 +27,7 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamService }: TAuditLogQueueServiceFactoryDep): Promise => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -86,60 +78,7 @@ export const auditLogQueueServiceFactory = async ({ 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( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - 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; - }); - - try { - const response = await request.post( - url, - { ...providerSpecificPayload(url), ...auditLog }, - { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ); - return response; - } catch (error) { - logger.error( - `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` - ); - return error; - } - } - ) - ); + await auditLogStreamService.streamLog(orgId, auditLog); } }); diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index b8fc45645..05ea315cd 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -250,8 +250,11 @@ const cryptographyFactory = () => { }; }; - const encryptWithRootEncryptionKey = (data: string) => { - const appCfg = getConfig(); + const encryptWithRootEncryptionKey = ( + data: string, + appCfgOverride?: Pick + ) => { + const appCfg = appCfgOverride || getConfig(); const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 24294b46a..e17d06eef 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -559,20 +559,22 @@ export const registerRoutes = async ( permissionService }); + const auditLogStreamService = auditLogStreamServiceFactory({ + licenseService, + permissionService, + auditLogStreamDAL, + kmsService + }); + const auditLogQueue = await auditLogQueueServiceFactory({ auditLogDAL, queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamService }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); - const auditLogStreamService = auditLogStreamServiceFactory({ - licenseService, - permissionService, - auditLogStreamDAL - }); const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 8af4baa8b..344224008 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -246,13 +246,6 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ metadata: ResourceMetadataSchema.optional() }); -export const SanitizedAuditLogStreamSchema = z.object({ - id: z.string(), - url: z.string(), - createdAt: z.date(), - updatedAt: z.date() -}); - export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, diff --git a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx index 2ee4dff92..fab5f16b7 100644 --- a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx +++ b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx @@ -6,84 +6,133 @@ description: "Learn how to stream Infisical Audit Logs to external logging provi 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. + 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. - +Infisical Audit Log Streaming enables you to transmit your organization's audit logs to external logging providers for monitoring and analysis. ## Overview - - - ![stream create](/images/platform/audit-log-streams/stream-create.png) - - - ![stream create](/images/platform/audit-log-streams/stream-inputs.png) + + 1. Navigate to **Organization Settings** + 2. Select the **Audit Log Streams** tab + 3. Click **Add Log Stream** - 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 create](/images/platform/audit-log-streams/stream-create.png) + + + If your log provider is included in this list, select it. Otherwise click on **Custom** to input your own Endpoint URL and headers. + + ![select provider](/images/platform/audit-log-streams/select-provider.png) + + + Depending on your chosen provider, you'll be asked to input different credentials. + + For **Custom**, you need to input an endpoint URL and headers. + + ![custom provider](/images/platform/audit-log-streams/custom-provider.png) + + Once you're finished, click **Create Log Stream**. + + + Your audit logs are now ready to be streamed. + + ![stream list](/images/platform/audit-log-streams/stream-list.png) -![stream listt](/images/platform/audit-log-streams/stream-list.png) -Your Audit Logs are now ready to be streamed. - ## Example Providers -### Better Stack + + + You can stream to Better Stack using a **Custom** log stream. - - - ![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) + + + On Better Stack, select **Connect Source** and click **Create source** after providing a name. - 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 \**. - - + ![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png) -### Datadog + Once your source is created, take note of the **endpoint** and **Source token** for the next step. - - - ![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) + ![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png) + + + On Infisical, create a new audit log stream and select the **Custom** option. - 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) + ![select custom](/images/platform/audit-log-streams/select-custom.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**. - - + 1. Fill in the endpoint URL with your Better Stack source endpoint + 2. Create a new header with key `Authorization` and set the value as `Bearer ` -## Audit Log Stream Data + ![custom provider](/images/platform/audit-log-streams/custom-provider.png) -Each log entry sent to the external logging provider will follow the same structure. + Once you're finished, click **Create Log Stream**. + + + + + You can stream to Datadog using the **Datadog** provider log stream. + + + + ![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) + + + On Infisical, create a new audit log stream and select the **Datadog** provider option. + + Input your **Datadog Region** and the **Token** obtained from step 2. + + ![datadog details](/images/platform/audit-log-streams/datadog-details.png) + + Once you're finished, click **Create Log Stream**. + + + + + You can stream to Splunk using the **Splunk** provider log stream. + + + + Navigate to **Settings** > **Data Inputs**. + + ![splunk data inputs](/images/platform/audit-log-streams/splunk-data-inputs.png) + + Click on **HTTP Event Collector**. + + ![splunk http collector](/images/platform/audit-log-streams/splunk-http-collector.png) + + Click on **New Token** in the top left. + + ![splunk new token](/images/platform/audit-log-streams/splunk-new-token.png) + + Provide a name and click **Next**. + + ![splunk name](/images/platform/audit-log-streams/splunk-name.png) + + On the next page, click **Review** and then **Submit** at the top. On the final page you'll see your token. + + Copy the **Token Value** and your Splunk hostname from the URL to be used for later. + + ![splunk credentials](/images/platform/audit-log-streams/splunk-credentials.png) + + + On Infisical, create a new audit log stream and select the **Splunk** provider option. + + Input your **Splunk Hostname** and the **Token** obtained from step 1. + + ![splunk details](/images/platform/audit-log-streams/splunk-details.png) + + Once you're finished, click **Create Log Stream**. + + + + ### Example Log Entry @@ -117,106 +166,109 @@ Each log entry sent to the external logging provider will follow the same struct ``` ### Audit Logs Structure + + + Streamed audit log structure **varies based on provider**, but they all share the audit log fields shown below. + + - The unique identifier for the log entry. + The unique identifier for the log entry. - The entity responsible for performing or causing the event; this can be a user or service. + The entity responsible for performing or causing the event; this can be a user or service. - The metadata associated with the actor. This varies based on the actor type. + The metadata associated with the actor. This varies based on the actor type. - - This metadata is present when the `actor` field is set to `user`. + + + This metadata is present when the `actor` field is set to `user`. - - The unique identifier for the actor. - - - The email address of the actor. - - - The username of the actor. - - + + The unique identifier for the actor. + + + The email address of the actor. + + + The username of the actor. + + + + This metadata is present when the `actor` field is set to `identity`. - - This metadata is present when the `actor` field is set to `identity`. + + The unique identifier for the identity. + + + The name of the identity. + + + + This metadata is present when the `actor` field is set to `service`. - - The unique identifier for the identity. - - - The name of the identity. - - - - - This metadata is present when the `actor` field is set to `service`. - - - The unique identifier for the service. - - - The name of the service. - - - - - - If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. - + + The unique identifier for the service. + + + The name of the service. + + + + + If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. + - The IP address of the actor. + The IP address of the actor. - The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. + The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. - `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. + `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. - The metadata associated with the event. This varies based on the event type. + The metadata associated with the event. This varies based on the event type. - The user agent of the actor, if applicable. + The user agent of the actor, if applicable. - The type of user agent. + The type of user agent. - The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. + The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. - The creation date of the log entry. + The creation date of the log entry. - The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. + The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. - The unique identifier for the organization where the event occurred. + The unique identifier for the organization where the event occurred. - The unique identifier for the project where the event occurred. + The unique identifier for the project where the event occurred. - The `projectId` field will only be present if the event occurred at the project level, not the organization level. + The `projectId` field will only be present if the event occurred at the project level, not the organization level. - The name of the project where the event occurred. + The name of the project where the event occurred. - The `projectName` field will only be present if the event occurred at the project level, not the organization level. + The `projectName` field will only be present if the event occurred at the project level, not the organization level. diff --git a/docs/images/platform/audit-log-streams/custom-provider.png b/docs/images/platform/audit-log-streams/custom-provider.png new file mode 100644 index 000000000..e860e2e90 Binary files /dev/null and b/docs/images/platform/audit-log-streams/custom-provider.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-details.png b/docs/images/platform/audit-log-streams/datadog-details.png new file mode 100644 index 000000000..29b1464be Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-details.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 deleted file mode 100644 index 7960b1145..000000000 Binary files a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png and /dev/null 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 deleted file mode 100644 index 5ae25b0b3..000000000 Binary files a/docs/images/platform/audit-log-streams/datadog-source-details.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/select-custom.png b/docs/images/platform/audit-log-streams/select-custom.png new file mode 100644 index 000000000..7a55b24a7 Binary files /dev/null and b/docs/images/platform/audit-log-streams/select-custom.png differ diff --git a/docs/images/platform/audit-log-streams/select-provider.png b/docs/images/platform/audit-log-streams/select-provider.png new file mode 100644 index 000000000..c289fd3af Binary files /dev/null and b/docs/images/platform/audit-log-streams/select-provider.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-credentials.png b/docs/images/platform/audit-log-streams/splunk-credentials.png new file mode 100644 index 000000000..8b8624700 Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-credentials.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-data-inputs.png b/docs/images/platform/audit-log-streams/splunk-data-inputs.png new file mode 100644 index 000000000..3446f89f3 Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-data-inputs.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-details.png b/docs/images/platform/audit-log-streams/splunk-details.png new file mode 100644 index 000000000..b7ece41fc Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-details.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-http-collector.png b/docs/images/platform/audit-log-streams/splunk-http-collector.png new file mode 100644 index 000000000..d8090095c Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-http-collector.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-name.png b/docs/images/platform/audit-log-streams/splunk-name.png new file mode 100644 index 000000000..2c382539e Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-name.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-new-token.png b/docs/images/platform/audit-log-streams/splunk-new-token.png new file mode 100644 index 000000000..d4c53569d Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-new-token.png differ diff --git a/docs/images/platform/audit-log-streams/stream-create.png b/docs/images/platform/audit-log-streams/stream-create.png index 949278e3d..1244fa558 100644 Binary files a/docs/images/platform/audit-log-streams/stream-create.png 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 deleted file mode 100644 index 6b9d7c57b..000000000 Binary files a/docs/images/platform/audit-log-streams/stream-inputs.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/stream-list.png b/docs/images/platform/audit-log-streams/stream-list.png index c5cc5598b..ad355c3b0 100644 Binary files a/docs/images/platform/audit-log-streams/stream-list.png and b/docs/images/platform/audit-log-streams/stream-list.png differ diff --git a/frontend/public/images/integrations/Datadog.png b/frontend/public/images/integrations/Datadog.png new file mode 100644 index 000000000..7c1b33de0 Binary files /dev/null and b/frontend/public/images/integrations/Datadog.png differ diff --git a/frontend/public/images/integrations/Splunk.png b/frontend/public/images/integrations/Splunk.png new file mode 100644 index 000000000..54f13cd25 Binary files /dev/null and b/frontend/public/images/integrations/Splunk.png differ diff --git a/frontend/src/helpers/auditLogStreams.ts b/frontend/src/helpers/auditLogStreams.ts new file mode 100644 index 000000000..00d2b9e51 --- /dev/null +++ b/frontend/src/helpers/auditLogStreams.ts @@ -0,0 +1,32 @@ +import { faCode, IconDefinition } from "@fortawesome/free-solid-svg-icons"; + +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TAuditLogStream } from "@app/hooks/api/types"; +import { DiscriminativePick } from "@app/types"; + +export const AUDIT_LOG_STREAM_PROVIDER_MAP: Record< + LogProvider, + { name: string; image?: string; icon?: IconDefinition; size?: number } +> = { + [LogProvider.Custom]: { name: "Custom", icon: faCode }, + [LogProvider.Datadog]: { name: "Datadog", image: "Datadog.png" }, + [LogProvider.Splunk]: { name: "Splunk", image: "Splunk.png", size: 65 } +}; + +// Strictly for showing to the client in the front-end +export function getProviderUrl( + logStream: DiscriminativePick +) { + switch (logStream.provider) { + case LogProvider.Custom: + return logStream.credentials.url; + case LogProvider.Datadog: + return logStream.credentials.url; + case LogProvider.Splunk: + return `https://${logStream.credentials.hostname}:8088/services/collector/event`; + default: + throw new Error( + `Unhandled provider in getProviderUrl: ${(logStream as TAuditLogStream).provider}` + ); + } +} diff --git a/frontend/src/hooks/api/auditLogStreams/enums.ts b/frontend/src/hooks/api/auditLogStreams/enums.ts new file mode 100644 index 000000000..9c03dc2bd --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/enums.ts @@ -0,0 +1,5 @@ +export enum LogProvider { + Datadog = "datadog", + Splunk = "splunk", + Custom = "custom" +} diff --git a/frontend/src/hooks/api/auditLogStreams/index.tsx b/frontend/src/hooks/api/auditLogStreams/index.tsx index 72b1fba1a..0c2adeab0 100644 --- a/frontend/src/hooks/api/auditLogStreams/index.tsx +++ b/frontend/src/hooks/api/auditLogStreams/index.tsx @@ -1,6 +1,2 @@ -export { - useCreateAuditLogStream, - useDeleteAuditLogStream, - useUpdateAuditLogStream -} from "./mutations"; -export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries"; +export * from "./mutations"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/auditLogStreams/mutations.tsx b/frontend/src/hooks/api/auditLogStreams/mutations.tsx index 1ed93f95b..24253ca63 100644 --- a/frontend/src/hooks/api/auditLogStreams/mutations.tsx +++ b/frontend/src/hooks/api/auditLogStreams/mutations.tsx @@ -12,50 +12,54 @@ import { export const useCreateAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TCreateAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ provider, ...params }: TCreateAuditLogStreamDTO) => { const { data } = await apiRequest.post<{ auditLogStream: TAuditLogStream }>( - "/api/v1/audit-log-streams", - dto + `/api/v1/audit-log-streams/${provider}`, + params ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); - } + onSuccess: () => queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }) }); }; export const useUpdateAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TUpdateAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ auditLogStreamId, provider, ...params }: TUpdateAuditLogStreamDTO) => { const { data } = await apiRequest.patch<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${dto.id}`, - dto + `/api/v1/audit-log-streams/${provider}/${auditLogStreamId}`, + params ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); + onSuccess: (_, { auditLogStreamId, provider }) => { + queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }); + queryClient.invalidateQueries({ + queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId) + }); } }); }; export const useDeleteAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TDeleteAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ auditLogStreamId, provider }: TDeleteAuditLogStreamDTO) => { const { data } = await apiRequest.delete<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${dto.id}` + `/api/v1/audit-log-streams/${provider}/${auditLogStreamId}` ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); + onSuccess: (_, { auditLogStreamId, provider }) => { + queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }); + queryClient.invalidateQueries({ + queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId) + }); } }); }; diff --git a/frontend/src/hooks/api/auditLogStreams/queries.tsx b/frontend/src/hooks/api/auditLogStreams/queries.tsx index ff7d8b499..97d7d6267 100644 --- a/frontend/src/hooks/api/auditLogStreams/queries.tsx +++ b/frontend/src/hooks/api/auditLogStreams/queries.tsx @@ -1,40 +1,89 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TAuditLogStream } from "./types"; +import { TAuditLogStreamProviderOption } from "./types/provider-options"; +import { LogProvider } from "./enums"; +import { TAuditLogStream, TAuditLogStreamProviderMap } from "./types"; export const auditLogStreamKeys = { - list: (orgId: string) => ["audit-log-stream", { orgId }], - getById: (id: string) => ["audit-log-stream-details", { id }] + all: ["audit-log-stream"] as const, + options: () => [...auditLogStreamKeys.all, "options"] as const, + list: () => [...auditLogStreamKeys.all, "list"] as const, + getById: (provider: string, id: string) => + [...auditLogStreamKeys.all, provider, "get-by-id", id] as const }; -const fetchAuditLogStreams = async () => { - const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( - "/api/v1/audit-log-streams" - ); +export const useGetAuditLogStreamOptions = ( + options?: Omit< + UseQueryOptions< + TAuditLogStreamProviderOption[], + unknown, + TAuditLogStreamProviderOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get<{ providerOptions: TAuditLogStreamProviderOption[] }>( + "/api/v1/audit-log-streams/options" + ); - return data.auditLogStreams; -}; - -export const useGetAuditLogStreams = (orgId: string) => - useQuery({ - queryKey: auditLogStreamKeys.list(orgId), - queryFn: () => fetchAuditLogStreams(), - enabled: Boolean(orgId) + return data.providerOptions; + }, + ...options }); - -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) +export const useListAuditLogStreams = ( + options?: Omit< + UseQueryOptions< + TAuditLogStream[], + unknown, + TAuditLogStream[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( + "/api/v1/audit-log-streams" + ); + + return data.auditLogStreams; + }, + ...options }); +}; + +export const useGetAuditLogStreamById = ( + provider: T, + logStreamId: string, + options?: Omit< + UseQueryOptions< + TAuditLogStreamProviderMap[T], + unknown, + TAuditLogStreamProviderMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.getById(provider, logStreamId), + queryFn: async () => { + const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStreamProviderMap[T] }>( + `/api/v1/audit-log-streams/${provider}/${logStreamId}` + ); + + return data.auditLogStream; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types.ts b/frontend/src/hooks/api/auditLogStreams/types.ts deleted file mode 100644 index 8e21a3209..000000000 --- a/frontend/src/hooks/api/auditLogStreams/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -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/auditLogStreams/types/index.ts b/frontend/src/hooks/api/auditLogStreams/types/index.ts new file mode 100644 index 000000000..d3dffe6a9 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/index.ts @@ -0,0 +1,25 @@ +import { LogProvider } from "../enums"; +import { TCustomProviderLogStream } from "./providers/custom-provider"; +import { TDatadogProviderLogStream } from "./providers/datadog-provider"; +import { TSplunkProviderLogStream } from "./providers/splunk-provider"; + +export type TAuditLogStream = + | TCustomProviderLogStream + | TDatadogProviderLogStream + | TSplunkProviderLogStream; + +export type TAuditLogStreamProviderMap = { + [LogProvider.Custom]: TCustomProviderLogStream; + [LogProvider.Datadog]: TDatadogProviderLogStream; + [LogProvider.Splunk]: TSplunkProviderLogStream; +}; + +export type TCreateAuditLogStreamDTO = Pick; +export type TUpdateAuditLogStreamDTO = Pick & { + provider: LogProvider; + auditLogStreamId: string; +}; +export type TDeleteAuditLogStreamDTO = { + provider: LogProvider; + auditLogStreamId: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts b/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts new file mode 100644 index 000000000..e9b06dfef --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts @@ -0,0 +1,11 @@ +import { LogProvider } from "../enums"; + +export type TAuditLogStreamProviderOptionBase = { + name: string; +}; + +export type TAuditLogStreamProviderOption = { + [P in keyof typeof LogProvider]: TAuditLogStreamProviderOptionBase & { + provider: (typeof LogProvider)[P]; + }; +}[keyof typeof LogProvider]; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts new file mode 100644 index 000000000..83ff9e010 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TCustomProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Custom; + credentials: { + url: string; + headers: { key: string; value: string }[]; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts new file mode 100644 index 000000000..85198bb1a --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TDatadogProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Datadog; + credentials: { + url: string; + token: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts new file mode 100644 index 000000000..5ea3137e5 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts @@ -0,0 +1,6 @@ +export type TRootProviderLogStream = { + id: string; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts new file mode 100644 index 000000000..bf5c676ef --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TSplunkProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Splunk; + credentials: { + hostname: string; + token: string; + }; +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx index 9f7494076..755611587 100644 --- a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx @@ -1,12 +1,10 @@ -import { useOrganization } from "@app/context"; -import { useFetchServerStatus, useGetAuditLogStreams } from "@app/hooks/api"; +import { useFetchServerStatus, useListAuditLogStreams } from "@app/hooks/api"; import { OrgAlertBanner } from "../OrgAlertBanner"; export const AuditLogBanner = () => { - const org = useOrganization(); const { data: status, isLoading: isLoadingStatus } = useFetchServerStatus(); - const { data: streams, isLoading: isLoadingStreams } = useGetAuditLogStreams(org.currentOrg.id); + const { data: streams, isLoading: isLoadingStreams } = useListAuditLogStreams(); if (isLoadingStreams || isLoadingStatus || !streams) return null; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx deleted file mode 100644 index 7e16afd60..000000000 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx +++ /dev/null @@ -1,207 +0,0 @@ -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; -}; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -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: (err as Error)?.message ?? "Failed to create stream" - }); - } - }; - - if (isEdit && auditLogStream.isPending) { - 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/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/AuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/AuditLogStreamForm.tsx new file mode 100644 index 000000000..1af5e8d1a --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/AuditLogStreamForm.tsx @@ -0,0 +1,125 @@ +import { createNotification } from "@app/components/notifications"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams"; +import { useCreateAuditLogStream, useUpdateAuditLogStream } from "@app/hooks/api"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TAuditLogStream } from "@app/hooks/api/types"; +import { DiscriminativePick } from "@app/types"; + +import { AuditLogStreamHeader } from "../components/AuditLogStreamHeader"; +import { CustomProviderAuditLogStreamForm } from "./CustomProviderAuditLogStreamForm"; +import { DatadogProviderAuditLogStreamForm } from "./DatadogProviderAuditLogStreamForm"; +import { SplunkProviderAuditLogStreamForm } from "./SplunkProviderAuditLogStreamForm"; + +type FormProps = { + onComplete: (auditLogStream: TAuditLogStream) => void; +}; + +type CreateFormProps = FormProps & { provider: LogProvider }; +type UpdateFormProps = FormProps & { + auditLogStream: TAuditLogStream; +}; + +const CreateForm = ({ provider, onComplete }: CreateFormProps) => { + const createAuditLogStream = useCreateAuditLogStream(); + const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[provider]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const logStream = await createAuditLogStream.mutateAsync(formData); + createNotification({ + text: `Successfully created ${providerName} Log Stream`, + type: "success" + }); + onComplete(logStream); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to create ${providerName} Log Stream`, + text: err.message, + type: "error" + }); + } + }; + + switch (provider) { + case LogProvider.Custom: + return ; + case LogProvider.Datadog: + return ; + case LogProvider.Splunk: + return ; + default: + throw new Error(`Unhandled Provider: ${provider}`); + } +}; + +const UpdateForm = ({ auditLogStream, onComplete }: UpdateFormProps) => { + const updateAuditLogStream = useUpdateAuditLogStream(); + const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await updateAuditLogStream.mutateAsync({ + auditLogStreamId: auditLogStream.id, + ...formData + }); + createNotification({ + text: `Successfully updated ${providerName} Log Stream`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${providerName} Log Stream`, + text: err.message, + type: "error" + }); + } + }; + + switch (auditLogStream.provider) { + case LogProvider.Custom: + return ( + + ); + case LogProvider.Datadog: + return ( + + ); + case LogProvider.Splunk: + return ( + + ); + default: + throw new Error(`Unhandled Provider: ${(auditLogStream as TAuditLogStream).provider}`); + } +}; + +type Props = { onBack?: () => void } & Pick & + ( + | { provider: LogProvider; auditLogStream?: undefined } + | { provider?: undefined; auditLogStream: TAuditLogStream } + ); +export const AuditLogStreamForm = ({ onBack, ...props }: Props) => { + const { provider, auditLogStream } = props; + + return ( +
+ + {auditLogStream ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx new file mode 100644 index 000000000..258af14b9 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx @@ -0,0 +1,176 @@ +import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, FormLabel, IconButton, Input, ModalClose } from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TCustomProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/custom-provider"; + +type Props = { + auditLogStream?: TCustomProviderLogStream; + onSubmit: (formData: FormData) => void; +}; + +const formSchema = z.object({ + provider: z.literal(LogProvider.Custom), + credentials: z.object({ + url: z.string().url().trim().min(1).max(255), + headers: z + .object({ + key: z.string().min(1), + value: z.string().min(1) + }) + .array() + }) +}); + +type FormData = z.infer; + +export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const isUpdate = Boolean(auditLogStream); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: auditLogStream ?? { + provider: LogProvider.Custom + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty }, + getValues, + setValue + } = form; + + const headerFields = useFieldArray({ + control, + name: "credentials.headers" + }); + + return ( + +
+ ( + + + + )} + /> + + + {headerFields.fields.map(({ id: headerFieldId }, i) => ( +
+ ( + + + + )} + /> + ( + + { + if ( + auditLogStream && + auditLogStream.credentials.headers[i] && + auditLogStream.credentials.headers[i].value === "******" && + field.value === "******" + ) { + field.onChange(""); + } + e.target.type = "text"; + }} + onBlur={(e) => { + if ( + auditLogStream && + auditLogStream.credentials.headers[i] && + auditLogStream.credentials.headers[i].value === "******" && + field.value === "" + ) { + field.onChange("******"); + } + e.target.type = "password"; + }} + /> + + )} + /> + { + const header = getValues("credentials.headers"); + if (header && header?.length > 1) { + headerFields.remove(i); + } else { + setValue("credentials.headers", [{ key: "", value: "" }]); + } + }} + > + + +
+ ))} +
+ +
+ +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/DatadogProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/DatadogProviderAuditLogStreamForm.tsx new file mode 100644 index 000000000..2db315416 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/DatadogProviderAuditLogStreamForm.tsx @@ -0,0 +1,132 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TDatadogProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/datadog-provider"; + +type Props = { + auditLogStream?: TDatadogProviderLogStream; + onSubmit: (formData: FormData) => void; +}; + +const formSchema = z.object({ + provider: z.literal(LogProvider.Datadog), + credentials: z.object({ + url: z.string().url().trim().min(1).max(255), + token: z + .string() + .trim() + .regex(/^[a-fA-F0-9]{32}$/, "Invalid Datadog API key format") + }) +}); + +type FormData = z.infer; + +const DATADOG_ENDPOINTS = { + "Datadog US1": "https://http-intake.logs.datadoghq.com/api/v2/logs", + "Datadog US3": "https://http-intake.logs.us3.datadoghq.com/api/v2/logs", + "Datadog US5": "https://http-intake.logs.us5.datadoghq.com/api/v2/logs", + "Datadog EU": "https://http-intake.logs.datadoghq.eu/api/v2/logs", + "Datadog AP1": "https://http-intake.logs.ap1.datadoghq.com/api/v2/logs", + "Datadog AP2": "https://http-intake.logs.ap2.datadoghq.com/api/v2/logs", + "Datadog GovCloud (US1-FED)": "https://http-intake.logs.ddog-gov.com/api/v2/logs" +}; + +export const DatadogProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const isUpdate = Boolean(auditLogStream); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: auditLogStream ?? { + provider: LogProvider.Datadog, + credentials: { + url: DATADOG_ENDPOINTS["Datadog US1"] + } + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/SplunkProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/SplunkProviderAuditLogStreamForm.tsx new file mode 100644 index 000000000..c9d248d61 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/SplunkProviderAuditLogStreamForm.tsx @@ -0,0 +1,120 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, SecretInput } from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TSplunkProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/splunk-provider"; + +type Props = { + auditLogStream?: TSplunkProviderLogStream; + onSubmit: (formData: FormData) => void; +}; + +const formSchema = z.object({ + provider: z.literal(LogProvider.Splunk), + credentials: z.object({ + hostname: z + .string() + .trim() + .min(1) + .max(255) + .superRefine((val, ctx) => { + if (val.includes("://")) { + ctx.addIssue({ + code: "custom", + message: "Hostname should not include protocol" + }); + return; + } + + try { + const url = new URL(`https://${val}`); + if (url.hostname !== val) { + ctx.addIssue({ + code: "custom", + message: "Must be a valid hostname without port or path" + }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid hostname" }); + } + }), + token: z.string().uuid().trim().min(1) + }) +}); + +type FormData = z.infer; + +export const SplunkProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const isUpdate = Boolean(auditLogStream); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: auditLogStream ?? { + provider: LogProvider.Splunk + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx index beb0f63db..b58d1a6eb 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx @@ -1,72 +1,24 @@ -import { faPlug, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -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 -} from "@app/components/v2"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - useOrganization, - useSubscription -} from "@app/context"; +import { Button } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, 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"; +import { AuditLogStreamTable } from "./components/AuditLogStreamTable"; +import { AddAuditLogStreamModal } from "./components"; 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, isPending: 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" - }); - } - }; + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "auditLogStreamForm", + "upgradePlan" + ] as const); return (
@@ -84,8 +36,10 @@ export const AuditLogStreamsTab = withPermission( }} leftIcon={} isDisabled={!isAllowed} + variant="outline_bg" + colorSchema="secondary" > - Create + Add Log Stream )} @@ -93,102 +47,15 @@ export const AuditLogStreamsTab = withPermission(

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")} - /> - - + onOpenChange={(isOpen) => handlePopUpToggle("auditLogStreamForm", isOpen)} + /> 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} + text="You can add audit log streams if you switch to Infisical's Enterprise plan." />
); diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AddAuditLogStreamModal.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AddAuditLogStreamModal.tsx new file mode 100644 index 000000000..5a6892a49 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AddAuditLogStreamModal.tsx @@ -0,0 +1,69 @@ +import { Dispatch, SetStateAction, useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TAuditLogStream } from "@app/hooks/api/types"; + +import { AuditLogStreamForm } from "../AuditLogStreamForm/AuditLogStreamForm"; +import { LogStreamProviderSelect } from "./LogStreamProviderSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (auditLogStream: TAuditLogStream) => void; + selectedProvider: LogProvider | null; + setSelectedProvider: Dispatch>; +}; + +const Content = ({ onComplete, selectedProvider, setSelectedProvider }: ContentProps) => { + if (selectedProvider) { + return ( + setSelectedProvider(null)} + provider={selectedProvider} + /> + ); + } + + return ; +}; + +export const AddAuditLogStreamModal = ({ isOpen, onOpenChange }: Props) => { + const [selectedProvider, setSelectedProvider] = useState(null); + + return ( + { + onOpenChange(e); + if (!e) setSelectedProvider(null); + }} + > + + Select a log provider or{" "} + {" "} + to stream logs to. + + > + onOpenChange(false)} + selectedProvider={selectedProvider} + setSelectedProvider={setSelectedProvider} + /> + + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamHeader.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamHeader.tsx new file mode 100644 index 000000000..3175ffa46 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamHeader.tsx @@ -0,0 +1,69 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; + +type Props = { + provider: LogProvider; + logStreamExists: boolean; + onBack?: () => void; +}; + +export const AuditLogStreamHeader = ({ provider, logStreamExists, onBack }: Props) => { + const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider]; + + return ( +
+
+ {providerDetails.image ? ( + {providerDetails.name} + ) : ( + providerDetails.icon && ( +
+ +
+ ) + )} +
+
+
+ {providerDetails.name} + +
+ + Docs + +
+
+
+

+ {logStreamExists + ? `${providerDetails.name} Log Stream` + : `Create a ${providerDetails.name} Log Stream`} +

+
+ {onBack && ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamRow.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamRow.tsx new file mode 100644 index 000000000..037ba6f2a --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamRow.tsx @@ -0,0 +1,111 @@ +import { faAsterisk, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionActions } from "@app/context/OrgPermissionContext/types"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP, getProviderUrl } from "@app/helpers/auditLogStreams"; +import { TAuditLogStream } from "@app/hooks/api/types"; + +type Props = { + logStream: TAuditLogStream; + onDelete: (logStream: TAuditLogStream) => void; + onEditCredentials: (logStream: TAuditLogStream) => void; +}; + +export const AuditLogStreamRow = ({ logStream, onDelete, onEditCredentials }: Props) => { + const { id, provider } = logStream; + + const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider]; + const url = getProviderUrl(logStream); + + return ( + + +
+
+ {providerDetails.image ? ( + {providerDetails.name} + ) : ( + providerDetails.icon && ( + + ) + )} +
+ {providerDetails.name} +
+ + +
+

{url}

+
+ + +
+ + + + + + + + + + {(isAllowed: boolean) => ( + } + onClick={() => onEditCredentials(logStream)} + > + Edit Credentials + + )} + + + {(isAllowed: boolean) => ( + } + onClick={() => onDelete(logStream)} + > + Delete Stream + + )} + + + + +
+ + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamTable.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamTable.tsx new file mode 100644 index 000000000..152f1d1c7 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/AuditLogStreamTable.tsx @@ -0,0 +1,304 @@ +import { useMemo, useState } from "react"; +import { + faArrowDown, + faArrowUp, + faFilter, + faMagnifyingGlass, + faPlug, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP, getProviderUrl } from "@app/helpers/auditLogStreams"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { useListAuditLogStreams } from "@app/hooks/api"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { TAuditLogStream } from "@app/hooks/api/types"; + +import { AuditLogStreamRow } from "./AuditLogStreamRow"; +import { DeleteAuditLogStreamModal } from "./DeleteAuditLogStreamModal"; +import { EditAuditLogStreamCredentialsModal } from "./EditAuditLogStreamCredentialsModal"; + +enum LogStreamsOrderBy { + Provider = "provider", + Url = "url" +} + +type LogStreamFilters = { + providers: LogProvider[]; +}; + +export const AuditLogStreamTable = () => { + const { isPending, data: logStreams = [] } = useListAuditLogStreams(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "delete", + "editCredentials" + ] as const); + + const [filters, setFilters] = useState({ + providers: [] + }); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(LogStreamsOrderBy.Provider, { + initPerPage: getUserTablePreference("logStreamsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("logStreamsTable", PreferenceKey.PerPage, newPerPage); + }; + + const filteredLogStreams = useMemo( + () => + logStreams + .filter((stream) => { + const { provider } = stream; + + if (filters.providers.length && !filters.providers.includes(provider)) return false; + + const searchValue = search.trim().toLowerCase(); + + return AUDIT_LOG_STREAM_PROVIDER_MAP[provider].name.toLowerCase().includes(searchValue); + }) + .sort((a, b) => { + const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case LogStreamsOrderBy.Url: + return getProviderUrl(one) + .toLowerCase() + .localeCompare(getProviderUrl(two).toLowerCase()); + case LogStreamsOrderBy.Provider: + default: + return AUDIT_LOG_STREAM_PROVIDER_MAP[one.provider].name + .toLowerCase() + .localeCompare(AUDIT_LOG_STREAM_PROVIDER_MAP[two.provider].name.toLowerCase()); + } + }), + [logStreams, orderDirection, search, orderBy, filters] + ); + + useResetPageHelper({ + totalCount: filteredLogStreams.length, + offset, + setPage + }); + + const handleSort = (column: LogStreamsOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: LogStreamsOrderBy) => + twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: LogStreamsOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + + const isTableFiltered = Boolean(filters.providers.length); + + const handleDelete = (logStream: TAuditLogStream) => handlePopUpOpen("delete", logStream); + + const handleEditCredentials = (logStream: TAuditLogStream) => { + handlePopUpOpen("editCredentials", logStream); + }; + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search audit log streams..." + className="flex-1" + /> + + + + + + + + Filter by Provider + {logStreams.length ? ( + [...new Set(logStreams.map(({ provider }) => provider))].map((provider) => { + const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider]; + + return ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + providers: prev.providers.includes(provider) + ? prev.providers.filter((a) => a !== provider) + : [...prev.providers, provider] + })); + }} + key={provider} + iconPos="right" + > +
+ {providerDetails.image ? ( + {providerDetails.name} + ) : ( + providerDetails.icon && ( + + ) + )} + {providerDetails.name} +
+
+ ); + }) + ) : ( + No Providers Configured + )} +
+
+
+ + + + + + + + + + + {isPending && ( + + )} + {filteredLogStreams.slice(offset, perPage * page).map((stream) => ( + + ))} + +
+
+ Provider + handleSort(LogStreamsOrderBy.Provider)} + > + + +
+
+
+ Endpoint URL + handleSort(LogStreamsOrderBy.Url)} + > + + +
+
+
+ {Boolean(filteredLogStreams.length) && ( + + )} + {!isPending && !filteredLogStreams?.length && ( + + )} +
+ handlePopUpToggle("delete", isOpen)} + auditLogStream={popUp.delete.data} + /> + handlePopUpToggle("editCredentials", isOpen)} + auditLogStream={popUp.editCredentials.data} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/DeleteAuditLogStreamModal.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/DeleteAuditLogStreamModal.tsx new file mode 100644 index 000000000..1a65e6c67 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/DeleteAuditLogStreamModal.tsx @@ -0,0 +1,54 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams"; +import { useDeleteAuditLogStream } from "@app/hooks/api"; +import { TAuditLogStream } from "@app/hooks/api/types"; + +type Props = { + auditLogStream?: TAuditLogStream; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const DeleteAuditLogStreamModal = ({ isOpen, onOpenChange, auditLogStream }: Props) => { + const deleteAuditLogStream = useDeleteAuditLogStream(); + + if (!auditLogStream) return null; + + const { id: auditLogStreamId, provider } = auditLogStream; + + const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider]; + + const handleDelete = async () => { + try { + await deleteAuditLogStream.mutateAsync({ + auditLogStreamId, + provider + }); + + createNotification({ + text: `Successfully deleted ${providerDetails.name} stream`, + type: "success" + }); + + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to delete ${providerDetails.name} stream`, + type: "error" + }); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/EditAuditLogStreamCredentialsModal.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/EditAuditLogStreamCredentialsModal.tsx new file mode 100644 index 000000000..6e21b10fe --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/EditAuditLogStreamCredentialsModal.tsx @@ -0,0 +1,34 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams"; +import { TAuditLogStream } from "@app/hooks/api/types"; + +import { AuditLogStreamForm } from "../AuditLogStreamForm/AuditLogStreamForm"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + auditLogStream?: TAuditLogStream; +}; + +export const EditAuditLogStreamCredentialsModal = ({ + isOpen, + onOpenChange, + auditLogStream +}: Props) => { + if (!auditLogStream) return null; + + return ( + + + onOpenChange(false)} + auditLogStream={auditLogStream} + /> + + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx new file mode 100644 index 000000000..26708e17b --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx @@ -0,0 +1,155 @@ +import { useMemo } from "react"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { EmptyState, Spinner } from "@app/components/v2"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetAuditLogStreamOptions } from "@app/hooks/api"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; + +type Props = { + onSelect: (provider: LogProvider) => void; +}; + +// TODO: When we have more than 1 page of providers, uncomment the search components + +export const LogStreamProviderSelect = ({ onSelect }: Props) => { + const { isPending, data: logStreamOptions } = useGetAuditLogStreamOptions(); + + const { search, setPage, page, perPage, offset } = usePagination("", { + initPerPage: 16 + }); + + const filteredOptions = useMemo( + () => + (logStreamOptions || []) + .filter( + ({ name, provider }) => + name.toLowerCase().includes(search.trim().toLowerCase()) || + provider.toLowerCase().includes(search.trim().toLowerCase()) + ) + .sort((a, b) => { + if (a.provider === LogProvider.Custom) return 1; + if (b.provider === LogProvider.Custom) return -1; + return 0; + }), + [logStreamOptions, search] + ); + + useResetPageHelper({ + totalCount: filteredOptions.length, + offset, + setPage + }); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ {/* setSearch(e.target.value)} + leftIcon={} + placeholder="Search options..." + className="bg-mineshaft-800 placeholder:text-mineshaft-400" + /> */} +
+ {filteredOptions.slice(offset, perPage * page)?.map((option) => { + const { image, icon, name, size = 50 } = AUDIT_LOG_STREAM_PROVIDER_MAP[option.provider]; + + return ( + + ); + })} + {!filteredOptions.length && ( + + )} +
+ {/* {Boolean(filteredOptions.length) && ( + +

Infisical is constantly adding support for more providers.

+

+ {`If you don't see the third-party + provider you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ + Don't see the third-party provider you're looking for? + + +
+ + } + count={filteredOptions.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + perPageList={[16]} + /> + )} */} +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/index.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/index.tsx new file mode 100644 index 000000000..69febb4a9 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/index.tsx @@ -0,0 +1 @@ +export * from "./AddAuditLogStreamModal";