Merge pull request #4483 from Infisical/ENG-3663

Audit Log Stream Rework
This commit is contained in:
x032205
2025-09-05 18:40:48 -04:00
committed by GitHub
75 changed files with 3007 additions and 1097 deletions

View File

@@ -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";

View File

@@ -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<void> {
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<Awaited<ReturnType<(typeof kmsService)["createCipherPairWithDataKey"]>>>(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<void> {
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<Awaited<ReturnType<(typeof kmsService)["createCipherPairWithDataKey"]>>>(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");
});
}
}

View File

@@ -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<typeof AuditLogStreamsSchema>;

View File

@@ -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 };
}
});
};

View File

@@ -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 = <T extends TAuditLogStream>({
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 };
}
});
};

View File

@@ -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 };
}
});
};

View File

@@ -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<LogProvider, (server: FastifyZodProvider) => Promise<void>> =
{
[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
});
}
};

View File

@@ -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) => {

View File

@@ -0,0 +1,5 @@
export enum LogProvider {
Datadog = "datadog",
Splunk = "splunk",
Custom = "custom"
}

View File

@@ -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<TAuditLogStreamCredentials>;
export const LOG_STREAM_FACTORY_MAP: Record<LogProvider, TLogStreamFactoryImplementation> = {
[LogProvider.Datadog]: DatadogProviderFactory as TLogStreamFactoryImplementation,
[LogProvider.Splunk]: SplunkProviderFactory as TLogStreamFactoryImplementation,
[LogProvider.Custom]: CustomProviderFactory as TLogStreamFactoryImplementation
};

View File

@@ -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<string, string> = {};
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<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...logStream,
credentials: await decryptLogStreamCredentials({
encryptedCredentials: logStream.encryptedCredentials,
orgId: logStream.orgId,
kmsService
})
} as TAuditLogStream;
};

View File

@@ -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
});

View File

@@ -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<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export type TAuditLogStreamServiceFactory = ReturnType<typeof auditLogStreamServiceFactory>;
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<Record<string, string>>((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
};
};

View File

@@ -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<TOrgPermission, "orgId"> & {
url: string;
headers?: LogStreamHeaders[];
export type TUpdateAuditLogStreamDTO = {
logStreamId: string;
provider: LogProvider;
credentials: TAuditLogStreamCredentials;
};
export type TUpdateAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & {
id: string;
url?: string;
headers?: LogStreamHeaders[];
};
export type TLogStreamFactoryValidateCredentials<C extends TAuditLogStreamCredentials> = (input: {
credentials: C;
}) => Promise<C>;
export type TDeleteAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & {
id: string;
};
export type TLogStreamFactoryStreamLog<C extends TAuditLogStreamCredentials> = (input: {
credentials: C;
auditLog: TAuditLogs;
}) => Promise<void>;
export type TListAuditLogStreamDTO = Omit<TOrgPermission, "orgId">;
export type TGetDetailsAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & {
id: string;
};
export type TAuditLogStreamServiceFactory = {
create: (arg: TCreateAuditLogStreamDTO) => Promise<TAuditLogStreams>;
updateById: (arg: TUpdateAuditLogStreamDTO) => Promise<TAuditLogStreams>;
deleteById: (arg: TDeleteAuditLogStreamDTO) => Promise<TAuditLogStreams>;
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<TAuditLogStreams[]>;
export type TLogStreamFactory<C extends TAuditLogStreamCredentials> = () => {
validateCredentials: TLogStreamFactoryValidateCredentials<C>;
streamLog: TLogStreamFactoryStreamLog<C>;
};

View File

@@ -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<TCustomProviderCredentials> = 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<TCustomProviderCredentials> = 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
};
};

View File

@@ -0,0 +1,8 @@
import { LogProvider } from "../audit-log-stream-enums";
export const getCustomProviderListItem = () => {
return {
name: "Custom" as const,
provider: LogProvider.Custom as const
};
};

View File

@@ -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
});

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import { CustomProviderCredentialsSchema, CustomProviderSchema } from "./custom-provider-schemas";
export type TCustomProvider = z.infer<typeof CustomProviderSchema>;
export type TCustomProviderCredentials = z.infer<typeof CustomProviderCredentialsSchema>;

View File

@@ -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<string, unknown>) {
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<TDatadogProviderCredentials> = 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<TDatadogProviderCredentials> = 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
};
};

View File

@@ -0,0 +1,8 @@
import { LogProvider } from "../audit-log-stream-enums";
export const getDatadogProviderListItem = () => {
return {
name: "Datadog" as const,
provider: LogProvider.Datadog as const
};
};

View File

@@ -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
});

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import { DatadogProviderCredentialsSchema, DatadogProviderSchema } from "./datadog-provider-schemas";
export type TDatadogProvider = z.infer<typeof DatadogProviderSchema>;
export type TDatadogProviderCredentials = z.infer<typeof DatadogProviderCredentialsSchema>;

View File

@@ -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<string, unknown>) {
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<TSplunkProviderCredentials> = 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<TSplunkProviderCredentials> = 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
};
};

View File

@@ -0,0 +1,8 @@
import { LogProvider } from "../audit-log-stream-enums";
export const getSplunkProviderListItem = () => {
return {
name: "Splunk" as const,
provider: LogProvider.Splunk as const
};
};

View File

@@ -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
});

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import { SplunkProviderCredentialsSchema, SplunkProviderSchema } from "./splunk-provider-schemas";
export type TSplunkProvider = z.infer<typeof SplunkProviderSchema>;
export type TSplunkProviderCredentials = z.infer<typeof SplunkProviderCredentialsSchema>;

View File

@@ -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<TAuditLogStreamDALFactory, "find">;
auditLogStreamService: Pick<TAuditLogStreamServiceFactory, "streamLog">;
queueService: TQueueServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -35,7 +27,7 @@ export const auditLogQueueServiceFactory = async ({
queueService,
projectDAL,
licenseService,
auditLogStreamDAL
auditLogStreamService
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
const pushToLog = async (data: TCreateAuditLogDTO) => {
await queueService.queue<QueueName.AuditLog>(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);
}
});

View File

@@ -250,8 +250,11 @@ const cryptographyFactory = () => {
};
};
const encryptWithRootEncryptionKey = (data: string) => {
const appCfg = getConfig();
const encryptWithRootEncryptionKey = (
data: string,
appCfgOverride?: Pick<TEnvConfig, "ROOT_ENCRYPTION_KEY" | "ENCRYPTION_KEY">
) => {
const appCfg = appCfgOverride || getConfig();
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
const encryptionKey = appCfg.ENCRYPTION_KEY;

View File

@@ -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,

View File

@@ -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,

View File

@@ -6,84 +6,133 @@ description: "Learn how to stream Infisical Audit Logs to external logging provi
<Info>
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.
</Info>
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
<Steps>
<Step title="Navigate to Organization Settings in your sidebar." />
<Step title="Select Audit Log Streams Tab.">
![stream create](/images/platform/audit-log-streams/stream-create.png)
</Step>
<Step title="Click on Create">
![stream create](/images/platform/audit-log-streams/stream-inputs.png)
<Step title="Create Stream">
1. Navigate to **Organization Settings**
2. Select the **Audit Log Streams** tab
3. Click **Add Log Stream**
Provide the following values
<ParamField path="Endpoint URL" type="string" required>
The HTTPS endpoint URL of the logging provider that collects the JSON stream.
</ParamField>
<ParamField path="Headers" type="string" >
The HTTP headers for the logging provider for identification and authentication.
</ParamField>
![stream create](/images/platform/audit-log-streams/stream-create.png)
</Step>
<Step title="Select Provider">
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)
</Step>
<Step title="Input Credentials">
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**.
</Step>
<Step title="Log Stream Created">
Your audit logs are now ready to be streamed.
![stream list](/images/platform/audit-log-streams/stream-list.png)
</Step>
</Steps>
![stream listt](/images/platform/audit-log-streams/stream-list.png)
Your Audit Logs are now ready to be streamed.
## Example Providers
### Better Stack
<AccordionGroup>
<Accordion title="Better Stack">
You can stream to Better Stack using a **Custom** log stream.
<Steps>
<Step title="Select Connect Source">
![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png)
</Step>
<Step title="Provide a name and select platform"/>
<Step title="Provide Audit Log Stream inputs">
![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png)
<Steps>
<Step title="Connect Source">
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 \<source token from betterstack\>**.
</Step>
</Steps>
![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.
<Steps>
<Step title="Navigate to API Keys section">
![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png)
</Step>
<Step title="Select New Key and provide a key name">
![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)
</Step>
<Step title="Find your Datadog region specific logging endpoint.">
![datadog url](/images/platform/audit-log-streams/datadog-logging-endpoint.png)
![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png)
</Step>
<Step title="Create Audit Log Stream on Infisical">
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.
</Step>
<Step title="Provide audit log stream inputs">
![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**.
</Step>
</Steps>
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 <betterstack-src-token>`
## 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**.
</Step>
</Steps>
</Accordion>
<Accordion title="Datadog">
You can stream to Datadog using the **Datadog** provider log stream.
<Steps>
<Step title="Navigate to API Keys section">
![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png)
</Step>
<Step title="Select New Key and provide a key name">
![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)
</Step>
<Step title="Create Audit Log Stream on Infisical">
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**.
</Step>
</Steps>
</Accordion>
<Accordion title="Splunk">
You can stream to Splunk using the **Splunk** provider log stream.
<Steps>
<Step title="Obtain Splunk Token">
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)
</Step>
<Step title="Create Audit Log Stream on Infisical">
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**.
</Step>
</Steps>
</Accordion>
</AccordionGroup>
### Example Log Entry
@@ -117,106 +166,109 @@ Each log entry sent to the external logging provider will follow the same struct
```
### Audit Logs Structure
<Warning>
Streamed audit log structure **varies based on provider**, but they all share the audit log fields shown below.
</Warning>
<ParamField path="id" type="string" required>
The unique identifier for the log entry.
The unique identifier for the log entry.
</ParamField>
<ParamField path="actor" type="platform | user | service | identity | scimClient | unknownUser" required>
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.
</ParamField>
<ParamField path="actorMetadata" type="object" required>
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.
<Accordion title="User Metadata">
This metadata is present when the `actor` field is set to `user`.
<AccordionGroup>
<Accordion title="User Metadata">
This metadata is present when the `actor` field is set to `user`.
<ParamField path="userId" type="string" required>
The unique identifier for the actor.
</ParamField>
<ParamField path="email" type="string" required>
The email address of the actor.
</ParamField>
<ParamField path="username" type="string" required>
The username of the actor.
</ParamField>
</Accordion>
<ParamField path="userId" type="string" required>
The unique identifier for the actor.
</ParamField>
<ParamField path="email" type="string" required>
The email address of the actor.
</ParamField>
<ParamField path="username" type="string" required>
The username of the actor.
</ParamField>
</Accordion>
<Accordion title="Identity Metadata">
This metadata is present when the `actor` field is set to `identity`.
<Accordion title="Identity Metadata">
This metadata is present when the `actor` field is set to `identity`.
<ParamField path="identityId" type="string" required>
The unique identifier for the identity.
</ParamField>
<ParamField path="name" type="string" required>
The name of the identity.
</ParamField>
</Accordion>
<Accordion title="Service Token Metadata">
This metadata is present when the `actor` field is set to `service`.
<ParamField path="identityId" type="string" required>
The unique identifier for the identity.
</ParamField>
<ParamField path="name" type="string" required>
The name of the identity.
</ParamField>
</Accordion>
<Accordion title="Service Token Metadata">
This metadata is present when the `actor` field is set to `service`.
<ParamField path="serviceId" type="string" required>
The unique identifier for the service.
</ParamField>
<ParamField path="name" type="string" required>
The name of the service.
</ParamField>
</Accordion>
<Note>
If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object.
</Note>
<ParamField path="serviceId" type="string" required>
The unique identifier for the service.
</ParamField>
<ParamField path="name" type="string" required>
The name of the service.
</ParamField>
</Accordion>
</AccordionGroup>
<Note>
If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object.
</Note>
</ParamField>
<ParamField path="ipAddress" type="string" required>
The IP address of the actor.
The IP address of the actor.
</ParamField>
<ParamField path="eventType" type="string" required>
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`.
</ParamField>
<ParamField path="eventMetadata" type="object" required>
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.
</ParamField>
<ParamField path="userAgent" type="string">
The user agent of the actor, if applicable.
The user agent of the actor, if applicable.
</ParamField>
<ParamField path="userAgentType" type="web | cli | k8-operator | terraform | other | InfisicalPythonSDK | InfisicalNodeSDK">
The type of user agent.
The type of user agent.
</ParamField>
<ParamField path="expiresAt" type="string" required>
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.
</ParamField>
<ParamField path="createdAt" type="string" required>
The creation date of the log entry.
The creation date of the log entry.
</ParamField>
<ParamField path="updatedAt" type="string" required>
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.
</ParamField>
<ParamField path="orgId" type="string" required>
The unique identifier for the organization where the event occurred.
The unique identifier for the organization where the event occurred.
</ParamField>
<ParamField path="projectId" type="string">
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.
</ParamField>
<ParamField path="projectName" type="string">
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.
</ParamField>

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -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<TAuditLogStream, "provider" | "credentials">
) {
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}`
);
}
}

View File

@@ -0,0 +1,5 @@
export enum LogProvider {
Datadog = "datadog",
Splunk = "splunk",
Custom = "custom"
}

View File

@@ -1,6 +1,2 @@
export {
useCreateAuditLogStream,
useDeleteAuditLogStream,
useUpdateAuditLogStream
} from "./mutations";
export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries";
export * from "./mutations";
export * from "./queries";

View File

@@ -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)
});
}
});
};

View File

@@ -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<typeof auditLogStreamKeys.options>
>,
"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<typeof auditLogStreamKeys.list>
>,
"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 = <T extends LogProvider>(
provider: T,
logStreamId: string,
options?: Omit<
UseQueryOptions<
TAuditLogStreamProviderMap[T],
unknown,
TAuditLogStreamProviderMap[T],
ReturnType<typeof auditLogStreamKeys.getById>
>,
"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
});
};

View File

@@ -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;
};

View File

@@ -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<TAuditLogStream, "provider" | "credentials">;
export type TUpdateAuditLogStreamDTO = Pick<TAuditLogStream, "credentials"> & {
provider: LogProvider;
auditLogStreamId: string;
};
export type TDeleteAuditLogStreamDTO = {
provider: LogProvider;
auditLogStreamId: string;
};

View File

@@ -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];

View File

@@ -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 }[];
};
};

View File

@@ -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;
};
};

View File

@@ -0,0 +1,6 @@
export type TRootProviderLogStream = {
id: string;
orgId: string;
createdAt: string;
updatedAt: string;
};

View File

@@ -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;
};
};

View File

@@ -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;

View File

@@ -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<typeof formSchema>;
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<TForm>({
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 (
<div className="flex items-center justify-center p-8">
<Spinner size="lg" />
</div>
);
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Endpoint URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<FormLabel label="Headers" isOptional />
{headerFields.fields.map(({ id: headerFieldId }, i) => (
<div key={headerFieldId} className="flex space-x-2">
<Controller
control={control}
name={`headers.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-1/3"
>
<Input {...field} placeholder="Authorization" />
</FormControl>
)}
/>
<Controller
control={control}
name={`headers.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
autoComplete="new-password"
/>
</FormControl>
)}
/>
<IconButton
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
const header = getValues("headers");
if (header && header?.length > 1) {
headerFields.remove(i);
} else {
setValue("headers", [{ key: "", value: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => headerFields.append({ value: "", key: "" })}
>
Add Key
</Button>
</div>
</div>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}
</Button>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -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<TAuditLogStream, "provider" | "credentials">
) => {
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 <CustomProviderAuditLogStreamForm onSubmit={onSubmit} />;
case LogProvider.Datadog:
return <DatadogProviderAuditLogStreamForm onSubmit={onSubmit} />;
case LogProvider.Splunk:
return <SplunkProviderAuditLogStreamForm onSubmit={onSubmit} />;
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<TAuditLogStream, "provider" | "credentials">
) => {
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 (
<CustomProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
);
case LogProvider.Datadog:
return (
<DatadogProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
);
case LogProvider.Splunk:
return (
<SplunkProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
);
default:
throw new Error(`Unhandled Provider: ${(auditLogStream as TAuditLogStream).provider}`);
}
};
type Props = { onBack?: () => void } & Pick<FormProps, "onComplete"> &
(
| { provider: LogProvider; auditLogStream?: undefined }
| { provider?: undefined; auditLogStream: TAuditLogStream }
);
export const AuditLogStreamForm = ({ onBack, ...props }: Props) => {
const { provider, auditLogStream } = props;
return (
<div>
<AuditLogStreamHeader
logStreamExists={Boolean(auditLogStream)}
provider={auditLogStream ? auditLogStream.provider : provider}
onBack={onBack}
/>
{auditLogStream ? (
<UpdateForm {...props} auditLogStream={auditLogStream} />
) : (
<CreateForm {...props} provider={provider} />
)}
</div>
);
};

View File

@@ -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<typeof formSchema>;
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
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 (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="credentials.url"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Endpoint URL"
>
<Input {...field} placeholder="https://example.com" />
</FormControl>
)}
/>
<FormLabel label="Headers" isOptional />
{headerFields.fields.map(({ id: headerFieldId }, i) => (
<div key={headerFieldId} className="flex space-x-2">
<Controller
control={control}
name={`credentials.headers.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-1/3"
>
<Input {...field} placeholder="Authorization" />
</FormControl>
)}
/>
<Controller
control={control}
name={`credentials.headers.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
autoComplete="new-password"
onFocus={(e) => {
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";
}}
/>
</FormControl>
)}
/>
<IconButton
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
const header = getValues("credentials.headers");
if (header && header?.length > 1) {
headerFields.remove(i);
} else {
setValue("credentials.headers", [{ key: "", value: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => headerFields.append({ value: "", key: "" })}
>
Add Key
</Button>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Create Log Stream"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -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<typeof formSchema>;
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<FormData>({
resolver: zodResolver(formSchema),
defaultValues: auditLogStream ?? {
provider: LogProvider.Datadog,
credentials: {
url: DATADOG_ENDPOINTS["Datadog US1"]
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="credentials.url"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Datadog Region"
helperText={value}
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.entries(DATADOG_ENDPOINTS).map(([k, v]) => {
return (
<SelectItem value={v} key={k}>
{k}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.token"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Datadog Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Create Log Stream"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -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<typeof formSchema>;
export const SplunkProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: auditLogStream ?? {
provider: LogProvider.Splunk
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="credentials.hostname"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Hostname"
>
<Input {...field} placeholder="splunk.example.com" />
</FormControl>
)}
/>
<Controller
name="credentials.token"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Splunk Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Create Log Stream"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -84,8 +36,10 @@ export const AuditLogStreamsTab = withPermission(
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
variant="outline_bg"
colorSchema="secondary"
>
Create
Add Log Stream
</Button>
)}
</OrgPermissionCan>
@@ -93,102 +47,15 @@ export const AuditLogStreamsTab = withPermission(
<p className="mb-8 text-gray-400">
Send audit logs from Infisical to external logging providers via HTTP
</p>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>URL</Td>
<Td className="text-right">Action</Td>
</Tr>
</THead>
<TBody>
{isAuditLogStreamsLoading && (
<TableSkeleton columns={2} innerKey="stream-loading" />
)}
{!isAuditLogStreamsLoading && auditLogStreams && auditLogStreams?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No audit log streams found" icon={faPlug} />
</Td>
</Tr>
)}
{!isAuditLogStreamsLoading &&
auditLogStreams?.map(({ id, url }) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("auditLogStreamForm", id)}
>
Edit
</Button>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteAuditLogStream", id)}
>
Delete
</Button>
)}
</OrgPermissionCan>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</div>
<Modal
<AuditLogStreamTable />
<AddAuditLogStreamModal
isOpen={popUp.auditLogStreamForm.isOpen}
onOpenChange={(isModalOpen) => {
handlePopUpToggle("auditLogStreamForm", isModalOpen);
}}
>
<ModalContent
title={`${popUp?.auditLogStreamForm?.data ? "Update" : "Create"} Audit Log Stream `}
subTitle="Continuously stream logs from Infisical to third-party logging providers."
>
<AuditLogStreamForm
id={popUp?.auditLogStreamForm?.data as string}
onClose={() => handlePopUpToggle("auditLogStreamForm")}
/>
</ModalContent>
</Modal>
onOpenChange={(isOpen) => handlePopUpToggle("auditLogStreamForm", isOpen)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
/>
<DeleteActionModal
isOpen={popUp.deleteAuditLogStream.isOpen}
deleteKey="delete"
title="Are you sure you want to remove this stream?"
onChange={(isOpen) => handlePopUpToggle("deleteAuditLogStream", isOpen)}
onClose={() => handlePopUpClose("deleteAuditLogStream")}
onDeleteApproved={handleAuditLogStreamDelete}
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
/>
</div>
);

View File

@@ -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<SetStateAction<LogProvider | null>>;
};
const Content = ({ onComplete, selectedProvider, setSelectedProvider }: ContentProps) => {
if (selectedProvider) {
return (
<AuditLogStreamForm
onComplete={onComplete}
onBack={() => setSelectedProvider(null)}
provider={selectedProvider}
/>
);
}
return <LogStreamProviderSelect onSelect={setSelectedProvider} />;
};
export const AddAuditLogStreamModal = ({ isOpen, onOpenChange }: Props) => {
const [selectedProvider, setSelectedProvider] = useState<LogProvider | null>(null);
return (
<Modal
isOpen={isOpen}
onOpenChange={(e) => {
onOpenChange(e);
if (!e) setSelectedProvider(null);
}}
>
<ModalContent
className="max-w-2xl"
title="Log Provider"
subTitle=<>
Select a log provider or{" "}
<button
type="button"
className="underline"
onClick={() => setSelectedProvider(LogProvider.Custom)}
>
input a custom URL
</button>{" "}
to stream logs to.
</>
>
<Content
onComplete={() => onOpenChange(false)}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -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 (
<div className="mb-4 flex w-full items-center gap-2 border-b border-mineshaft-500 pb-4">
<div className="relative">
{providerDetails.image ? (
<img
alt={providerDetails.name}
src={`/images/integrations/${providerDetails.image}`}
className="size-12 rounded-md bg-bunker-500 p-2"
/>
) : (
providerDetails.icon && (
<div className="size-12 rounded-md bg-bunker-500 p-2">
<FontAwesomeIcon
icon={providerDetails.icon}
className="h-full w-full text-mineshaft-300"
/>
</div>
)
)}
</div>
<div>
<div className="mb-1 flex items-center text-mineshaft-300">
{providerDetails.name}
<a
href="https://infisical.com/docs/documentation/platform/audit-log-streams/audit-log-streams#example-providers"
target="_blank"
className="ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-px mr-1 text-xs" />
<span>Docs</span>
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="mb-px ml-1 text-[10px]" />
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{logStreamExists
? `${providerDetails.name} Log Stream`
: `Create a ${providerDetails.name} Log Stream`}
</p>
</div>
{onBack && (
<button
type="button"
className="ml-auto mt-1 text-xs text-mineshaft-400 underline underline-offset-2 hover:text-mineshaft-300"
onClick={onBack}
>
Select another provider
</button>
)}
</div>
);
};

View File

@@ -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 (
<Tr
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
key={`log-stream-${id}`}
>
<Td>
<div className="flex items-center gap-2">
<div className="relative">
{providerDetails.image ? (
<img
alt={providerDetails.name}
src={`/images/integrations/${providerDetails.image}`}
className="size-5"
/>
) : (
providerDetails.icon && (
<FontAwesomeIcon
icon={providerDetails.icon}
className="size-5 text-mineshaft-300"
/>
)
)}
</div>
<span className="hidden lg:inline">{providerDetails.name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<p className="truncate">{url}</p>
</div>
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(logStream)}
>
Edit Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(logStream)}
>
Delete Stream
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
);
};

View File

@@ -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<LogStreamFilters>({
providers: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<LogStreamsOrderBy>(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 (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search audit log streams..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Log Streams"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Filter by Provider</DropdownMenuLabel>
{logStreams.length ? (
[...new Set(logStreams.map(({ provider }) => provider))].map((provider) => {
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
providers: prev.providers.includes(provider)
? prev.providers.filter((a) => a !== provider)
: [...prev.providers, provider]
}));
}}
key={provider}
iconPos="right"
>
<div className="flex items-center gap-2">
{providerDetails.image ? (
<img
alt={providerDetails.name}
src={`/images/integrations/${providerDetails.image}`}
className="h-4 w-4"
/>
) : (
providerDetails.icon && (
<FontAwesomeIcon
icon={providerDetails.icon}
className="size-4 text-mineshaft-300"
/>
)
)}
<span>{providerDetails.name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Providers Configured</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/4">
<div className="flex items-center">
Provider
<IconButton
variant="plain"
className={getClassName(LogStreamsOrderBy.Provider)}
ariaLabel="sort"
onClick={() => handleSort(LogStreamsOrderBy.Provider)}
>
<FontAwesomeIcon icon={getColSortIcon(LogStreamsOrderBy.Provider)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Endpoint URL
<IconButton
variant="plain"
className={getClassName(LogStreamsOrderBy.Url)}
ariaLabel="sort"
onClick={() => handleSort(LogStreamsOrderBy.Url)}
>
<FontAwesomeIcon icon={getColSortIcon(LogStreamsOrderBy.Url)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && (
<TableSkeleton
innerKey="audit-log-streams-table"
columns={3}
key="audit-log-streams"
/>
)}
{filteredLogStreams.slice(offset, perPage * page).map((stream) => (
<AuditLogStreamRow
logStream={stream}
key={stream.id}
onDelete={handleDelete}
onEditCredentials={handleEditCredentials}
/>
))}
</TBody>
</Table>
{Boolean(filteredLogStreams.length) && (
<Pagination
count={filteredLogStreams.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredLogStreams?.length && (
<EmptyState
title={
logStreams.length
? "No log streams match search..."
: "No log streams have been configured"
}
icon={logStreams.length ? faSearch : faPlug}
/>
)}
</TableContainer>
<DeleteAuditLogStreamModal
isOpen={popUp.delete.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("delete", isOpen)}
auditLogStream={popUp.delete.data}
/>
<EditAuditLogStreamCredentialsModal
isOpen={popUp.editCredentials.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)}
auditLogStream={popUp.editCredentials.data}
/>
</div>
);
};

View File

@@ -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 (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title="Are you sure you want to delete this log stream?"
deleteKey="delete"
onDeleteApproved={handleDelete}
/>
);
};

View File

@@ -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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Log Stream Credentials"
subTitle={`Update the credentials for this ${AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider].name} Log Stream.`}
>
<AuditLogStreamForm
onComplete={() => onOpenChange(false)}
auditLogStream={auditLogStream}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -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 (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* <Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search options..."
className="bg-mineshaft-800 placeholder:text-mineshaft-400"
/> */}
<div className="grid h-[29.5rem] grid-cols-4 content-start gap-2">
{filteredOptions.slice(offset, perPage * page)?.map((option) => {
const { image, icon, name, size = 50 } = AUDIT_LOG_STREAM_PROVIDER_MAP[option.provider];
return (
<button
type="button"
onClick={() => onSelect(option.provider)}
className={`group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 ${option.provider === LogProvider.Custom ? "bg-mineshaft-700/30 hover:bg-mineshaft-600/30" : "bg-mineshaft-700 hover:bg-mineshaft-600"} p-4 duration-200`}
>
{image && (
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
/>
</div>
)}
{icon && (
<FontAwesomeIcon className="mt-auto size-10 text-mineshaft-300" icon={icon} />
)}
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
{!filteredOptions.length && (
<EmptyState
className="col-span-full mt-40"
title="No providers match search"
icon={faSearch}
/>
)}
</div>
{/* {Boolean(filteredOptions.length) && (
<Pagination
startAdornment={
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more providers.</p>
<p>
{`If you don't see the third-party
provider you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="-ml-3 flex items-center gap-1.5 text-mineshaft-400">
<span className="text-xs">
Don&#39;t see the third-party provider you&#39;re looking for?
</span>
<FontAwesomeIcon size="xs" icon={faInfoCircle} />
</div>
</Tooltip>
}
count={filteredOptions.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
perPageList={[16]}
/>
)} */}
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./AddAuditLogStreamModal";