Merge pull request #4483 from Infisical/ENG-3663
Audit Log Stream Rework
2
backend/src/@types/fastify.d.ts
vendored
@@ -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";
|
||||
|
||||
221
backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
51
backend/src/ee/routes/v1/audit-log-stream-routers/index.ts
Normal 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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum LogProvider {
|
||||
Datadog = "datadog",
|
||||
Splunk = "splunk",
|
||||
Custom = "custom"
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { LogProvider } from "../audit-log-stream-enums";
|
||||
|
||||
export const getCustomProviderListItem = () => {
|
||||
return {
|
||||
name: "Custom" as const,
|
||||
provider: LogProvider.Custom as const
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { LogProvider } from "../audit-log-stream-enums";
|
||||
|
||||
export const getDatadogProviderListItem = () => {
|
||||
return {
|
||||
name: "Datadog" as const,
|
||||
provider: LogProvider.Datadog as const
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { LogProvider } from "../audit-log-stream-enums";
|
||||
|
||||
export const getSplunkProviderListItem = () => {
|
||||
return {
|
||||
name: "Splunk" as const,
|
||||
provider: LogProvider.Splunk as const
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.">
|
||||

|
||||
</Step>
|
||||
<Step title="Click on Create">
|
||||

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

|
||||
</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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
|
||||
Once you're finished, click **Create Log Stream**.
|
||||
</Step>
|
||||
<Step title="Log Stream Created">
|
||||
Your audit logs are now ready to be streamed.
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||

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

|
||||
</Step>
|
||||
<Step title="Provide a name and select platform"/>
|
||||
<Step title="Provide Audit Log Stream inputs">
|
||||

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

|
||||
|
||||
### 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">
|
||||

|
||||
</Step>
|
||||
<Step title="Select New Key and provide a key name">
|
||||

|
||||

|
||||
</Step>
|
||||
<Step title="Find your Datadog region specific logging endpoint.">
|
||||

|
||||

|
||||
</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">
|
||||

|
||||

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

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

|
||||
</Step>
|
||||
<Step title="Select New Key and provide a key name">
|
||||

|
||||

|
||||
</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.
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
Click on **HTTP Event Collector**.
|
||||
|
||||

|
||||
|
||||
Click on **New Token** in the top left.
|
||||
|
||||

|
||||
|
||||
Provide a name and click **Next**.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
|
||||
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>
|
||||
|
||||
BIN
docs/images/platform/audit-log-streams/custom-provider.png
Normal file
|
After Width: | Height: | Size: 462 KiB |
BIN
docs/images/platform/audit-log-streams/datadog-details.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 38 KiB |
BIN
docs/images/platform/audit-log-streams/select-custom.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/images/platform/audit-log-streams/select-provider.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
docs/images/platform/audit-log-streams/splunk-credentials.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
docs/images/platform/audit-log-streams/splunk-data-inputs.png
Normal file
|
After Width: | Height: | Size: 523 KiB |
BIN
docs/images/platform/audit-log-streams/splunk-details.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/images/platform/audit-log-streams/splunk-http-collector.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
docs/images/platform/audit-log-streams/splunk-name.png
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
docs/images/platform/audit-log-streams/splunk-new-token.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 698 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 702 KiB |
BIN
frontend/public/images/integrations/Datadog.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
frontend/public/images/integrations/Splunk.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
32
frontend/src/helpers/auditLogStreams.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
5
frontend/src/hooks/api/auditLogStreams/enums.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum LogProvider {
|
||||
Datadog = "datadog",
|
||||
Splunk = "splunk",
|
||||
Custom = "custom"
|
||||
}
|
||||
@@ -1,6 +1,2 @@
|
||||
export {
|
||||
useCreateAuditLogStream,
|
||||
useDeleteAuditLogStream,
|
||||
useUpdateAuditLogStream
|
||||
} from "./mutations";
|
||||
export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
25
frontend/src/hooks/api/auditLogStreams/types/index.ts
Normal 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;
|
||||
};
|
||||
@@ -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];
|
||||
@@ -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 }[];
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TRootProviderLogStream = {
|
||||
id: string;
|
||||
orgId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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't see the third-party provider you'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AddAuditLogStreamModal";
|
||||