diff --git a/backend/e2e-test/routes/v3/secret-reference.spec.ts b/backend/e2e-test/routes/v3/secret-reference.spec.ts index 560565342..a5d8ba61d 100644 --- a/backend/e2e-test/routes/v3/secret-reference.spec.ts +++ b/backend/e2e-test/routes/v3/secret-reference.spec.ts @@ -314,8 +314,8 @@ describe("Secret expansion", () => { expect(listSecrets.imports).toEqual( expect.arrayContaining([ expect.objectContaining({ - secretPath: `/__reserve_replication_${secretImportFromProdToDev.id}`, - environment: seedData1.environment.slug, + secretPath: "/deep/nested", + environment: "prod", secrets: expect.arrayContaining([ expect.objectContaining({ secretKey: "NESTED_KEY_1", diff --git a/backend/package-lock.json b/backend/package-lock.json index f22fd0b7b..c6ac0b147 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "@fastify/multipart": "8.3.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", + "@fastify/reply-from": "^9.8.0", "@fastify/request-context": "^5.1.0", "@fastify/session": "^10.7.0", "@fastify/static": "^7.0.4", @@ -8044,6 +8045,42 @@ "toad-cache": "^3.3.0" } }, + "node_modules/@fastify/reply-from": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz", + "integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.0.0", + "end-of-stream": "^1.4.4", + "fast-content-type-parse": "^1.1.0", + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0", + "toad-cache": "^3.7.0", + "undici": "^5.19.1" + } + }, + "node_modules/@fastify/reply-from/node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@fastify/reply-from/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@fastify/request-context": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz", @@ -29330,9 +29367,10 @@ } }, "node_modules/toad-cache": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz", - "integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", "engines": { "node": ">=12" } diff --git a/backend/package.json b/backend/package.json index aaef9f567..0c8464eaf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -145,6 +145,7 @@ "@fastify/multipart": "8.3.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", + "@fastify/reply-from": "^9.8.0", "@fastify/request-context": "^5.1.0", "@fastify/session": "^10.7.0", "@fastify/static": "^7.0.4", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c25d8d4d1..b0a5fbb45 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,13 +1,13 @@ import "fastify"; -import { Redis } from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; 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"; @@ -83,6 +83,8 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; @@ -161,6 +163,7 @@ declare module "fastify" { }; // identity injection. depending on which kinda of token the information is filled in auth auth: TAuthMode; + shouldForwardWritesToPrimaryInstance: boolean; permission: { authMethod: ActorAuthMethod; type: ActorType; @@ -194,7 +197,7 @@ declare module "fastify" { } interface FastifyInstance { - redis: Redis; + redis: Redis | Cluster; services: { login: TAuthLoginFactory; password: TAuthPasswordFactory; @@ -303,6 +306,8 @@ declare module "fastify" { bus: TEventBusService; sse: TServerSentEventsService; identityAuthTemplate: TIdentityAuthTemplateServiceFactory; + notification: TNotificationServiceFactory; + offlineUsageReport: TOfflineUsageReportServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f645cb8f2..525ad1619 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -530,6 +530,11 @@ import { TSecretReminderRecipientsInsert, TSecretReminderRecipientsUpdate } from "@app/db/schemas/secret-reminder-recipients"; +import { + TUserNotifications, + TUserNotificationsInsert, + TUserNotificationsUpdate +} from "@app/db/schemas/user-notifications"; declare module "knex" { namespace Knex { @@ -1254,5 +1259,10 @@ declare module "knex/types/tables" { TRemindersRecipientsInsert, TRemindersRecipientsUpdate >; + [TableName.UserNotifications]: KnexOriginal.CompositeTableType< + TUserNotifications, + TUserNotificationsInsert, + TUserNotificationsUpdate + >; } } diff --git a/backend/src/db/migrations/20250829203610_user-notifications.ts b/backend/src/db/migrations/20250829203610_user-notifications.ts new file mode 100644 index 000000000..6fabfa882 --- /dev/null +++ b/backend/src/db/migrations/20250829203610_user-notifications.ts @@ -0,0 +1,50 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.UserNotifications))) { + const createTableSql = knex.schema + .createTable(TableName.UserNotifications, (t) => { + t.uuid("id").defaultTo(knex.fn.uuid()); + t.uuid("userId").notNullable(); + t.uuid("orgId").nullable(); + + t.string("type").notNullable(); + t.string("title").notNullable(); // Markdown + t.text("body").nullable(); // Markdown + t.string("link").nullable(); + t.boolean("isRead").notNullable().defaultTo(false); + + t.timestamps(true, true, true); + + t.primary(["id", "createdAt"]); + }) + .toString(); + + await knex.schema.raw(` + ${createTableSql} PARTITION BY RANGE ("createdAt"); + `); + + await knex.schema.raw( + `CREATE TABLE ${TableName.UserNotifications}_default PARTITION OF ${TableName.UserNotifications} DEFAULT` + ); + + await knex.schema.alterTable(TableName.UserNotifications, (t) => { + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.index("type"); + t.index(["userId", "isRead"]); + t.index(["userId", "createdAt", "orgId"]); + }); + + await createOnUpdateTrigger(knex, TableName.UserNotifications); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.UserNotifications); + await dropOnUpdateTrigger(knex, TableName.UserNotifications); +} diff --git a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts new file mode 100644 index 000000000..a70dcb8b9 --- /dev/null +++ b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts @@ -0,0 +1,221 @@ +import { Knex } from "knex"; + +import { inMemoryKeyStore } from "@app/keystore/memory"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; + +import { SecretKeyEncoding, TableName } from "../schemas"; +import { getMigrationEnvConfig } from "./utils/env-config"; +import { createCircularCache } from "./utils/ring-buffer"; +import { getMigrationEncryptionServices } from "./utils/services"; + +const BATCH_SIZE = 500; +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AuditLogStream)) { + const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider"); + const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + if (!hasProvider) t.string("provider").notNullable().defaultTo("custom"); + if (!hasEncryptedCredentials) t.binary("encryptedCredentials"); + + // This column will no longer be used but we're not dropping it so that we can have a backup in case the migration goes wrong + t.string("url").nullable().alter(); + }); + + if (!hasEncryptedCredentials) { + const superAdminDAL = superAdminDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL); + const keyStore = inMemoryKeyStore(); + + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const orgEncryptionRingBuffer = + createCircularCache>>(25); + + const logStreams = await knex(TableName.AuditLogStream).select( + "id", + "orgId", + + "url", + "encryptedHeadersAlgorithm", + "encryptedHeadersCiphertext", + "encryptedHeadersIV", + "encryptedHeadersKeyEncoding", + "encryptedHeadersTag" + ); + + const updatedLogStreams = await Promise.all( + logStreams.map(async (el) => { + let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId); + if (!orgKmsService) { + orgKmsService = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.Organization, + orgId: el.orgId + }, + knex + ); + orgEncryptionRingBuffer.push(el.orgId, orgKmsService); + } + + const provider = "custom"; + let credentials; + + if ( + el.encryptedHeadersTag && + el.encryptedHeadersIV && + el.encryptedHeadersCiphertext && + el.encryptedHeadersKeyEncoding + ) { + const decryptedHeaders = crypto + .encryption() + .symmetric() + .decryptWithRootEncryptionKey({ + tag: el.encryptedHeadersTag, + iv: el.encryptedHeadersIV, + ciphertext: el.encryptedHeadersCiphertext, + keyEncoding: el.encryptedHeadersKeyEncoding as SecretKeyEncoding + }); + + credentials = { + url: el.url, + headers: JSON.parse(decryptedHeaders) + }; + } else { + credentials = { + url: el.url, + headers: [] + }; + } + + const encryptedCredentials = orgKmsService.encryptor({ + plainText: Buffer.from(JSON.stringify(credentials), "utf8") + }).cipherTextBlob; + + return { + id: el.id, + orgId: el.orgId, + url: el.url, + provider, + encryptedCredentials + }; + }) + ); + + for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.AuditLogStream) + .insert(updatedLogStreams.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + t.binary("encryptedCredentials").notNullable().alter(); + }); + } + } +} + +// IMPORTANT: The down migration does not utilize the existing "url" and encrypted header columns +// because we're taking the latest data from the credentials column and re-encrypting it into relevant columns +// +// If this down migration was to fail, you can fall-back to the existing URL and encrypted header columns to retrieve +// data that was created prior to this migration + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AuditLogStream)) { + const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider"); + const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); + + if (hasEncryptedCredentials) { + const superAdminDAL = superAdminDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL); + const keyStore = inMemoryKeyStore(); + + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const orgEncryptionRingBuffer = + createCircularCache>>(25); + + const logStreamsToRevert = await knex(TableName.AuditLogStream) + .select("id", "orgId", "encryptedCredentials") + .where("provider", "custom") + .whereNotNull("encryptedCredentials"); + + const updatedLogStreams = await Promise.all( + logStreamsToRevert.map(async (el) => { + let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId); + if (!orgKmsService) { + orgKmsService = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.Organization, + orgId: el.orgId + }, + knex + ); + orgEncryptionRingBuffer.push(el.orgId, orgKmsService); + } + + const decryptedCredentials = orgKmsService + .decryptor({ + cipherTextBlob: el.encryptedCredentials + }) + .toString(); + + const credentials: { url: string; headers: { key: string; value: string }[] } = + JSON.parse(decryptedCredentials); + + const originalUrl: string = credentials.url; + + const encryptedHeadersResult = crypto + .encryption() + .symmetric() + .encryptWithRootEncryptionKey(JSON.stringify(credentials.headers), envConfig); + + const encryptedHeadersAlgorithm: string = encryptedHeadersResult.algorithm; + const encryptedHeadersCiphertext: string = encryptedHeadersResult.ciphertext; + const encryptedHeadersIV: string = encryptedHeadersResult.iv; + const encryptedHeadersKeyEncoding: string = encryptedHeadersResult.encoding; + const encryptedHeadersTag: string = encryptedHeadersResult.tag; + + return { + id: el.id, + orgId: el.orgId, + encryptedCredentials: el.encryptedCredentials, + + url: originalUrl, + encryptedHeadersAlgorithm, + encryptedHeadersCiphertext, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersTag + }; + }) + ); + + for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.AuditLogStream) + .insert(updatedLogStreams.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + + await knex(TableName.AuditLogStream) + .where((qb) => { + void qb.whereNot("provider", "custom").orWhereNull("url"); + }) + .del(); + } + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + t.string("url").notNullable().alter(); + + if (hasProvider) t.dropColumn("provider"); + if (hasEncryptedCredentials) t.dropColumn("encryptedCredentials"); + }); + } +} diff --git a/backend/src/db/schemas/audit-log-streams.ts b/backend/src/db/schemas/audit-log-streams.ts index 901dd8d27..a3f6bafba 100644 --- a/backend/src/db/schemas/audit-log-streams.ts +++ b/backend/src/db/schemas/audit-log-streams.ts @@ -5,11 +5,13 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const AuditLogStreamsSchema = z.object({ id: z.string().uuid(), - url: z.string(), + url: z.string().nullable().optional(), encryptedHeadersCiphertext: z.string().nullable().optional(), encryptedHeadersIV: z.string().nullable().optional(), encryptedHeadersTag: z.string().nullable().optional(), @@ -17,7 +19,9 @@ export const AuditLogStreamsSchema = z.object({ encryptedHeadersKeyEncoding: z.string().nullable().optional(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + provider: z.string().default("custom"), + encryptedCredentials: zodBuffer }); export type TAuditLogStreams = z.infer; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 855934b28..3e3e81fd0 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -131,6 +131,7 @@ export enum TableName { SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2", SnapshotSecretV2 = "secret_snapshot_secrets_v2", ProjectSplitBackfillIds = "project_split_backfill_ids", + UserNotifications = "user_notifications", // Gateway OrgGatewayConfig = "org_gateway_config", Gateway = "gateways", diff --git a/backend/src/db/schemas/user-notifications.ts b/backend/src/db/schemas/user-notifications.ts new file mode 100644 index 000000000..146526600 --- /dev/null +++ b/backend/src/db/schemas/user-notifications.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserNotificationsSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + orgId: z.string().uuid().nullable().optional(), + type: z.string(), + title: z.string(), + body: z.string().nullable().optional(), + link: z.string().nullable().optional(), + isRead: z.boolean().default(false), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserNotifications = z.infer; +export type TUserNotificationsInsert = Omit, TImmutableDBKeys>; +export type TUserNotificationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-router.ts deleted file mode 100644 index 17bd9e64b..000000000 --- a/backend/src/ee/routes/v1/audit-log-stream-router.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { z } from "zod"; - -import { AUDIT_LOG_STREAMS } from "@app/lib/api-docs"; -import { readLimit } from "@app/server/config/rateLimiter"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { SanitizedAuditLogStreamSchema } from "@app/server/routes/sanitizedSchemas"; -import { AuthMode } from "@app/services/auth/auth-type"; - -export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { - server.route({ - method: "POST", - url: "/", - config: { - rateLimit: readLimit - }, - schema: { - description: "Create an Audit Log Stream.", - security: [ - { - bearerAuth: [] - } - ], - body: z.object({ - url: z.string().min(1).describe(AUDIT_LOG_STREAMS.CREATE.url), - headers: z - .object({ - key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.key), - value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.value) - }) - .describe(AUDIT_LOG_STREAMS.CREATE.headers.desc) - .array() - .optional() - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.create({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - url: req.body.url, - headers: req.body.headers - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "PATCH", - url: "/:id", - config: { - rateLimit: readLimit - }, - schema: { - description: "Update an Audit Log Stream by ID.", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - id: z.string().describe(AUDIT_LOG_STREAMS.UPDATE.id) - }), - body: z.object({ - url: z.string().optional().describe(AUDIT_LOG_STREAMS.UPDATE.url), - headers: z - .object({ - key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.key), - value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.value) - }) - .describe(AUDIT_LOG_STREAMS.UPDATE.headers.desc) - .array() - .optional() - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.updateById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id, - url: req.body.url, - headers: req.body.headers - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "DELETE", - url: "/:id", - config: { - rateLimit: readLimit - }, - schema: { - description: "Delete an Audit Log Stream by ID.", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - id: z.string().describe(AUDIT_LOG_STREAMS.DELETE.id) - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.deleteById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "GET", - url: "/:id", - config: { - rateLimit: readLimit - }, - schema: { - description: "Get an Audit Log Stream by ID.", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - id: z.string().describe(AUDIT_LOG_STREAMS.GET_BY_ID.id) - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema.extend({ - headers: z - .object({ - key: z.string(), - value: z.string() - }) - .array() - .optional() - }) - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.getById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "GET", - url: "/", - config: { - rateLimit: readLimit - }, - schema: { - description: "List Audit Log Streams.", - security: [ - { - bearerAuth: [] - } - ], - response: { - 200: z.object({ - auditLogStreams: SanitizedAuditLogStreamSchema.array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStreams = await server.services.auditLogStream.list({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod - }); - - return { auditLogStreams }; - } - }); -}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts new file mode 100644 index 000000000..816fc62fb --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums"; +import { TAuditLogStream } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAuditLogStreamEndpoints = ({ + server, + provider, + createSchema, + updateSchema, + sanitizedResponseSchema +}: { + server: FastifyZodProvider; + provider: LogProvider; + createSchema: z.ZodType<{ + credentials: T["credentials"]; + }>; + updateSchema: z.ZodType<{ + credentials: T["credentials"]; + }>; + sanitizedResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: "/:logStreamId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + + const auditLogStream = await server.services.auditLogStream.getById(logStreamId, provider, req.permission); + + return { auditLogStream }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: createSchema, + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { credentials } = req.body; + + const auditLogStream = await server.services.auditLogStream.create( + { + provider, + credentials + }, + req.permission + ); + + return { auditLogStream }; + } + }); + + server.route({ + method: "PATCH", + url: "/:logStreamId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + body: updateSchema, + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + const { credentials } = req.body; + + const auditLogStream = await server.services.auditLogStream.updateById( + { + logStreamId, + provider, + credentials + }, + req.permission + ); + + return { auditLogStream }; + } + }); + + server.route({ + method: "DELETE", + url: "/:logStreamId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + + const auditLogStream = await server.services.auditLogStream.deleteById(logStreamId, provider, req.permission); + + return { auditLogStream }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts new file mode 100644 index 000000000..bf10e48bf --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { + CustomProviderListItemSchema, + SanitizedCustomProviderSchema +} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas"; +import { + DatadogProviderListItemSchema, + SanitizedDatadogProviderSchema +} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas"; +import { + SanitizedSplunkProviderSchema, + SplunkProviderListItemSchema +} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SanitizedAuditLogStreamSchema = z.union([ + SanitizedCustomProviderSchema, + SanitizedDatadogProviderSchema, + SanitizedSplunkProviderSchema +]); + +const ProviderOptionsSchema = z.discriminatedUnion("provider", [ + CustomProviderListItemSchema, + DatadogProviderListItemSchema, + SplunkProviderListItemSchema +]); + +export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + providerOptions: ProviderOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const providerOptions = server.services.auditLogStream.listProviderOptions(); + + return { providerOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + auditLogStreams: SanitizedAuditLogStreamSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStreams = await server.services.auditLogStream.list(req.permission); + + return { auditLogStreams }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts new file mode 100644 index 000000000..ea3d48b30 --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts @@ -0,0 +1,51 @@ +import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums"; +import { + CreateCustomProviderLogStreamSchema, + SanitizedCustomProviderSchema, + UpdateCustomProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas"; +import { + CreateDatadogProviderLogStreamSchema, + SanitizedDatadogProviderSchema, + UpdateDatadogProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas"; +import { + CreateSplunkProviderLogStreamSchema, + SanitizedSplunkProviderSchema, + UpdateSplunkProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas"; + +import { registerAuditLogStreamEndpoints } from "./audit-log-stream-endpoints"; + +export * from "./audit-log-stream-router"; + +export const AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP: Record Promise> = + { + [LogProvider.Custom]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Custom, + sanitizedResponseSchema: SanitizedCustomProviderSchema, + createSchema: CreateCustomProviderLogStreamSchema, + updateSchema: UpdateCustomProviderLogStreamSchema + }); + }, + [LogProvider.Datadog]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Datadog, + sanitizedResponseSchema: SanitizedDatadogProviderSchema, + createSchema: CreateDatadogProviderLogStreamSchema, + updateSchema: UpdateDatadogProviderLogStreamSchema + }); + }, + [LogProvider.Splunk]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Splunk, + sanitizedResponseSchema: SanitizedSplunkProviderSchema, + createSchema: CreateSplunkProviderLogStreamSchema, + updateSchema: UpdateSplunkProviderLogStreamSchema + }); + } + }; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index ab9503f58..8bf066013 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -3,7 +3,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; -import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; @@ -114,7 +114,21 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerGroupRouter, { prefix: "/groups" }); - await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); + + await server.register( + async (auditLogStreamRouter) => { + await auditLogStreamRouter.register(registerAuditLogStreamRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP).map(([provider, router]) => + auditLogStreamRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/audit-log-streams" } + ); + await server.register(registerUserAdditionalPrivilegeRouter, { prefix: "/user-project-additional-privilege" }); await server.register( async (privilegeRouter) => { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 0f05bd5af..008b61919 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -20,6 +20,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TNotificationServiceFactory } from "../../../services/notification/notification-service"; +import { NotificationType } from "../../../services/notification/notification-types"; import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; @@ -67,6 +69,7 @@ type TSecretApprovalRequestServiceFactoryDep = { projectSlackConfigDAL: Pick; microsoftTeamsService: Pick; projectMicrosoftTeamsConfigDAL: Pick; + notificationService: Pick; }; export const accessApprovalRequestServiceFactory = ({ @@ -84,7 +87,8 @@ export const accessApprovalRequestServiceFactory = ({ kmsService, microsoftTeamsService, projectMicrosoftTeamsConfigDAL, - projectSlackConfigDAL + projectSlackConfigDAL, + notificationService }: TSecretApprovalRequestServiceFactoryDep): TAccessApprovalRequestServiceFactory => { const $getEnvironmentFromPermissions = (permissions: unknown): string | null => { if (!Array.isArray(permissions) || permissions.length === 0) { @@ -245,7 +249,8 @@ export const accessApprovalRequestServiceFactory = ({ ); const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; - const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; + const approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ input: { @@ -274,6 +279,17 @@ export const accessApprovalRequestServiceFactory = ({ } }); + await notificationService.createUserNotifications( + approverUsers.map((approver) => ({ + userId: approver.id, + orgId: actorOrgId, + type: NotificationType.ACCESS_APPROVAL_REQUEST, + title: "Access Approval Request", + body: `**${requesterFullName}** (${requestedByUser.email}) has requested ${isTemporary ? "temporary" : "permanent"} access to **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`, + link: approvalPath + })) + ); + await smtpService.sendMail({ recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), subjectLine: "Access Approval Request", @@ -391,7 +407,8 @@ export const accessApprovalRequestServiceFactory = ({ const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`; - const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; + const approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ input: { @@ -422,27 +439,44 @@ export const accessApprovalRequestServiceFactory = ({ } }); - await smtpService.sendMail({ - recipients: policy.approvers - .filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id) - .map((approver) => approver.email!), - subjectLine: "Access Approval Request Updated", - substitutions: { - projectName: project.name, - requesterFullName, - requesterEmail: requestedByUser.email, - isTemporary: true, - expiresIn: msFn(ms(temporaryRange || ""), { long: true }), - secretPath, - environment: envSlug, - permissions: accessTypes, - approvalUrl, - editNote, - editorFullName, - editorEmail: editedByUser.email - }, - template: SmtpTemplates.AccessApprovalRequestUpdated - }); + await notificationService.createUserNotifications( + policy.approvers + .filter((approver) => Boolean(approver.userId) && approver.userId !== editedByUser.id) + .map((approver) => ({ + userId: approver.userId!, + orgId: actorOrgId, + type: NotificationType.ACCESS_APPROVAL_REQUEST_UPDATED, + title: "Access Approval Request Updated", + body: `**${editorFullName}** (${editedByUser.email}) has updated the access request submitted by **${requesterFullName}** (${requestedByUser.email}) for **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`, + link: approvalPath + })) + ); + + const recipients = policy.approvers + .filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id) + .map((approver) => approver.email!); + + if (recipients.length > 0) { + await smtpService.sendMail({ + recipients, + subjectLine: "Access Approval Request Updated", + substitutions: { + projectName: project.name, + requesterFullName, + requesterEmail: requestedByUser.email, + isTemporary: true, + expiresIn: msFn(ms(temporaryRange || ""), { long: true }), + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + editNote, + editorFullName, + editorEmail: editedByUser.email + }, + template: SmtpTemplates.AccessApprovalRequestUpdated + }); + } return approvalRequest; }); diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts new file mode 100644 index 000000000..9c03dc2bd --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts @@ -0,0 +1,5 @@ +export enum LogProvider { + Datadog = "datadog", + Splunk = "splunk", + Custom = "custom" +} diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts new file mode 100644 index 000000000..f70dc02a0 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts @@ -0,0 +1,13 @@ +import { LogProvider } from "./audit-log-stream-enums"; +import { TAuditLogStreamCredentials, TLogStreamFactory } from "./audit-log-stream-types"; +import { CustomProviderFactory } from "./custom/custom-provider-factory"; +import { DatadogProviderFactory } from "./datadog/datadog-provider-factory"; +import { SplunkProviderFactory } from "./splunk/splunk-provider-factory"; + +type TLogStreamFactoryImplementation = TLogStreamFactory; + +export const LOG_STREAM_FACTORY_MAP: Record = { + [LogProvider.Datadog]: DatadogProviderFactory as TLogStreamFactoryImplementation, + [LogProvider.Splunk]: SplunkProviderFactory as TLogStreamFactoryImplementation, + [LogProvider.Custom]: CustomProviderFactory as TLogStreamFactoryImplementation +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts index dc93f238e..87a7736a3 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts @@ -1,21 +1,70 @@ -export function providerSpecificPayload(url: string) { - const { hostname } = new URL(url); +import { TAuditLogStreams } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; - const payload: Record = {}; +import { TAuditLogStream, TAuditLogStreamCredentials } from "./audit-log-stream-types"; +import { getCustomProviderListItem } from "./custom/custom-provider-fns"; +import { getDatadogProviderListItem } from "./datadog/datadog-provider-fns"; +import { getSplunkProviderListItem } from "./splunk/splunk-provider-fns"; - switch (hostname) { - case "http-intake.logs.datadoghq.com": - case "http-intake.logs.us3.datadoghq.com": - case "http-intake.logs.us5.datadoghq.com": - case "http-intake.logs.datadoghq.eu": - case "http-intake.logs.ap1.datadoghq.com": - case "http-intake.logs.ddog-gov.com": - payload.ddsource = "infisical"; - payload.service = "audit-logs"; - break; - default: - break; - } +export const listProviderOptions = () => { + return [getDatadogProviderListItem(), getSplunkProviderListItem(), getCustomProviderListItem()].sort((a, b) => + a.name.localeCompare(b.name) + ); +}; - return payload; -} +export const encryptLogStreamCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TAuditLogStreamCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptLogStreamCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TAuditLogStreamCredentials; +}; + +export const decryptLogStream = async ( + logStream: TAuditLogStreams, + kmsService: Pick +) => { + return { + ...logStream, + credentials: await decryptLogStreamCredentials({ + encryptedCredentials: logStream.encryptedCredentials, + orgId: logStream.orgId, + kmsService + }) + } as TAuditLogStream; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts new file mode 100644 index 000000000..4fba79107 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts @@ -0,0 +1,14 @@ +import { AuditLogStreamsSchema } from "@app/db/schemas"; + +export const BaseProviderSchema = AuditLogStreamsSchema.omit({ + encryptedCredentials: true, + provider: true, + + // Old "archived" values + encryptedHeadersAlgorithm: true, + encryptedHeadersCiphertext: true, + encryptedHeadersIV: true, + encryptedHeadersKeyEncoding: true, + encryptedHeadersTag: true, + url: true +}); diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts index 46d2782b3..5dd0fd4ba 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -1,242 +1,252 @@ import { ForbiddenError } from "@casl/ability"; -import { RawAxiosRequestHeaders } from "axios"; +import { AxiosError } from "axios"; -import { SecretKeyEncoding } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; -import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { TAuditLogs } from "@app/db/schemas"; +import { + decryptLogStream, + decryptLogStreamCredentials, + encryptLogStreamCredentials, + listProviderOptions +} from "@app/ee/services/audit-log-stream/audit-log-stream-fns"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { AUDIT_LOG_STREAM_TIMEOUT } from "../audit-log/audit-log-queue"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; -import { providerSpecificPayload } from "./audit-log-stream-fns"; -import { LogStreamHeaders, TAuditLogStreamServiceFactory } from "./audit-log-stream-types"; +import { LogProvider } from "./audit-log-stream-enums"; +import { LOG_STREAM_FACTORY_MAP } from "./audit-log-stream-factory"; +import { TAuditLogStream, TCreateAuditLogStreamDTO, TUpdateAuditLogStreamDTO } from "./audit-log-stream-types"; +import { TCustomProviderCredentials } from "./custom/custom-provider-types"; -type TAuditLogStreamServiceFactoryDep = { +export type TAuditLogStreamServiceFactoryDep = { auditLogStreamDAL: TAuditLogStreamDALFactory; permissionService: Pick; licenseService: Pick; + kmsService: Pick; }; +export type TAuditLogStreamServiceFactory = ReturnType; + export const auditLogStreamServiceFactory = ({ auditLogStreamDAL, permissionService, - licenseService -}: TAuditLogStreamServiceFactoryDep): TAuditLogStreamServiceFactory => { - const create: TAuditLogStreamServiceFactory["create"] = async ({ - url, - actor, - headers = [], - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - - const plan = await licenseService.getPlan(actorOrgId); + licenseService, + kmsService +}: TAuditLogStreamServiceFactoryDep) => { + const create = async ({ provider, credentials }: TCreateAuditLogStreamDTO, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); if (!plan.auditLogStreams) { throw new BadRequestError({ - message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." + message: "Failed to create Audit Log Stream: Plan restriction. Upgrade plan to continue." }); } const { permission } = await permissionService.getOrgPermission( - actor, - actorId, - actorOrgId, - actorAuthMethod, - actorOrgId + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); - - const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + const totalStreams = await auditLogStreamDAL.find({ orgId: actor.orgId }); if (totalStreams.length >= plan.auditLogStreamLimit) { throw new BadRequestError({ - message: - "Failed to create audit log streams due to plan limit reached. Kindly contact Infisical to add more streams." + message: "Failed to create Audit Log Stream: Plan limit reached. Contact Infisical to increase quota." }); } - // testing connection first - const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (headers.length) - headers.forEach(({ key, value }) => { - streamHeaders[key] = value; - }); + const factory = LOG_STREAM_FACTORY_MAP[provider](); + const validatedCredentials = await factory.validateCredentials({ credentials }); - await request - .post( - url, - { ...providerSpecificPayload(url), ping: "ok" }, - { - headers: streamHeaders, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ) - .catch((err) => { - throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` }); - }); + const encryptedCredentials = await encryptLogStreamCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); - const encryptedHeaders = headers - ? crypto.encryption().symmetric().encryptWithRootEncryptionKey(JSON.stringify(headers)) - : undefined; const logStream = await auditLogStreamDAL.create({ - orgId: actorOrgId, - url, - ...(encryptedHeaders - ? { - encryptedHeadersCiphertext: encryptedHeaders.ciphertext, - encryptedHeadersIV: encryptedHeaders.iv, - encryptedHeadersTag: encryptedHeaders.tag, - encryptedHeadersAlgorithm: encryptedHeaders.algorithm, - encryptedHeadersKeyEncoding: encryptedHeaders.encoding - } - : {}) + orgId: actor.orgId, + provider, + encryptedCredentials }); - return logStream; + + return { ...logStream, credentials: validatedCredentials } as TAuditLogStream; }; - const updateById: TAuditLogStreamServiceFactory["updateById"] = async ({ - id, - url, - actor, - headers = [], - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - - const plan = await licenseService.getPlan(actorOrgId); - if (!plan.auditLogStreams) + const updateById = async ( + { logStreamId, provider, credentials }: TUpdateAuditLogStreamDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.auditLogStreams) { throw new BadRequestError({ - message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." + message: "Failed to update Audit Log Stream: Plan restriction. Upgrade plan to continue." }); + } - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const logStream = await auditLogStreamDAL.findById(logStreamId); + if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + logStream.orgId + ); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); - // testing connection first - const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (headers.length) - headers.forEach(({ key, value }) => { - streamHeaders[key] = value; - }); + const finalCredentials = { ...credentials }; - await request - .post( - url || logStream.url, - { ...providerSpecificPayload(url || logStream.url), ping: "ok" }, - { - headers: streamHeaders, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ) - .catch((err) => { - throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); - }); + // For the "Custom" provider, we must handle masked header values ('******'). + // These are placeholders from the frontend for secrets that haven't been changed. + // We need to replace them with the original, unmasked values from the database. + if ( + provider === LogProvider.Custom && + "headers" in finalCredentials && + Array.isArray(finalCredentials.headers) && + finalCredentials.headers.some((header) => header.value === "******") + ) { + const decryptedOldCredentials = (await decryptLogStreamCredentials({ + encryptedCredentials: logStream.encryptedCredentials, + orgId: logStream.orgId, + kmsService + })) as TCustomProviderCredentials; - const encryptedHeaders = headers - ? crypto.encryption().symmetric().encryptWithRootEncryptionKey(JSON.stringify(headers)) - : undefined; - const updatedLogStream = await auditLogStreamDAL.updateById(id, { - url, - ...(encryptedHeaders - ? { - encryptedHeadersCiphertext: encryptedHeaders.ciphertext, - encryptedHeadersIV: encryptedHeaders.iv, - encryptedHeadersTag: encryptedHeaders.tag, - encryptedHeadersAlgorithm: encryptedHeaders.algorithm, - encryptedHeadersKeyEncoding: encryptedHeaders.encoding + const oldHeadersMap = decryptedOldCredentials.headers.reduce>((acc, header) => { + acc[header.key] = header.value; + return acc; + }, {}); + + const finalHeaders: { key: string; value: string }[] = []; + for (const header of finalCredentials.headers) { + if (header.value === "******") { + const oldValue = oldHeadersMap[header.key]; + if (oldValue) { + finalHeaders.push({ key: header.key, value: oldValue }); } - : {}) + } else { + finalHeaders.push(header); + } + } + finalCredentials.headers = finalHeaders; + } + + const factory = LOG_STREAM_FACTORY_MAP[provider](); + const validatedCredentials = await factory.validateCredentials({ credentials: finalCredentials }); + + const encryptedCredentials = await encryptLogStreamCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService }); - return updatedLogStream; + + const updatedLogStream = await auditLogStreamDAL.updateById(logStreamId, { + encryptedCredentials + }); + + return { ...updatedLogStream, credentials: validatedCredentials } as TAuditLogStream; }; - const deleteById: TAuditLogStreamServiceFactory["deleteById"] = async ({ - id, - actor, - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); + const deleteById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => { + const logStream = await auditLogStreamDAL.findById(logStreamId); + if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + logStream.orgId + ); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); - const deletedLogStream = await auditLogStreamDAL.deleteById(id); - return deletedLogStream; + if (logStream.provider !== provider) { + throw new BadRequestError({ + message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'` + }); + } + + const deletedLogStream = await auditLogStreamDAL.deleteById(logStreamId); + + return decryptLogStream(deletedLogStream, kmsService); }; - const getById: TAuditLogStreamServiceFactory["getById"] = async ({ - id, - actor, - actorId, - actorOrgId, - actorAuthMethod - }) => { - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const getById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => { + const logStream = await auditLogStreamDAL.findById(logStreamId); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${logStreamId}' not found` }); - const headers = - logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - tag: logStream.encryptedHeadersTag, - iv: logStream.encryptedHeadersIV, - ciphertext: logStream.encryptedHeadersCiphertext, - keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding - }) - ) as LogStreamHeaders[]) - : undefined; - - return { ...logStream, headers }; - }; - - const list: TAuditLogStreamServiceFactory["list"] = async ({ actor, actorId, actorOrgId, actorAuthMethod }) => { const { permission } = await permissionService.getOrgPermission( - actor, - actorId, - actorOrgId, - actorAuthMethod, - actorOrgId + actor.type, + actor.id, + logStream.orgId, + actor.authMethod, + actor.orgId ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); - const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); - return logStreams; + if (logStream.provider !== provider) { + throw new BadRequestError({ + message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'` + }); + } + + return decryptLogStream(logStream, kmsService); + }; + + const list = async (actor: OrgServiceActor) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const logStreams = await auditLogStreamDAL.find({ orgId: actor.orgId }); + + return Promise.all(logStreams.map((stream) => decryptLogStream(stream, kmsService))); + }; + + const streamLog = async (orgId: string, auditLog: TAuditLogs) => { + const logStreams = await auditLogStreamDAL.find({ orgId }); + await Promise.allSettled( + logStreams.map(async ({ provider, encryptedCredentials }) => { + const credentials = await decryptLogStreamCredentials({ + encryptedCredentials, + orgId, + kmsService + }); + + const factory = LOG_STREAM_FACTORY_MAP[provider as LogProvider](); + + try { + await factory.streamLog({ + credentials, + auditLog + }); + } catch (error) { + logger.error( + error, + `Failed to stream audit log [auditLogId=${auditLog.id}] [provider=${provider}] [orgId=${orgId}]${error instanceof AxiosError ? `: ${error.message}` : ""}` + ); + throw error; + } + }) + ); }; return { @@ -244,6 +254,8 @@ export const auditLogStreamServiceFactory = ({ updateById, deleteById, getById, - list + list, + listProviderOptions, + streamLog }; }; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts index 4c4a5609e..d0f78f632 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts @@ -1,48 +1,38 @@ -import { TAuditLogStreams } from "@app/db/schemas"; -import { TOrgPermission } from "@app/lib/types"; +import { TAuditLogs } from "@app/db/schemas"; -export type LogStreamHeaders = { - key: string; - value: string; +import { LogProvider } from "./audit-log-stream-enums"; +import { TCustomProvider, TCustomProviderCredentials } from "./custom/custom-provider-types"; +import { TDatadogProvider, TDatadogProviderCredentials } from "./datadog/datadog-provider-types"; +import { TSplunkProvider, TSplunkProviderCredentials } from "./splunk/splunk-provider-types"; + +export type TAuditLogStream = TDatadogProvider | TSplunkProvider | TCustomProvider; + +export type TAuditLogStreamCredentials = + | TDatadogProviderCredentials + | TSplunkProviderCredentials + | TCustomProviderCredentials; + +export type TCreateAuditLogStreamDTO = { + provider: LogProvider; + credentials: TAuditLogStreamCredentials; }; -export type TCreateAuditLogStreamDTO = Omit & { - url: string; - headers?: LogStreamHeaders[]; +export type TUpdateAuditLogStreamDTO = { + logStreamId: string; + provider: LogProvider; + credentials: TAuditLogStreamCredentials; }; -export type TUpdateAuditLogStreamDTO = Omit & { - id: string; - url?: string; - headers?: LogStreamHeaders[]; -}; +export type TLogStreamFactoryValidateCredentials = (input: { + credentials: C; +}) => Promise; -export type TDeleteAuditLogStreamDTO = Omit & { - id: string; -}; +export type TLogStreamFactoryStreamLog = (input: { + credentials: C; + auditLog: TAuditLogs; +}) => Promise; -export type TListAuditLogStreamDTO = Omit; - -export type TGetDetailsAuditLogStreamDTO = Omit & { - id: string; -}; - -export type TAuditLogStreamServiceFactory = { - create: (arg: TCreateAuditLogStreamDTO) => Promise; - updateById: (arg: TUpdateAuditLogStreamDTO) => Promise; - deleteById: (arg: TDeleteAuditLogStreamDTO) => Promise; - getById: (arg: TGetDetailsAuditLogStreamDTO) => Promise<{ - headers: LogStreamHeaders[] | undefined; - orgId: string; - url: string; - id: string; - createdAt: Date; - updatedAt: Date; - encryptedHeadersCiphertext?: string | null | undefined; - encryptedHeadersIV?: string | null | undefined; - encryptedHeadersTag?: string | null | undefined; - encryptedHeadersAlgorithm?: string | null | undefined; - encryptedHeadersKeyEncoding?: string | null | undefined; - }>; - list: (arg: TListAuditLogStreamDTO) => Promise; +export type TLogStreamFactory = () => { + validateCredentials: TLogStreamFactoryValidateCredentials; + streamLog: TLogStreamFactoryStreamLog; }; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts new file mode 100644 index 000000000..6e397638c --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts @@ -0,0 +1,67 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TCustomProviderCredentials } from "./custom-provider-types"; + +export const CustomProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { url, headers } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) { + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + } + + await request + .post( + url, + { ping: "ok" }, + { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { url, headers } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (headers.length) { + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + } + + await request.post(url, auditLog, { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts new file mode 100644 index 000000000..27b8bbf72 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getCustomProviderListItem = () => { + return { + name: "Custom" as const, + provider: LogProvider.Custom as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts new file mode 100644 index 000000000..d960d0fe9 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts @@ -0,0 +1,50 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const CustomProviderCredentialsSchema = z.object({ + url: z.string().url().trim().min(1).max(255), + headers: z + .object({ + key: z + .string() + .min(1) + .refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header keys cannot contain newlines or carriage returns"), + value: z + .string() + .min(1) + .refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header values cannot contain newlines or carriage returns") + }) + .array() +}); + +const BaseCustomProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Custom) }); + +export const CustomProviderSchema = BaseCustomProviderSchema.extend({ + credentials: CustomProviderCredentialsSchema +}); + +export const SanitizedCustomProviderSchema = BaseCustomProviderSchema.extend({ + credentials: z.object({ + url: CustomProviderCredentialsSchema.shape.url, + // Return header keys and a redacted value + headers: CustomProviderCredentialsSchema.shape.headers.transform((headers) => + headers.map((header) => ({ ...header, value: "******" })) + ) + }) +}); + +export const CustomProviderListItemSchema = z.object({ + name: z.literal("Custom"), + provider: z.literal(LogProvider.Custom) +}); + +export const CreateCustomProviderLogStreamSchema = z.object({ + credentials: CustomProviderCredentialsSchema +}); + +export const UpdateCustomProviderLogStreamSchema = z.object({ + credentials: CustomProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts new file mode 100644 index 000000000..9b2de8347 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { CustomProviderCredentialsSchema, CustomProviderSchema } from "./custom-provider-schemas"; + +export type TCustomProvider = z.infer; + +export type TCustomProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts new file mode 100644 index 000000000..ec55784da --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts @@ -0,0 +1,67 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TDatadogProviderCredentials } from "./datadog-provider-types"; + +function createPayload(event: Record) { + const appCfg = getConfig(); + + const ddtags = [`env:${appCfg.NODE_ENV || "unknown"}`].join(","); + + return { + ...event, + hostname: new URL(appCfg.SITE_URL || "http://infisical").hostname, + ddsource: "infisical", + service: "infisical", + ddtags + }; +} + +export const DatadogProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { url, token } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token }; + + await request + .post(url, createPayload({ ping: "ok" }), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with Datadog: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { url, token } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token }; + + await request.post(url, createPayload(auditLog), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts new file mode 100644 index 000000000..ec68fdb39 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getDatadogProviderListItem = () => { + return { + name: "Datadog" as const, + provider: LogProvider.Datadog as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts new file mode 100644 index 000000000..0445d79f2 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts @@ -0,0 +1,38 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const DatadogProviderCredentialsSchema = z.object({ + url: z.string().url().trim().min(1).max(255), + token: z + .string() + .trim() + .refine((val) => new RE2(/^[a-fA-F0-9]{32}$/).test(val), "Invalid Datadog API key format") +}); + +const BaseDatadogProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Datadog) }); + +export const DatadogProviderSchema = BaseDatadogProviderSchema.extend({ + credentials: DatadogProviderCredentialsSchema +}); + +export const SanitizedDatadogProviderSchema = BaseDatadogProviderSchema.extend({ + credentials: DatadogProviderCredentialsSchema.pick({ + url: true + }) +}); + +export const DatadogProviderListItemSchema = z.object({ + name: z.literal("Datadog"), + provider: z.literal(LogProvider.Datadog) +}); + +export const CreateDatadogProviderLogStreamSchema = z.object({ + credentials: DatadogProviderCredentialsSchema +}); + +export const UpdateDatadogProviderLogStreamSchema = z.object({ + credentials: DatadogProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts new file mode 100644 index 000000000..e7f6a1f28 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { DatadogProviderCredentialsSchema, DatadogProviderSchema } from "./datadog-provider-schemas"; + +export type TDatadogProvider = z.infer; + +export type TDatadogProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts new file mode 100644 index 000000000..72fc90ce2 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts @@ -0,0 +1,84 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TSplunkProviderCredentials } from "./splunk-provider-types"; + +function createPayload(event: Record) { + const appCfg = getConfig(); + + return { + time: Math.floor(Date.now() / 1000), + ...(appCfg.SITE_URL && { host: new URL(appCfg.SITE_URL).host }), + source: "infisical", + sourcetype: "_json", + event + }; +} + +async function createSplunkUrl(hostname: string) { + let parsedHostname: string; + try { + parsedHostname = new URL(`https://${hostname}`).hostname; + } catch (error) { + throw new BadRequestError({ message: `Invalid Splunk hostname provided: ${(error as Error).message}` }); + } + + await blockLocalAndPrivateIpAddresses(`https://${parsedHostname}`); + + return `https://${parsedHostname}:8088/services/collector/event`; +} + +export const SplunkProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { hostname, token } = credentials; + + const url = await createSplunkUrl(hostname); + + const streamHeaders: RawAxiosRequestHeaders = { + "Content-Type": "application/json", + Authorization: `Splunk ${token}` + }; + + await request + .post(url, createPayload({ ping: "ok" }), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with Splunk: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { hostname, token } = credentials; + + const url = await createSplunkUrl(hostname); + + const streamHeaders: RawAxiosRequestHeaders = { + "Content-Type": "application/json", + Authorization: `Splunk ${token}` + }; + + await request.post(url, createPayload(auditLog), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts new file mode 100644 index 000000000..e2ea2e316 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getSplunkProviderListItem = () => { + return { + name: "Splunk" as const, + provider: LogProvider.Splunk as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts new file mode 100644 index 000000000..ab28db616 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const SplunkProviderCredentialsSchema = z.object({ + hostname: z + .string() + .trim() + .min(1) + .max(255) + .superRefine((val, ctx) => { + if (val.includes("://")) { + ctx.addIssue({ + code: "custom", + message: "Hostname should not include protocol" + }); + return; + } + + try { + const url = new URL(`https://${val}`); + if (url.hostname !== val) { + ctx.addIssue({ + code: "custom", + message: "Must be a valid hostname without port or path" + }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid hostname" }); + } + }), + token: z.string().uuid().trim().min(1) +}); + +const BaseSplunkProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Splunk) }); + +export const SplunkProviderSchema = BaseSplunkProviderSchema.extend({ + credentials: SplunkProviderCredentialsSchema +}); + +export const SanitizedSplunkProviderSchema = BaseSplunkProviderSchema.extend({ + credentials: SplunkProviderCredentialsSchema.pick({ + hostname: true + }) +}); + +export const SplunkProviderListItemSchema = z.object({ + name: z.literal("Splunk"), + provider: z.literal(LogProvider.Splunk) +}); + +export const CreateSplunkProviderLogStreamSchema = z.object({ + credentials: SplunkProviderCredentialsSchema +}); + +export const UpdateSplunkProviderLogStreamSchema = z.object({ + credentials: SplunkProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts new file mode 100644 index 000000000..11f7c8fcb --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { SplunkProviderCredentialsSchema, SplunkProviderSchema } from "./splunk-provider-schemas"; + +export type TSplunkProvider = z.infer; + +export type TSplunkProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 0914b8f6b..6b286c2ec 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,22 +1,14 @@ -import { AxiosError, RawAxiosRequestHeaders } from "axios"; - -import { SecretKeyEncoding } from "@app/db/schemas"; -import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { logger } from "@app/lib/logger"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { TAuditLogStreamDALFactory } from "../audit-log-stream/audit-log-stream-dal"; -import { providerSpecificPayload } from "../audit-log-stream/audit-log-stream-fns"; -import { LogStreamHeaders } from "../audit-log-stream/audit-log-stream-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TCreateAuditLogDTO } from "./audit-log-types"; type TAuditLogQueueServiceFactoryDep = { auditLogDAL: TAuditLogDALFactory; - auditLogStreamDAL: Pick; + auditLogStreamService: Pick; queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; @@ -35,7 +27,7 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamService }: TAuditLogQueueServiceFactoryDep): Promise => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -86,60 +78,7 @@ export const auditLogQueueServiceFactory = async ({ userAgentType }); - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; - - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; - }); - - try { - const response = await request.post( - url, - { ...providerSpecificPayload(url), ...auditLog }, - { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ); - return response; - } catch (error) { - logger.error( - `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` - ); - return error; - } - } - ) - ); + await auditLogStreamService.streamLog(orgId, auditLog); } }); diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index 294a9a723..32efd9848 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -30,7 +30,7 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s export const CassandraProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); - const hostIps = await Promise.all( + await Promise.all( providerInputs.host .split(",") .filter(Boolean) @@ -48,10 +48,10 @@ export const CassandraProvider = (): TDynamicProviderFns => { allowedExpressions: (val) => ["username"].includes(val) }); - return { ...providerInputs, hostIps }; + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIps: string[] }) => { + const $getClient = async (providerInputs: z.infer) => { const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const client = new cassandra.Client({ sslOptions, @@ -64,7 +64,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { }, keyspace: providerInputs.keyspace, localDataCenter: providerInputs?.localDataCenter, - contactPoints: providerInputs.hostIps + contactPoints: providerInputs.host.split(",") }); return client; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index bde62fc61..f4d43f23f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -28,14 +28,14 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s export const ElasticSearchProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); - return { ...providerInputs, hostIp }; + await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const connection = new ElasticSearchClient({ node: { - url: new URL(`${providerInputs.hostIp}:${providerInputs.port}`), + url: new URL(`${providerInputs.host}:${providerInputs.port}`), ...(providerInputs.ca && { ssl: { rejectUnauthorized: false, diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index dfae417f6..db1d30dfa 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -28,15 +28,15 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s export const MongoDBProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); - return { ...providerInputs, hostIp }; + await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const isSrv = !providerInputs.port; const uri = isSrv - ? `mongodb+srv://${providerInputs.hostIp}` - : `mongodb://${providerInputs.hostIp}:${providerInputs.port}`; + ? `mongodb+srv://${providerInputs.host}` + : `mongodb://${providerInputs.host}:${providerInputs.port}`; const client = new MongoClient(uri, { auth: { diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 2660d9d8a..f3a0470b0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -87,13 +87,13 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa export const RabbitMqProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); - return { ...providerInputs, hostIp }; + await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const axiosInstance = axios.create({ - baseURL: `${providerInputs.hostIp}:${providerInputs.port}/api`, + baseURL: `${providerInputs.host}:${providerInputs.port}/api`, auth: { username: providerInputs.username, password: providerInputs.password diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index b84859484..34f77d465 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -36,7 +36,7 @@ export const SapAseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); + await verifyHostInputValidity(providerInputs.host); validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password"].includes(val) }); @@ -45,16 +45,13 @@ export const SapAseProvider = (): TDynamicProviderFns => { allowedExpressions: (val) => ["username"].includes(val) }); } - return { ...providerInputs, hostIp }; + return { ...providerInputs }; }; - const $getClient = async ( - providerInputs: z.infer & { hostIp: string }, - useMaster?: boolean - ) => { + const $getClient = async (providerInputs: z.infer, useMaster?: boolean) => { const connectionString = `DRIVER={FreeTDS};` + - `SERVER=${providerInputs.hostIp};` + + `SERVER=${providerInputs.host};` + `PORT=${providerInputs.port};` + `DATABASE=${useMaster ? "master" : providerInputs.database};` + `UID=${providerInputs.username};` + diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index 53b88a192..bc7400a36 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -37,7 +37,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); + await verifyHostInputValidity(providerInputs.host); validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) }); @@ -49,12 +49,12 @@ export const SapHanaProvider = (): TDynamicProviderFns => { validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, { allowedExpressions: (val) => ["username"].includes(val) }); - return { ...providerInputs, hostIp }; + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const client = hdb.createClient({ - host: providerInputs.hostIp, + host: providerInputs.host, port: providerInputs.port, user: providerInputs.username, password: providerInputs.password, diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index c8d036ce3..57ce710b1 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -150,8 +150,10 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { - const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const ssl = providerInputs.ca + ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } + : undefined; const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL; const db = knex({ @@ -159,7 +161,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) connection: { database: providerInputs.database, port: providerInputs.port, - host: providerInputs.host, + host: providerInputs.client === SqlProviders.Postgres ? providerInputs.hostIp : providerInputs.host, user: providerInputs.username, password: providerInputs.password, ssl, @@ -209,8 +211,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); let isConnected = false; - const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { - const db = await $getClient({ ...providerInputs, port, host }); + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ ...providerInputs, port, host, hostIp: providerInputs.hostIp }); // oracle needs from keyword const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; diff --git a/backend/src/ee/services/event/event-bus-service.ts b/backend/src/ee/services/event/event-bus-service.ts index bb102c721..00fd14e9f 100644 --- a/backend/src/ee/services/event/event-bus-service.ts +++ b/backend/src/ee/services/event/event-bus-service.ts @@ -1,11 +1,11 @@ -import Redis from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { z } from "zod"; import { logger } from "@app/lib/logger"; import { BusEventSchema, TopicName } from "./types"; -export const eventBusFactory = (redis: Redis) => { +export const eventBusFactory = (redis: Redis | Cluster) => { const publisher = redis.duplicate(); // Duplicate the publisher to create a subscriber. // This is necessary because Redis does not allow a single connection to both publish and subscribe. diff --git a/backend/src/ee/services/event/event-sse-service.ts b/backend/src/ee/services/event/event-sse-service.ts index dc52cc14c..147af8e3c 100644 --- a/backend/src/ee/services/event/event-sse-service.ts +++ b/backend/src/ee/services/event/event-sse-service.ts @@ -1,6 +1,6 @@ /* eslint-disable no-continue */ import { subject } from "@casl/ability"; -import Redis from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { KeyStorePrefixes } from "@app/keystore/keystore"; import { logger } from "@app/lib/logger"; @@ -12,7 +12,7 @@ import { BusEvent, RegisteredEvent } from "./types"; const AUTH_REFRESH_INTERVAL = 60 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000; -export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { +export const sseServiceFactory = (bus: TEventBusService, redis: Redis | Cluster) => { const clients = new Set(); const heartbeatInterval = setInterval(() => { diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts index 13e18374f..9ed5e677c 100644 --- a/backend/src/ee/services/event/event-sse-stream.ts +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -3,7 +3,7 @@ import { Readable } from "node:stream"; import { MongoAbility, PureAbility } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; -import Redis from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { nanoid } from "nanoid"; import { ProjectType } from "@app/db/schemas"; @@ -65,7 +65,7 @@ export type EventStreamClient = { matcher: PureAbility; }; -export function createEventStreamClient(redis: Redis, options: IEventStreamClientOpts): EventStreamClient { +export function createEventStreamClient(redis: Redis | Cluster, options: IEventStreamClientOpts): EventStreamClient { const rules = options.registered.map((r) => { const secretPath = r.conditions?.secretPath; const hasConditions = r.conditions?.environmentSlug || r.conditions?.secretPath; diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 891fa208f..a3327b264 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -323,6 +323,8 @@ export const licenseServiceFactory = ({ }); } + await updateSubscriptionOrgMemberCount(orgId); + const { data: { url } } = await licenseServerCloudApi.request.post( @@ -722,6 +724,16 @@ export const licenseServiceFactory = ({ await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); }; + const getCustomerId = () => { + if (!selfHostedLicense) return "unknown"; + return selfHostedLicense?.customerId; + }; + + const getLicenseId = () => { + if (!selfHostedLicense) return "unknown"; + return selfHostedLicense?.licenseId; + }; + return { generateOrgCustomerId, removeOrgCustomer, @@ -736,6 +748,8 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, + getCustomerId, + getLicenseId, invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 4a5558f46..db41d00f7 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -59,7 +59,7 @@ type TSecretReplicationServiceFactoryDep = { TSecretVersionV2DALFactory, "find" | "insertMany" | "update" | "findLatestVersionMany" >; - secretImportDAL: Pick; + secretImportDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, "findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath" diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 777e73fe2..45589e529 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -3,6 +3,7 @@ import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; import { applyJitter } from "@app/lib/dates"; import { delay as delayMs } from "@app/lib/delay"; import { ExecutionResult, Redlock, Settings } from "@app/lib/red-lock"; +import { Redis, Cluster } from "ioredis"; export const PgSqlLock = { BootUpMigration: 2023, @@ -38,6 +39,7 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, @@ -101,30 +103,57 @@ export type TKeyStoreFactory = { getKeysByPattern: (pattern: string, limit?: number) => Promise; }; -export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => { - const redis = buildRedisFromConfig(redisConfigKeys); - const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); +const pickPrimaryOrSecondaryRedis = (primary: Redis | Cluster, secondaries?: Array) => { + if (!secondaries || !secondaries.length) return primary; + const selectedReplica = secondaries[Math.floor(Math.random() * secondaries.length)]; + return selectedReplica; +}; + +interface TKeyStoreFactoryDTO extends TRedisConfigKeys { + REDIS_READ_REPLICAS?: { host: string; port: number }[]; +} + +export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStoreFactory => { + const primaryRedis = buildRedisFromConfig(redisConfigKeys); + const redisReadReplicas = redisConfigKeys.REDIS_READ_REPLICAS?.map((el) => { + if (redisConfigKeys.REDIS_URL) { + const primaryNode = new URL(redisConfigKeys?.REDIS_URL); + primaryNode.hostname = el.host; + primaryNode.port = String(el.port); + return buildRedisFromConfig({ ...redisConfigKeys, REDIS_URL: primaryNode.toString() }); + } + + if (redisConfigKeys.REDIS_SENTINEL_HOSTS) { + return buildRedisFromConfig({ ...redisConfigKeys, REDIS_SENTINEL_HOSTS: [el] }); + } + + return buildRedisFromConfig({ ...redisConfigKeys, REDIS_CLUSTER_HOSTS: [el] }); + }); + const redisLock = new Redlock([primaryRedis], { retryCount: 2, retryDelay: 200 }); const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => - redis.set(prefix ? `${prefix}:${key}` : key, value); + primaryRedis.set(prefix ? `${prefix}:${key}` : key, value); - const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); + const getItem = async (key: string, prefix?: string) => + pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).get(prefix ? `${prefix}:${key}` : key); const getItems = async (keys: string[], prefix?: string) => - redis.mget(keys.map((key) => (prefix ? `${prefix}:${key}` : key))); + pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).mget( + keys.map((key) => (prefix ? `${prefix}:${key}` : key)) + ); const setItemWithExpiry = async ( key: string, expiryInSeconds: number | string, value: string | number | Buffer, prefix?: string - ) => redis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds); + ) => primaryRedis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds); - const deleteItem = async (key: string) => redis.del(key); + const deleteItem = async (key: string) => primaryRedis.del(key); const deleteItemsByKeyIn = async (keys: string[]) => { if (keys.length === 0) return 0; - return redis.del(keys); + return primaryRedis.del(keys); }; const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => { @@ -134,12 +163,12 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac do { // Await in loop is needed so that Redis is not overwhelmed // eslint-disable-next-line no-await-in-loop - const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads + const [nextCursor, keys] = await primaryRedis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads cursor = nextCursor; for (let i = 0; i < keys.length; i += batchSize) { const batch = keys.slice(i, i + batchSize); - const pipeline = redis.pipeline(); + const pipeline = primaryRedis.pipeline(); for (const key of batch) { pipeline.unlink(key); } @@ -155,9 +184,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac return totalDeleted; }; - const incrementBy = async (key: string, value: number) => redis.incrby(key, value); + const incrementBy = async (key: string, value: number) => primaryRedis.incrby(key, value); - const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); + const setExpiry = async (key: string, expiryInSeconds: number) => primaryRedis.expire(key, expiryInSeconds); const waitTillReady = async ({ key, @@ -188,7 +217,13 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac do { // eslint-disable-next-line no-await-in-loop - const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); + const [nextCursor, keys] = await pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).scan( + cursor, + "MATCH", + pattern, + "COUNT", + 1000 + ); cursor = nextCursor; allKeys.push(...keys); diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 586a69655..c4170aac3 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -37,6 +37,8 @@ const envSchema = z .default("false") .transform((el) => el === "true"), REDIS_URL: zpStr(z.string().optional()), + REDIS_USERNAME: zpStr(z.string().optional()), + REDIS_PASSWORD: zpStr(z.string().optional()), REDIS_SENTINEL_HOSTS: zpStr( z .string() @@ -49,6 +51,28 @@ const envSchema = z REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"), REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")), REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")), + REDIS_CLUSTER_HOSTS: zpStr( + z + .string() + .optional() + .describe("Comma-separated list of Redis Cluster host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380") + ), + REDIS_READ_REPLICAS: zpStr( + z + .string() + .optional() + .describe( + "Comma-separated list of Redis read replicas host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380" + ) + ), + REDIS_CLUSTER_ENABLE_TLS: z + .enum(["true", "false"]) + .default("false") + .transform((el) => el === "true"), + REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE: z + .enum(["true", "false"]) + .default("false") + .transform((el) => el === "true"), HOST: zpStr(z.string().default("localhost")), DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` @@ -218,6 +242,8 @@ const envSchema = z ), PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7), + INFISICAL_PRIMARY_INSTANCE_URL: zpStr(z.string().optional()), + // HSM HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), @@ -335,8 +361,8 @@ const envSchema = z "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." ) .refine( - (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS), - "Either REDIS_URL or REDIS_SENTINEL_HOSTS must be defined." + (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS), + "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined." ) .transform((data) => ({ ...data, @@ -346,7 +372,7 @@ const envSchema = z : undefined, isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), - isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), + isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS || data.REDIS_CLUSTER_HOSTS), isDevelopmentMode: data.NODE_ENV === "development", isTestMode: data.NODE_ENV === "test", isRotationDevelopmentMode: @@ -361,6 +387,18 @@ const envSchema = z const [host, port] = el.trim().split(":"); return { host: host.trim(), port: Number(port.trim()) }; }), + REDIS_CLUSTER_HOSTS: data.REDIS_CLUSTER_HOSTS?.trim() + ?.split(",") + .map((el) => { + const [host, port] = el.trim().split(":"); + return { host: host.trim(), port: Number(port.trim()) }; + }), + REDIS_READ_REPLICAS: data.REDIS_READ_REPLICAS?.trim() + ?.split(",") + .map((el) => { + const [host, port] = el.trim().split(":"); + return { host: host.trim(), port: Number(port.trim()) }; + }), isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && diff --git a/backend/src/lib/config/redis.ts b/backend/src/lib/config/redis.ts index 987518dd5..5187f740a 100644 --- a/backend/src/lib/config/redis.ts +++ b/backend/src/lib/config/redis.ts @@ -2,6 +2,14 @@ import { Redis } from "ioredis"; export type TRedisConfigKeys = Partial<{ REDIS_URL: string; + REDIS_USERNAME: string; + REDIS_PASSWORD: string; + + REDIS_CLUSTER_HOSTS: { host: string; port: number }[]; + REDIS_CLUSTER_ENABLE_TLS: boolean; + // ref: https://github.com/redis/ioredis?tab=readme-ov-file#special-note-aws-elasticache-clusters-with-tls + REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE: boolean; + REDIS_SENTINEL_HOSTS: { host: string; port: number }[]; REDIS_SENTINEL_MASTER_NAME: string; REDIS_SENTINEL_ENABLE_TLS: boolean; @@ -12,6 +20,19 @@ export type TRedisConfigKeys = Partial<{ export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null }); + if (cfg.REDIS_CLUSTER_HOSTS) { + return new Redis.Cluster(cfg.REDIS_CLUSTER_HOSTS, { + dnsLookup: cfg.REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE + ? (address, callback) => callback(null, address) + : undefined, + redisOptions: { + username: cfg.REDIS_USERNAME, + password: cfg.REDIS_PASSWORD, + tls: cfg?.REDIS_CLUSTER_ENABLE_TLS ? {} : undefined + } + }); + } + return new Redis({ // refine at tope will catch this case sentinels: cfg.REDIS_SENTINEL_HOSTS!, @@ -19,6 +40,8 @@ export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { maxRetriesPerRequest: null, sentinelUsername: cfg.REDIS_SENTINEL_USERNAME, sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD, - enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS + enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS, + username: cfg.REDIS_USERNAME, + password: cfg.REDIS_PASSWORD }); }; diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index b8fc45645..05ea315cd 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -250,8 +250,11 @@ const cryptographyFactory = () => { }; }; - const encryptWithRootEncryptionKey = (data: string) => { - const appCfg = getConfig(); + const encryptWithRootEncryptionKey = ( + data: string, + appCfgOverride?: Pick + ) => { + const appCfg = appCfgOverride || getConfig(); const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f4c49d15a..5a7c92f22 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -23,6 +23,7 @@ import { logger } from "@app/lib/logger"; import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; +import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -67,7 +68,8 @@ export enum QueueName { SecretScanningV2 = "secret-scanning-v2", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", - SecretReminderMigration = "secret-reminder-migration" + SecretReminderMigration = "secret-reminder-migration", + UserNotification = "user-notification" } export enum QueueJobs { @@ -109,7 +111,8 @@ export enum QueueJobs { PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", - SecretReminderMigration = "secret-reminder-migration" + SecretReminderMigration = "secret-reminder-migration", + UserNotification = "user-notification-job" } export type TQueueJobTypes = { @@ -313,6 +316,10 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryAggregatedEvents; payload: undefined; }; + [QueueName.UserNotification]: { + name: QueueJobs.UserNotification; + payload: { notifications: TCreateUserNotificationDTO[] }; + }; }; const SECRET_SCANNING_JOBS = [ @@ -415,6 +422,7 @@ export const queueServiceFactory = ( redisCfg: TRedisConfigKeys, { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } ): TQueueServiceFactory => { + const isClusterMode = Boolean(redisCfg?.REDIS_CLUSTER_HOSTS); const connection = buildRedisFromConfig(redisCfg); const queueContainer = {} as Record< QueueName, @@ -457,6 +465,8 @@ export const queueServiceFactory = ( } queueContainer[name] = new Queue(name as string, { + // ref: docs.bullmq.io/bull/patterns/redis-cluster + prefix: isClusterMode ? `{${name}}` : undefined, ...queueSettings, ...(crypto.isFipsModeEnabled() ? { @@ -472,6 +482,7 @@ export const queueServiceFactory = ( const appCfg = getConfig(); if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { workerContainer[name] = new Worker(name, jobFn, { + prefix: isClusterMode ? `{${name}}` : undefined, ...queueSettings, ...(crypto.isFipsModeEnabled() ? { diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 321a3656e..8cf23f703 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -12,7 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit"; import { fastifyRequestContext } from "@fastify/request-context"; import fastify from "fastify"; -import { Redis } from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { Knex } from "knex"; import { HsmModule } from "@app/ee/services/hsm/hsm-types"; @@ -43,7 +43,7 @@ type TMain = { queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; hsmModule: HsmModule; - redis: Redis; + redis: Redis | Cluster; envConfig: TEnvConfig; superAdminDAL: TSuperAdminDALFactory; }; @@ -76,6 +76,7 @@ export const main = async ({ server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); + // @ts-expect-error akhilmhdh: even on setting it fastify as Redis | Cluster it's throwing error server.decorate("redis", redis); server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { try { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 97c62b545..2f128dda1 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -107,110 +107,117 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { }; // ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity). -export const injectIdentity = fp(async (server: FastifyZodProvider) => { - server.decorateRequest("auth", null); - server.addHook("onRequest", async (req) => { - const appCfg = getConfig(); +export const injectIdentity = fp( + async (server: FastifyZodProvider, opt: { shouldForwardWritesToPrimaryInstance?: boolean }) => { + server.decorateRequest("auth", null); + server.decorateRequest("shouldForwardWritesToPrimaryInstance", Boolean(opt.shouldForwardWritesToPrimaryInstance)); + server.addHook("onRequest", async (req) => { + const appCfg = getConfig(); - if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { - return; - } - - // Authentication is handled on a route-level here. - if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { - return; - } - - const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); - - if (!authMode) return; - - switch (authMode) { - case AuthMode.JWT: { - const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); - requestContext.set("orgId", orgId); - req.auth = { - authMode: AuthMode.JWT, - user, - userId: user.id, - tokenVersionId, - actor, - orgId: orgId as string, - authMethod: token.authMethod, - isMfaVerified: token.isMfaVerified, - token - }; - break; + if (opt.shouldForwardWritesToPrimaryInstance && req.method !== "GET") { + return; } - case AuthMode.IDENTITY_ACCESS_TOKEN: { - const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); - const serverCfg = await getServerCfg(); - requestContext.set("orgId", identity.orgId); - req.auth = { - authMode: AuthMode.IDENTITY_ACCESS_TOKEN, - actor, - orgId: identity.orgId, - identityId: identity.identityId, - identityName: identity.name, - authMethod: null, - isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), - token - }; - if (token?.identityAuth?.oidc) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - oidc: token?.identityAuth?.oidc - }); + + if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { + return; + } + + // Authentication is handled on a route-level here. + if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { + return; + } + + const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); + + if (!authMode) return; + + switch (authMode) { + case AuthMode.JWT: { + const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + requestContext.set("orgId", orgId); + req.auth = { + authMode: AuthMode.JWT, + user, + userId: user.id, + tokenVersionId, + actor, + orgId: orgId as string, + authMethod: token.authMethod, + isMfaVerified: token.isMfaVerified, + token + }; + break; } - if (token?.identityAuth?.kubernetes) { - requestContext.set("identityAuthInfo", { + case AuthMode.IDENTITY_ACCESS_TOKEN: { + const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); + const serverCfg = await getServerCfg(); + requestContext.set("orgId", identity.orgId); + req.auth = { + authMode: AuthMode.IDENTITY_ACCESS_TOKEN, + actor, + orgId: identity.orgId, identityId: identity.identityId, - kubernetes: token?.identityAuth?.kubernetes - }); + identityName: identity.name, + authMethod: null, + isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), + token + }; + if (token?.identityAuth?.oidc) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + oidc: token?.identityAuth?.oidc + }); + } + if (token?.identityAuth?.kubernetes) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + kubernetes: token?.identityAuth?.kubernetes + }); + } + if (token?.identityAuth?.aws) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + aws: token?.identityAuth?.aws + }); + } + break; } - if (token?.identityAuth?.aws) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - aws: token?.identityAuth?.aws - }); + case AuthMode.SERVICE_TOKEN: { + const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); + requestContext.set("orgId", serviceToken.orgId); + req.auth = { + orgId: serviceToken.orgId, + authMode: AuthMode.SERVICE_TOKEN as const, + serviceToken, + serviceTokenId: serviceToken.id, + actor, + authMethod: null, + token + }; + break; } - break; + case AuthMode.API_KEY: { + const user = await server.services.apiKey.fnValidateApiKey(token as string); + req.auth = { + authMode: AuthMode.API_KEY as const, + userId: user.id, + actor, + user, + orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! + authMethod: null, + token: token as string + }; + break; + } + case AuthMode.SCIM_TOKEN: { + const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + requestContext.set("orgId", orgId); + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; + break; + } + default: + throw new BadRequestError({ message: "Invalid token strategy provided" }); } - case AuthMode.SERVICE_TOKEN: { - const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); - requestContext.set("orgId", serviceToken.orgId); - req.auth = { - orgId: serviceToken.orgId, - authMode: AuthMode.SERVICE_TOKEN as const, - serviceToken, - serviceTokenId: serviceToken.id, - actor, - authMethod: null, - token - }; - break; - } - case AuthMode.API_KEY: { - const user = await server.services.apiKey.fnValidateApiKey(token as string); - req.auth = { - authMode: AuthMode.API_KEY as const, - userId: user.id, - actor, - user, - orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! - authMethod: null, - token: token as string - }; - break; - } - case AuthMode.SCIM_TOKEN: { - const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); - requestContext.set("orgId", orgId); - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; - break; - } - default: - throw new BadRequestError({ message: "Invalid token strategy provided" }); - } - }); -}); + }); + } +); diff --git a/backend/src/server/plugins/auth/verify-auth.ts b/backend/src/server/plugins/auth/verify-auth.ts index 44ea069dc..c63199769 100644 --- a/backend/src/server/plugins/auth/verify-auth.ts +++ b/backend/src/server/plugins/auth/verify-auth.ts @@ -10,6 +10,10 @@ interface TAuthOptions { export const verifyAuth = (authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) => (req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => { + if (req.shouldForwardWritesToPrimaryInstance && req.method !== "GET") { + return done(); + } + if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array"); if (!req.auth) throw new UnauthorizedError({ message: "Token missing" }); diff --git a/backend/src/server/plugins/primary-forwarding-mode.ts b/backend/src/server/plugins/primary-forwarding-mode.ts new file mode 100644 index 000000000..611806a6b --- /dev/null +++ b/backend/src/server/plugins/primary-forwarding-mode.ts @@ -0,0 +1,14 @@ +import replyFrom from "@fastify/reply-from"; +import fp from "fastify-plugin"; + +export const forwardWritesToPrimary = fp(async (server, opt: { primaryUrl: string }) => { + await server.register(replyFrom, { + base: opt.primaryUrl + }); + + server.addHook("preValidation", async (request, reply) => { + if (request.url.startsWith("/api") && ["POST", "PUT", "DELETE", "PATCH"].includes(request.method)) { + return reply.from(request.url); + } + }); +}); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dedee0e61..1c6de65aa 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -218,6 +218,11 @@ import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; +import { notificationQueueServiceFactory } from "@app/services/notification/notification-queue"; +import { notificationServiceFactory } from "@app/services/notification/notification-service"; +import { userNotificationDALFactory } from "@app/services/notification/user-notification-dal"; +import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; +import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -310,6 +315,7 @@ import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; +import { forwardWritesToPrimary } from "../plugins/primary-forwarding-mode"; import { registerV1Routes } from "./v1"; import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; @@ -385,6 +391,7 @@ export const registerRoutes = async ( const reminderRecipientDAL = reminderRecipientDALFactory(db); const integrationDAL = integrationDALFactory(db); + const offlineUsageReportDAL = offlineUsageReportDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db); const webhookDAL = webhookDALFactory(db); const serviceTokenDAL = serviceTokenDALFactory(db); @@ -418,6 +425,7 @@ export const registerRoutes = async ( const telemetryDAL = telemetryDALFactory(db); const appConnectionDAL = appConnectionDALFactory(db); const secretSyncDAL = secretSyncDALFactory(db, folderDAL); + const userNotificationDAL = userNotificationDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -555,20 +563,29 @@ 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 notificationQueue = await notificationQueueServiceFactory({ + userNotificationDAL, + queueService }); + + const notificationService = notificationServiceFactory({ notificationQueue, userNotificationDAL }); + + const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, @@ -842,7 +859,14 @@ export const registerRoutes = async ( licenseService, kmsService, microsoftTeamsService, - invalidateCacheQueue + invalidateCacheQueue, + smtpService, + tokenService + }); + + const offlineUsageReportService = offlineUsageReportServiceFactory({ + offlineUsageReportDAL, + licenseService }); const orgAdminService = orgAdminServiceFactory({ @@ -1369,7 +1393,8 @@ export const registerRoutes = async ( kmsService, groupDAL, microsoftTeamsService, - projectMicrosoftTeamsConfigDAL + projectMicrosoftTeamsConfigDAL, + notificationService }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1668,7 +1693,8 @@ export const registerRoutes = async ( secretVersionV2DAL: secretVersionV2BridgeDAL, identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, serviceTokenService, - orgService + orgService, + userNotificationDAL }); const dailyReminderQueueService = dailyReminderQueueServiceFactory({ @@ -2004,6 +2030,7 @@ export const registerRoutes = async ( apiKey: apiKeyService, authToken: tokenService, superAdmin: superAdminService, + offlineUsageReport: offlineUsageReportService, project: projectService, projectMembership: projectMembershipService, projectKey: projectKeyService, @@ -2097,7 +2124,8 @@ export const registerRoutes = async ( secretScanningV2: secretScanningV2Service, reminder: reminderService, bus: eventBusService, - sse: sseService + sse: sseService, + notification: notificationService }); const cronJobs: CronJob[] = []; @@ -2136,8 +2164,14 @@ export const registerRoutes = async ( user: userDAL, kmipClient: kmipClientDAL }); + const shouldForwardWritesToPrimaryInstance = Boolean(envConfig.INFISICAL_PRIMARY_INSTANCE_URL); + if (shouldForwardWritesToPrimaryInstance) { + logger.info(`Infisical primary instance is configured: ${envConfig.INFISICAL_PRIMARY_INSTANCE_URL}`); - await server.register(injectIdentity, { userDAL, serviceTokenDAL }); + await server.register(forwardWritesToPrimary, { primaryUrl: envConfig.INFISICAL_PRIMARY_INSTANCE_URL as string }); + } + + await server.register(injectIdentity, { shouldForwardWritesToPrimaryInstance }); await server.register(injectAssumePrivilege); await server.register(injectPermission); await server.register(injectRateLimits); diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 8af4baa8b..344224008 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -246,13 +246,6 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ metadata: ResourceMetadataSchema.optional() }); -export const SanitizedAuditLogStreamSchema = z.object({ - id: z.string(), - url: z.string(), - createdAt: z.date(), - updatedAt: z.date() -}); - export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index ea3726c22..7f5a2f374 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; +import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -53,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { defaultAuthOrgAuthMethod: z.string().nullish(), isSecretScanningDisabled: z.boolean(), kubernetesAutoFetchServiceAccountToken: z.boolean(), - paramsFolderSecretDetectionEnabled: z.boolean() + paramsFolderSecretDetectionEnabled: z.boolean(), + isOfflineUsageReportsEnabled: z.boolean() }) }) } @@ -69,7 +71,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, - paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED + paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED, + isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE } }; } @@ -215,7 +218,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }), membershipId: z.string(), role: z.string(), - roleId: z.string().nullish() + roleId: z.string().nullish(), + status: z.string().nullish() }) .array(), projects: z @@ -838,4 +842,121 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/organization-management/organizations", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: GenericResourceNameSchema, + inviteAdminEmails: z.string().email().array().min(1) + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organization = await server.services.superAdmin.createOrganization(req.body, req.permission); + return { organization }; + } + }); + + server.route({ + method: "POST", + url: "/organization-management/organizations/:organizationId/memberships/:membershipId/resend-invite", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string(), + membershipId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.resendOrgInvite(req.params, req.permission); + return { organizationMembership }; + } + }); + + server.route({ + method: "POST", + url: "/organization-management/organizations/:organizationId/access", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.joinOrganization( + req.params.organizationId, + req.permission + ); + return { organizationMembership }; + } + }); + + server.route({ + method: "POST", + url: "/usage-report/generate", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + csvContent: z.string(), + signature: z.string(), + filename: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const result = await server.services.offlineUsageReport.generateUsageReportCSV(); + + return { + csvContent: result.csvContent, + signature: result.signature, + filename: result.filename + }; + } + }); }; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 458efd194..6108be32b 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -33,6 +33,7 @@ import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; +import { registerNotificationRouter } from "./notification-router"; import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; @@ -83,6 +84,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); + await server.register(registerNotificationRouter, { prefix: "/notifications" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); diff --git a/backend/src/server/routes/v1/notification-router.ts b/backend/src/server/routes/v1/notification-router.ts new file mode 100644 index 000000000..5f72b88d6 --- /dev/null +++ b/backend/src/server/routes/v1/notification-router.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { UserNotificationsSchema } from "@app/db/schemas/user-notifications"; +import { UnauthorizedError } from "@app/lib/errors"; +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 registerNotificationRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/user", + config: { + rateLimit: readLimit + }, + method: "GET", + schema: { + response: { + 200: z.object({ + notifications: UserNotificationsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notifications = await server.services.notification.listUserNotifications({ + userId: req.auth.userId, + orgId: req.auth.orgId + }); + + return { notifications }; + } + }); + + server.route({ + url: "/user/:notificationId", + config: { + rateLimit: writeLimit + }, + method: "DELETE", + schema: { + params: z.object({ + notificationId: z.string() + }), + response: { + 200: z.object({ + notification: UserNotificationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notification = await server.services.notification.deleteUserNotification({ + notificationId: req.params.notificationId, + userId: req.auth.userId + }); + + return { notification }; + } + }); + + server.route({ + url: "/user/:notificationId", + config: { + rateLimit: writeLimit + }, + method: "PATCH", + schema: { + params: z.object({ + notificationId: z.string() + }), + body: z.object({ + isRead: z.boolean() + }), + response: { + 200: z.object({ + notification: UserNotificationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notification = await server.services.notification.updateUserNotification({ + notificationId: req.params.notificationId, + userId: req.auth.userId, + ...req.body + }); + + return { notification }; + } + }); + + // Mark all user notifications as read + server.route({ + url: "/user/mark-as-read", + config: { + rateLimit: writeLimit + }, + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + await server.services.notification.markUserNotificationsAsRead({ + userId: req.auth.userId, + orgId: req.auth.orgId + }); + } + }); +}; diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index 259a97ddb..a3b744485 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -2,10 +2,13 @@ import fastifyMultipart from "@fastify/multipart"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { VaultMappingType } from "@app/services/external-migration/external-migration-types"; +import { + ExternalMigrationProviders, + VaultMappingType +} from "@app/services/external-migration/external-migration-types"; const MB25_IN_BYTES = 26214400; @@ -81,4 +84,33 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider }); } }); + + server.route({ + method: "GET", + url: "/custom-migration-enabled/:provider", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + provider: z.nativeEnum(ExternalMigrationProviders) + }), + response: { + 200: z.object({ + enabled: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const enabled = await server.services.migration.hasCustomVaultMigration({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + provider: req.params.provider + }); + return { enabled }; + } + }); }; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index b9425d7be..02bd0c77f 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -56,7 +56,7 @@ const getConnectionConfig = ({ ? { rejectUnauthorized: sslRejectUnauthorized, ca: sslCertificate, - servername: host + serverName: host } : false }; @@ -90,7 +90,7 @@ export const getSqlConnectionClient = async (appConnection: Pick( }, { protocol: GatewayProxyProtocol.Tcp, - targetHost, + targetHost: app === AppConnection.Postgres ? targetHost : credentials.host, targetPort: credentials.port, relayHost, relayPort: Number(relayPort), diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 0e5ed2ad9..ede9e29e3 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -453,23 +453,24 @@ export const authLoginServiceFactory = ({ const selectedOrg = await orgDAL.findById(organizationId); + if (!selectedOrgMembership) { + throw new ForbiddenRequestError({ + message: `User does not have access to the organization named ${selectedOrg?.name}` + }); + } + // Check if authEnforced is true and the current auth method is not an enforced method if ( selectedOrg.authEnforced && !isAuthMethodSaml(decodedToken.authMethod) && - decodedToken.authMethod !== AuthMethod.OIDC + decodedToken.authMethod !== AuthMethod.OIDC && + !(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin) ) { throw new BadRequestError({ message: "Login with the auth method required by your organization." }); } - if (!selectedOrgMembership) { - throw new ForbiddenRequestError({ - message: `User does not have access to the organization named ${selectedOrg?.name}` - }); - } - if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 9fed186bc..ab89ea996 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1190,7 +1190,9 @@ export const internalCertificateAuthorityServiceFactory = ({ }); } - collectionId = certificateTemplate.pkiCollectionId as string; + if (!collectionId) { + collectionId = certificateTemplate.pkiCollectionId as string; + } ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); } diff --git a/backend/src/services/external-migration/external-migration-fns/vault.ts b/backend/src/services/external-migration/external-migration-fns/vault.ts index f5f57aa1b..ca6d2b0b0 100644 --- a/backend/src/services/external-migration/external-migration-fns/vault.ts +++ b/backend/src/services/external-migration/external-migration-fns/vault.ts @@ -408,19 +408,123 @@ export const transformToInfisicalFormatNamespaceToProjects = ( }; }; +export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData: VaultData[]): InfisicalImportData => { + const projects: Array<{ name: string; id: string }> = []; + const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = []; + const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = []; + const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = []; + + // track created entities to avoid duplicates + const projectMap = new Map(); // team name -> projectId + const environmentMap = new Map(); // team-name:envName -> environmentId + const folderMap = new Map(); // team-name:envName:folderPath -> folderId + + for (const data of vaultData) { + const { path, secretData } = data; + + const pathParts = path.split("/").filter(Boolean); + if (pathParts.length < 2) { + // eslint-disable-next-line no-continue + continue; + } + + // first level: environment (dev, prod, staging, etc.) + const environmentName = pathParts[0]; + // second level: team name (team1, team2, etc.) + const teamName = pathParts[1]; + // remaining parts: folder structure + const folderParts = pathParts.slice(2); + + // create project (team) if if doesn't exist + if (!projectMap.has(teamName)) { + const projectId = uuidv4(); + projectMap.set(teamName, projectId); + projects.push({ + name: teamName, + id: projectId + }); + } + const projectId = projectMap.get(teamName)!; + + // create environment (dev, prod, etc.) for team + const envKey = `${teamName}:${environmentName}`; + if (!environmentMap.has(envKey)) { + const environmentId = uuidv4(); + environmentMap.set(envKey, environmentId); + environments.push({ + name: environmentName, + id: environmentId, + projectId + }); + } + const environmentId = environmentMap.get(envKey)!; + + // create folder structure for path segments + let currentFolderId: string | undefined; + let currentPath = ""; + + for (const folderName of folderParts) { + currentPath = currentPath ? `${currentPath}/${folderName}` : folderName; + const folderKey = `${teamName}:${environmentName}:${currentPath}`; + + if (!folderMap.has(folderKey)) { + const folderId = uuidv4(); + folderMap.set(folderKey, folderId); + folders.push({ + id: folderId, + name: folderName, + environmentId, + parentFolderId: currentFolderId || environmentId + }); + currentFolderId = folderId; + } else { + currentFolderId = folderMap.get(folderKey)!; + } + } + + for (const [key, value] of Object.entries(secretData)) { + secrets.push({ + id: uuidv4(), + name: key, + environmentId, + value: String(value), + folderId: currentFolderId + }); + } + } + + return { + projects, + environments, + folders, + secrets + }; +}; + +// refer to internal doc for more details on which ID's belong to which orgs. +// when its a custom migration, then it doesn't matter which mapping type is used (as of now). +export const vaultMigrationTransformMappings: Record< + string, + (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData +> = { + "68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1 +} as const; + export const importVaultDataFn = async ( { vaultAccessToken, vaultNamespace, vaultUrl, mappingType, - gatewayId + gatewayId, + orgId }: { vaultAccessToken: string; vaultNamespace?: string; vaultUrl: string; mappingType: VaultMappingType; gatewayId?: string; + orgId: string; }, { gatewayService }: { gatewayService: Pick } ) => { @@ -432,6 +536,25 @@ export const importVaultDataFn = async ( }); } + let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData; + + if (mappingType === VaultMappingType.Custom) { + transformFn = vaultMigrationTransformMappings[orgId]; + + if (!transformFn) { + throw new BadRequestError({ + message: "Please contact our sales team to enable custom vault migrations." + }); + } + } else { + transformFn = transformToInfisicalFormatNamespaceToProjects; + } + + logger.info( + { orgId, mappingType }, + `[importVaultDataFn]: Running ${orgId in vaultMigrationTransformMappings ? "custom" : "default"} transform` + ); + const vaultApi = vaultFactory(gatewayService); const vaultData = await vaultApi.collectVaultData({ @@ -441,7 +564,5 @@ export const importVaultDataFn = async ( gatewayId }); - const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); - - return infisicalData; + return transformFn(vaultData, mappingType); }; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index f1047d0ee..73fac00b9 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -5,9 +5,20 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { TUserDALFactory } from "../user/user-dal"; -import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns"; +import { + decryptEnvKeyDataFn, + importVaultDataFn, + parseEnvKeyDataFn, + vaultMigrationTransformMappings +} from "./external-migration-fns"; import { TExternalMigrationQueueFactory } from "./external-migration-queue"; -import { ExternalPlatforms, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types"; +import { + ExternalMigrationProviders, + ExternalPlatforms, + THasCustomVaultMigrationDTO, + TImportEnvKeyDataDTO, + TImportVaultDataDTO +} from "./external-migration-types"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; @@ -101,7 +112,8 @@ export const externalMigrationServiceFactory = ({ vaultNamespace, vaultUrl, mappingType, - gatewayId + gatewayId, + orgId: actorOrgId }, { gatewayService @@ -127,8 +139,37 @@ export const externalMigrationServiceFactory = ({ }); }; + const hasCustomVaultMigration = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + provider + }: THasCustomVaultMigrationDTO) => { + const { membership } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + if (membership.role !== OrgMembershipRole.Admin) { + throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" }); + } + + if (provider !== ExternalMigrationProviders.Vault) { + throw new BadRequestError({ + message: "Invalid provider. Vault is the only supported provider for custom migrations." + }); + } + + return actorOrgId in vaultMigrationTransformMappings; + }; + return { importEnvKeyData, - importVaultData + importVaultData, + hasCustomVaultMigration }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index ac8ff44e2..804444172 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -4,7 +4,8 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type"; export enum VaultMappingType { Namespace = "namespace", - KeyVault = "key-vault" + KeyVault = "key-vault", + Custom = "custom" } export type InfisicalImportData = { @@ -26,6 +27,10 @@ export type TImportEnvKeyDataDTO = { encryptedJson: { nonce: string; data: string }; } & Omit; +export type THasCustomVaultMigrationDTO = { + provider: ExternalMigrationProviders; +} & Omit; + export type TImportVaultDataDTO = { vaultAccessToken: string; vaultNamespace?: string; @@ -111,3 +116,8 @@ export enum ExternalPlatforms { EnvKey = "EnvKey", Vault = "Vault" } + +export enum ExternalMigrationProviders { + Vault = "vault", + EnvKey = "env-key" +} diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 597747683..8aec16371 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -84,18 +84,20 @@ export const identityUaServiceFactory = ({ const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - let lock: Awaited>; - try { - lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { - retryCount: 3, - retryDelay: 300, - retryJitter: 100 - }); - } catch (e) { - logger.info( - `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` - ); - throw new RateLimitError({ message: "Rate limit exceeded" }); + let lock: Awaited> | undefined; + if (identityUa.lockoutEnabled) { + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + } } try { @@ -257,7 +259,7 @@ export const identityUaServiceFactory = ({ ...accessTokenTTLParams }; } finally { - await lock.release(); + if (lock) await lock.release(); } }; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index 2efec7a47..eaad08df8 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -39,7 +39,7 @@ const getIntegrationSecretsV2 = async ( }, secretV2BridgeDAL: Pick, folderDAL: Pick, - secretImportDAL: Pick + secretImportDAL: Pick ) => { const content: Record = {}; if (dto.depth > MAX_SYNC_SECRET_DEPTH) { @@ -300,7 +300,7 @@ export const deleteIntegrationSecrets = async ({ projectBotService: Pick; secretV2BridgeDAL: Pick; folderDAL: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; secretDAL: Pick; kmsService: Pick; }) => { diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 2ef8615eb..463ca12e3 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -40,7 +40,7 @@ type TIntegrationServiceFactoryDep = { projectBotService: TProjectBotServiceFactory; secretQueueService: Pick; secretV2BridgeDAL: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; kmsService: Pick; secretDAL: Pick; }; diff --git a/backend/src/services/notification/notification-queue.ts b/backend/src/services/notification/notification-queue.ts new file mode 100644 index 000000000..e5d89c83b --- /dev/null +++ b/backend/src/services/notification/notification-queue.ts @@ -0,0 +1,39 @@ +import { QueueJobs, TQueueServiceFactory } from "@app/queue"; + +import { TCreateUserNotificationDTO } from "./notification-types"; +import { TUserNotificationDALFactory } from "./user-notification-dal"; + +type TNotificationQueueServiceFactoryDep = { + userNotificationDAL: Pick; + queueService: TQueueServiceFactory; +}; + +export type TNotificationQueueServiceFactory = { + pushUserNotifications: (data: TCreateUserNotificationDTO[]) => Promise; +}; + +export const notificationQueueServiceFactory = async ({ + userNotificationDAL, + queueService +}: TNotificationQueueServiceFactoryDep): Promise => { + const pushUserNotifications = async (data: TCreateUserNotificationDTO[]) => { + await queueService.queuePg(QueueJobs.UserNotification, { notifications: data }); + }; + + await queueService.startPg( + QueueJobs.UserNotification, + async ([job]) => { + const { notifications } = job.data as { notifications: TCreateUserNotificationDTO[] }; + await userNotificationDAL.batchInsert(notifications); + }, + { + batchSize: 1, + workerCount: 2, + pollingIntervalSeconds: 1 + } + ); + + return { + pushUserNotifications + }; +}; diff --git a/backend/src/services/notification/notification-service.ts b/backend/src/services/notification/notification-service.ts new file mode 100644 index 000000000..ed50c3f1c --- /dev/null +++ b/backend/src/services/notification/notification-service.ts @@ -0,0 +1,81 @@ +import { NotFoundError, UnauthorizedError } from "@app/lib/errors"; + +import { TNotificationQueueServiceFactory } from "./notification-queue"; +import { TCreateUserNotificationDTO } from "./notification-types"; +import { TUserNotificationDALFactory } from "./user-notification-dal"; + +type TNotificationServiceFactoryDep = { + notificationQueue: TNotificationQueueServiceFactory; + userNotificationDAL: TUserNotificationDALFactory; +}; + +export type TNotificationServiceFactory = ReturnType; + +export const notificationServiceFactory = ({ + notificationQueue, + userNotificationDAL +}: TNotificationServiceFactoryDep) => { + const listUserNotifications = async ({ userId, orgId }: { userId: string; orgId: string }) => { + const now = new Date(); + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + const notifications = await userNotificationDAL.find({ + userId, + orgId, + startDate: threeMonthsAgo.toISOString(), + endDate: now.toISOString() + }); + + return notifications; + }; + + const createUserNotifications = async (data: TCreateUserNotificationDTO[]) => { + return notificationQueue.pushUserNotifications(data); + }; + + const deleteUserNotification = async ({ userId, notificationId }: { userId: string; notificationId: string }) => { + if (!userId) throw new UnauthorizedError({ message: "Invalid userId" }); + + const deletedNotifications = await userNotificationDAL.delete({ id: notificationId, userId }); + + if (deletedNotifications.length <= 0) throw new NotFoundError({ message: "Notification not found" }); + + return deletedNotifications[0]; + }; + + const markUserNotificationsAsRead = async ({ userId, orgId }: { userId: string; orgId: string }) => { + await userNotificationDAL.markAllNotificationsAsRead(userId, orgId); + }; + + const updateUserNotification = async ({ + userId, + notificationId, + isRead + }: { + userId: string; + notificationId: string; + isRead: boolean; + }) => { + const [updatedNotification] = await userNotificationDAL.update( + { + id: notificationId, + userId + }, + { + isRead + } + ); + + if (!updatedNotification) throw new NotFoundError({ message: "Notification not found" }); + return updatedNotification; + }; + + return { + listUserNotifications, + createUserNotifications, + deleteUserNotification, + markUserNotificationsAsRead, + updateUserNotification + }; +}; diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts new file mode 100644 index 000000000..30bc87244 --- /dev/null +++ b/backend/src/services/notification/notification-types.ts @@ -0,0 +1,15 @@ +export enum NotificationType { + ACCESS_APPROVAL_REQUEST = "access-approval-request", + ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated" +} + +export interface TCreateUserNotificationDTO { + userId: string; + // Adding an orgId will make the notification only show up when a user is in a certain org. Otherwise, it shows up in all orgs. + // Keep in mind that org-scoped links for a notification will break if orgId is missing and the user is in the wrong org + orgId?: string; + type: NotificationType; + title: string; + body?: string; + link?: string; +} diff --git a/backend/src/services/notification/user-notification-dal.ts b/backend/src/services/notification/user-notification-dal.ts new file mode 100644 index 000000000..6dcb2e3d0 --- /dev/null +++ b/backend/src/services/notification/user-notification-dal.ts @@ -0,0 +1,130 @@ +import knex from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; + +export type TUserNotificationDALFactory = ReturnType; + +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + +export const userNotificationDALFactory = (db: TDbClient) => { + const notificationOrm = ormify(db, TableName.UserNotifications); + + const find = async ( + { + userId, + orgId, + startDate, + endDate, + limit = 1000, + offset = 0 + }: { + userId: string; + orgId: string; + startDate: string; + endDate: string; + limit?: number; + offset?: number; + }, + tx?: knex.Knex + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.UserNotifications) + .where(`${TableName.UserNotifications}.userId`, userId) + .andWhere((qb) => { + void qb + .where(`${TableName.UserNotifications}.orgId`, orgId) + .orWhereNull(`${TableName.UserNotifications}.orgId`); + }) + .whereRaw(`"${TableName.UserNotifications}"."createdAt" >= ?::timestamptz`, [startDate]) + .andWhereRaw(`"${TableName.UserNotifications}"."createdAt" < ?::timestamptz`, [endDate]) + .select(selectAllTableCols(TableName.UserNotifications)) + .limit(limit) + .offset(offset) + .orderBy(`${TableName.UserNotifications}.createdAt`, "desc") + .timeout(1000 * 120); // 2 minutes timeout + + return docs; + } catch (error) { + if (error instanceof knex.KnexTimeoutError) { + throw new GatewayTimeoutError({ + error, + message: "Failed to fetch notifications due to timeout." + }); + } + + throw new DatabaseError({ error }); + } + }; + + // delete all notifications older than 3 months + const pruneNotifications = async () => { + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + let deletedNotificationIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + + logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedNotificationIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); + + const findExpiredNotificationSubQuery = trx(TableName.UserNotifications) + .where("createdAt", "<", threeMonthsAgo) + .orderBy(`${TableName.UserNotifications}.createdAt`, "desc") + .select("id") + .limit(PRUNE_BATCH_SIZE); + + // eslint-disable-next-line no-await-in-loop + const results = await trx(TableName.UserNotifications) + .whereIn("id", findExpiredNotificationSubQuery) + .del() + .returning("id"); + + return results; + }); + + numberOfRetryOnFailure = 0; + } catch (error) { + numberOfRetryOnFailure += 1; + deletedNotificationIds = []; + logger.error(error, "Failed to delete notification on pruning. Retrying..."); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + } while ( + deletedNotificationIds.length > 0 || + (numberOfRetryOnFailure > 0 && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE) + ); + + if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) { + logger.error( + `${QueueName.DailyResourceCleanUp}: prune notifications completed with persistent errors after ${MAX_RETRY_ON_FAILURE} retries. Some notifications might not have been pruned.` + ); + } else { + logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications completed`); + } + }; + + const markAllNotificationsAsRead = async (userId: string, orgId: string) => { + await db(TableName.UserNotifications) + .where({ userId }) + .andWhere((qb) => { + void qb.where({ orgId }).orWhereNull("orgId"); + }) + .update({ isRead: true }); + }; + + return { ...notificationOrm, pruneNotifications, find, markAllNotificationsAsRead }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-dal.ts b/backend/src/services/offline-usage-report/offline-usage-report-dal.ts new file mode 100644 index 000000000..109d90da8 --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-dal.ts @@ -0,0 +1,208 @@ +import { TDbClient } from "@app/db"; +import { ProjectType, TableName } from "@app/db/schemas"; + +export type TOfflineUsageReportDALFactory = ReturnType; + +export const offlineUsageReportDALFactory = (db: TDbClient) => { + const getUserMetrics = async () => { + // Get total users and admin users + const userMetrics = (await db + .from(TableName.Users) + .select( + db.raw( + ` + COUNT(*) as total_users, + COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users + ` + ) + ) + .where({ isGhost: false }) + .first()) as { total_users: string; admin_users: string } | undefined; + + // Get users by auth method + const authMethodStats = (await db + .from(TableName.Users) + .select( + db.raw(` + unnest("authMethods") as auth_method, + COUNT(*) as count + `) + ) + .where({ isGhost: false }) + .whereNotNull("authMethods") + .groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>; + + const usersByAuthMethod = authMethodStats.reduce( + (acc: Record, row: { auth_method: string; count: string }) => { + acc[row.auth_method] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + return { + totalUsers: parseInt(userMetrics?.total_users || "0", 10), + adminUsers: parseInt(userMetrics?.admin_users || "0", 10), + usersByAuthMethod + }; + }; + + const getMachineIdentityMetrics = async () => { + // Get total machine identities + const identityMetrics = (await db + .from(TableName.Identity) + .select( + db.raw( + ` + COUNT(*) as total_identities + ` + ) + ) + .first()) as { total_identities: string } | undefined; + + // Get identities by auth method + const authMethodStats = (await db + .from(TableName.Identity) + .select("authMethod") + .count("* as count") + .whereNotNull("authMethod") + .groupBy("authMethod")) as Array<{ authMethod: string; count: string }>; + + const machineIdentitiesByAuthMethod = authMethodStats.reduce( + (acc: Record, row: { authMethod: string; count: string }) => { + acc[row.authMethod] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + return { + totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10), + machineIdentitiesByAuthMethod + }; + }; + + const getProjectMetrics = async () => { + // Get total projects and projects by type + const projectMetrics = (await db + .from(TableName.Project) + .select("type") + .count("* as count") + .groupBy("type")) as Array<{ type: string; count: string }>; + + const totalProjects = projectMetrics.reduce( + (sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10), + 0 + ); + const projectsByType = projectMetrics.reduce( + (acc: Record, row: { type: string; count: string }) => { + acc[row.type] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + // Calculate average secrets per project + const secretsPerProject = (await db + .from(`${TableName.SecretV2} as s`) + .select("p.id as projectId") + .count("s.id as count") + .leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id") + .leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id") + .leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id") + .where("p.type", ProjectType.SecretManager) + .groupBy("p.id") + .whereNotNull("p.id")) as Array<{ projectId: string; count: string }>; + + const averageSecretsPerProject = + secretsPerProject.length > 0 + ? secretsPerProject.reduce( + (sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10), + 0 + ) / secretsPerProject.length + : 0; + + return { + totalProjects, + projectsByType, + averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100 + }; + }; + + const getSecretMetrics = async () => { + // Get total secrets + const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as + | { count: string } + | undefined; + + const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10); + + // Get secrets by project + const secretsByProject = (await db + .from(`${TableName.SecretV2} as s`) + .select("p.id as projectId", "p.name as projectName") + .count("s.id as secretCount") + .leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id") + .leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id") + .leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id") + .where("p.type", ProjectType.SecretManager) + .groupBy("p.id", "p.name") + .whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>; + + return { + totalSecrets, + secretsByProject: secretsByProject.map( + (row: { projectId: string; projectName: string; secretCount: string }) => ({ + projectId: row.projectId, + projectName: row.projectName, + secretCount: parseInt(row.secretCount, 10) + }) + ) + }; + }; + + const getSecretSyncMetrics = async () => { + const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as + | { count: string } + | undefined; + + return { + totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10) + }; + }; + + const getDynamicSecretMetrics = async () => { + const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as + | { count: string } + | undefined; + + return { + totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10) + }; + }; + + const getSecretRotationMetrics = async () => { + // Check both v1 and v2 secret rotation tables + const [v1RotationsResult, v2RotationsResult] = await Promise.all([ + db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>, + db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined> + ]); + + const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10); + const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10); + + return { + totalSecretRotations: totalV1Rotations + totalV2Rotations + }; + }; + + return { + getUserMetrics, + getMachineIdentityMetrics, + getProjectMetrics, + getSecretMetrics, + getSecretSyncMetrics, + getDynamicSecretMetrics, + getSecretRotationMetrics + }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-service.ts b/backend/src/services/offline-usage-report/offline-usage-report-service.ts new file mode 100644 index 000000000..179232aa4 --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-service.ts @@ -0,0 +1,133 @@ +import crypto from "crypto"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal"; + +type TOfflineUsageReportServiceFactoryDep = { + offlineUsageReportDAL: TOfflineUsageReportDALFactory; + licenseService: Pick; +}; + +export type TOfflineUsageReportServiceFactory = ReturnType; + +export const offlineUsageReportServiceFactory = ({ + offlineUsageReportDAL, + licenseService +}: TOfflineUsageReportServiceFactoryDep) => { + const signReportContent = (content: string, licenseId: string): string => { + const contentHash = crypto.createHash("sha256").update(content).digest("hex"); + const hmac = crypto.createHmac("sha256", licenseId); + hmac.update(contentHash); + return hmac.digest("hex"); + }; + + const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => { + const expectedSignature = signReportContent(content, licenseId); + return signature === expectedSignature; + }; + + const generateUsageReportCSV = async () => { + const cfg = getConfig(); + if (!cfg.LICENSE_KEY_OFFLINE) { + throw new BadRequestError({ + message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured." + }); + } + + const customerId = licenseService.getCustomerId() as string; + const licenseId = licenseService.getLicenseId(); + + const [ + userMetrics, + machineIdentityMetrics, + projectMetrics, + secretMetrics, + secretSyncMetrics, + dynamicSecretMetrics, + secretRotationMetrics + ] = await Promise.all([ + offlineUsageReportDAL.getUserMetrics(), + offlineUsageReportDAL.getMachineIdentityMetrics(), + offlineUsageReportDAL.getProjectMetrics(), + offlineUsageReportDAL.getSecretMetrics(), + offlineUsageReportDAL.getSecretSyncMetrics(), + offlineUsageReportDAL.getDynamicSecretMetrics(), + offlineUsageReportDAL.getSecretRotationMetrics() + ]); + + const headers = [ + "Total Users", + "Admin Users", + "Total Identities", + "Total Projects", + "Total Secrets", + "Total Secret Syncs", + "Total Dynamic Secrets", + "Total Secret Rotations", + "Avg Secrets Per Project" + ]; + + const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod); + allUserAuthMethods.forEach((method) => { + headers.push(`Users Auth ${method}`); + }); + + const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod); + allIdentityAuthMethods.forEach((method) => { + headers.push(`Identities Auth ${method}`); + }); + + const allProjectTypes = Object.keys(projectMetrics.projectsByType); + allProjectTypes.forEach((type) => { + headers.push(`Projects ${type}`); + }); + + headers.push("Signature"); + + const dataRow: (string | number)[] = [ + userMetrics.totalUsers, + userMetrics.adminUsers, + machineIdentityMetrics.totalMachineIdentities, + projectMetrics.totalProjects, + secretMetrics.totalSecrets, + secretSyncMetrics.totalSecretSyncs, + dynamicSecretMetrics.totalDynamicSecrets, + secretRotationMetrics.totalSecretRotations, + projectMetrics.averageSecretsPerProject + ]; + + allUserAuthMethods.forEach((method) => { + dataRow.push(userMetrics.usersByAuthMethod[method] || 0); + }); + allIdentityAuthMethods.forEach((method) => { + dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0); + }); + + allProjectTypes.forEach((type) => { + dataRow.push(projectMetrics.projectsByType[type] || 0); + }); + + const headersWithoutSignature = headers.slice(0, -1); + const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n"); + + const signature = signReportContent(contentWithoutSignature, licenseId); + dataRow.push(signature); + + const csvContent = [headers.join(","), dataRow.join(",")].join("\n"); + + return { + csvContent, + signature, + filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv` + }; + }; + + return { + generateUsageReportCSV, + verifyReportSignature: (csvContent: string, signature: string, licenseId: string) => + verifyReportContent(csvContent, signature, licenseId) + }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-types.ts b/backend/src/services/offline-usage-report/offline-usage-report-types.ts new file mode 100644 index 000000000..614d5ccce --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-types.ts @@ -0,0 +1,42 @@ +export interface TUsageMetrics { + // User metrics + totalUsers: number; + usersByAuthMethod: Record; + adminUsers: number; + + // Machine identity metrics + totalMachineIdentities: number; + machineIdentitiesByAuthMethod: Record; + + // Project metrics + totalProjects: number; + projectsByType: Record; + averageSecretsPerProject: number; + + // Secret metrics + totalSecrets: number; + totalSecretSyncs: number; + totalDynamicSecrets: number; + totalSecretRotations: number; +} + +export interface TUsageReportMetadata { + generatedAt: string; + instanceId: string; + reportVersion: string; +} + +export interface TUsageReport { + metadata: TUsageReportMetadata; + metrics: TUsageMetrics; + signature?: string; +} + +export interface TGenerateUsageReportDTO { + includeSignature?: boolean; +} + +export interface TVerifyUsageReportDTO { + reportData: string; + signature: string; +} diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index aa22b11c7..925784bff 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) + .select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus")) .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); const formattedDocs = sqlNestRelationships({ @@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => { orgMembershipId, orgMembershipRole, orgMembershipRoleName, - orgMembershipRoleId + orgMembershipRoleId, + orgMembershipStatus }) => ({ user: { id: userId, @@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => { firstName, lastName }, + status: orgMembershipStatus, membershipId: orgMembershipId, role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name roleId: orgMembershipRoleId @@ -488,6 +491,15 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => { + try { + const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*"); + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Create org memberships" }); + } + }; + const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => { try { const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*"); @@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => { findMembership, findMembershipWithScimFilter, createMembership, + bulkCreateMemberships, updateMembershipById, deleteMembershipById, deleteMembershipsById, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 356a8451c..978bc8dae 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -528,15 +528,18 @@ export const orgServiceFactory = ({ /* * Create organization * */ - const createOrganization = async ({ - userId, - userEmail, - orgName - }: { - userId: string; - orgName: string; - userEmail?: string | null; - }) => { + const createOrganization = async ( + { + userId, + userEmail, + orgName + }: { + userId?: string; + orgName: string; + userEmail?: string | null; + }, + trx?: Knex + ) => { const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); const key = crypto.randomBytes(32).toString("base64"); const { @@ -555,22 +558,25 @@ export const orgServiceFactory = ({ } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); - const organization = await orgDAL.transaction(async (tx) => { + + const createOrg = async (tx: Knex) => { // akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy const org = await orgDAL.create( { name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) }, tx ); - await orgDAL.createMembership( - { - userId, - orgId: org.id, - role: OrgMembershipRole.Admin, - status: OrgMembershipStatus.Accepted, - isActive: true - }, - tx - ); + if (userId) { + await orgDAL.createMembership( + { + userId, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted, + isActive: true + }, + tx + ); + } await orgBotDAL.create( { name: org.name, @@ -590,7 +596,9 @@ export const orgServiceFactory = ({ tx ); return org; - }); + }; + + const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg)); await licenseService.updateSubscriptionOrgMemberCount(organization.id); return organization; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 4261870b1..d59f20bc6 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,4 +1,5 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { createMongoAbility, ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability"; +import { PackRule, unpackRules } from "@casl/ability/extra"; import slugify from "@sindresorhus/slugify"; import { @@ -16,9 +17,11 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSet, ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -1852,9 +1855,52 @@ export const projectServiceFactory = ({ if (projectMember) throw new BadRequestError({ message: "User already has access to the project" }); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); - const filteredProjectMembers = projectMembers + + let filteredProjectMembers = projectMembers .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) .map((el) => el.user.email!); + if (filteredProjectMembers.length === 0) { + const customRolesWithMemberCreate = await projectRoleDAL.find({ projectId }); + const customRoleSlugsCanCreate = customRolesWithMemberCreate + .filter((role) => { + try { + const permissions = ( + typeof role.permissions === "string" + ? (JSON.parse(role.permissions) as PackRule>>[]) + : role.permissions + ) as PackRule>>[]; + + const ability = createMongoAbility>( + unpackRules>>(permissions) + ); + return ability.can(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member); + } catch { + return false; + } + }) + .map((role) => role.slug); + + if (customRoleSlugsCanCreate.length > 0) { + const usersWithCustomCreateMemberRole = projectMembers + .filter((member) => + member.roles.some((role) => role.customRoleSlug && customRoleSlugsCanCreate.includes(role.customRoleSlug)) + ) + .map((el) => el.user.email!) + .filter(Boolean); + + if (usersWithCustomCreateMemberRole.length > 0) { + filteredProjectMembers = usersWithCustomCreateMemberRole; + } + } + } + + if (filteredProjectMembers.length === 0) { + throw new BadRequestError({ + message: + "No users in this project have permission to grant you access. Please contact an organization administrator to assign the necessary permissions." + }); + } + const org = await orgDAL.findOne({ id: permission.orgId }); const project = await projectDAL.findById(projectId); const userDetails = await userDAL.findById(permission.id); diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index dcfa7ea0d..fbd9d3fd6 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -3,6 +3,7 @@ import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-d import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TUserNotificationDALFactory } from "@app/services/notification/user-notification-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; @@ -25,6 +26,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { serviceTokenService: Pick; queueService: TQueueServiceFactory; orgService: TOrgServiceFactory; + userNotificationDAL: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -40,7 +42,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretVersionV2DAL, identityUniversalAuthClientSecretDAL, serviceTokenService, - orgService + orgService, + userNotificationDAL }: TDailyResourceCleanUpQueueServiceFactoryDep) => { const appCfg = getConfig(); @@ -78,6 +81,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await serviceTokenService.notifyExpiringTokens(); await orgService.notifyInvitedUsers(); await auditLogDAL.pruneAuditLog(); + await userNotificationDAL.pruneNotifications(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); } catch (error) { logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index dbe2f6a84..db611dc6c 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -127,6 +127,27 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; + const findByIds = async (ids: string[], tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretImport) + .whereIn(`${TableName.SecretImport}.id`, ids) + .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) + .select( + db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, + db.ref("slug").withSchema(TableName.Environment), + db.ref("name").withSchema(TableName.Environment), + db.ref("id").withSchema(TableName.Environment).as("envId") + ); + + return docs.map(({ envId, slug, name, ...el }) => ({ + ...el, + importEnv: { id: envId, slug, name } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find secret imports by ids" }); + } + }; + const getProjectImportCount = async ( { search, ...filter }: Partial, tx?: Knex @@ -325,6 +346,7 @@ export const secretImportDALFactory = (db: TDbClient) => { ...secretImportOrm, find, findById, + findByIds, findByFolderIds, findLastImportPosition, updateAllPosition, diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 6aa73465d..c739c5ad2 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -1,3 +1,5 @@ +import RE2 from "re2"; + import { SecretType, TSecretImports, TSecrets, TSecretsV2 } from "@app/db/schemas"; import { groupBy, unique } from "@app/lib/fn"; @@ -54,6 +56,74 @@ type TSecretImportSecretsV2 = { const LEVEL_BREAK = 10; const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`; +const RESERVED_IMPORT_REGEX = new RE2("/__reserve_replication_([a-f0-9-]{36})"); + +/** + * Processes reserved imports by resolving them to their replication source. + */ +const processReservedImports = async < + T extends { + isReserved?: boolean | null; + importPath: string; + importEnv: { id: string; slug: string; name: string }; + folderId: string; + } +>( + imports: T[], + secretImportDAL: Pick +): Promise => { + const reservedImportIds: string[] = []; + + imports.forEach((secretImport) => { + if (secretImport.isReserved) { + const reservedMatch = RESERVED_IMPORT_REGEX.exec(secretImport.importPath); + if (reservedMatch) { + const referencedImportId = reservedMatch[1]; + reservedImportIds.push(referencedImportId); + } + } + }); + + if (reservedImportIds.length === 0) { + return imports; + } + + try { + const importDetailsMap = new Map< + string, + { importPath: string; importEnv: { id: string; slug: string; name: string } } + >(); + + const referencedImports = await secretImportDAL.findByIds(reservedImportIds); + referencedImports.forEach((referencedImport) => { + importDetailsMap.set(referencedImport.id, { + importPath: referencedImport.importPath, + importEnv: referencedImport.importEnv + }); + }); + + return imports.map((secretImport) => { + if (secretImport.isReserved) { + const reservedMatch = RESERVED_IMPORT_REGEX.exec(secretImport.importPath); + if (reservedMatch) { + const referencedImportId = reservedMatch[1]; + const referencedDetails = importDetailsMap.get(referencedImportId); + + if (referencedDetails) { + return { + ...secretImport, + importPath: referencedDetails.importPath, + importEnv: referencedDetails.importEnv + }; + } + } + } + return secretImport; + }); + } catch (error) { + return imports; + } +}; export const fnSecretsFromImports = async ({ allowedImports: possibleCyclicImports, folderDAL, @@ -167,7 +237,7 @@ export const fnSecretsV2FromImports = async ({ folderDAL: Pick; viewSecretValue: boolean; secretDAL: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; decryptor: (value?: Buffer | null) => string; expandSecretReferences?: (inputSecret: { value?: string; @@ -188,6 +258,10 @@ export const fnSecretsV2FromImports = async ({ })[]; }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; + const processedSecretImports = await processReservedImports(rootSecretImports, secretImportDAL); + + stack[0] = { secretImports: processedSecretImports, depth: 0, parentImportedSecrets: [] }; + const processedImports: TSecretImportSecretsV2[] = []; while (stack.length) { diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts index 680dd4256..9bcf659aa 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -12,7 +12,7 @@ type TAWSParameterStoreRecord = Record; type TAWSParameterStoreMetadataRecord = Record; type TAWSParameterStoreTagsRecord = Record>; -const MAX_RETRIES = 5; +const MAX_RETRIES = 10; const BATCH_SIZE = 10; const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => { diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index 8e37cf277..8573d6ffc 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts @@ -38,7 +38,7 @@ type TAwsSecretsRecord = Record; type TAwsSecretValuesRecord = Record; type TAwsSecretDescriptionsRecord = Record; -const MAX_RETRIES = 5; +const MAX_RETRIES = 10; const BATCH_SIZE = 20; const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => { diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 7bef7d8c7..915ff89bf 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -80,8 +80,8 @@ type TSecretSyncQueueFactoryDep = { | "deleteMany" | "invalidateSecretCacheByProjectId" >; - secretImportDAL: Pick; - secretSyncDAL: Pick; + secretImportDAL: Pick; + secretSyncDAL: Pick; auditLogService: Pick; projectMembershipDAL: Pick; projectDAL: TProjectDALFactory; @@ -104,17 +104,15 @@ type SecretSyncActionJob = Job< TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO >; +const JITTER_MS = 10 * 1000; +const REQUEUE_MS = 30 * 1000; +const REQUEUE_LIMIT = 30; +const CONNECTION_CONCURRENCY_LIMIT = 3; + const getRequeueDelay = (failureCount?: number) => { - if (!failureCount) return 0; - - const baseDelay = 1000; - const maxDelay = 30000; - - const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay); - - const jitter = delay * (0.5 + Math.random() * 0.5); - - return jitter; + const jitter = Math.random() * JITTER_MS; + if (!failureCount) return jitter; + return REQUEUE_MS + jitter; }; export const secretSyncQueueFactory = ({ @@ -193,6 +191,46 @@ export const secretSyncQueueFactory = ({ folderCommitService }); + const $isConnectionConcurrencyLimitReached = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + if (!concurrencyCount) return false; + + const count = Number.parseInt(concurrencyCount, 10); + + if (Number.isNaN(count)) return false; + + return count >= CONNECTION_CONCURRENCY_LIMIT; + }; + + const $incrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + incrementedCount + ); + }; + + const $decrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + decrementedCount + ); + }; + const $getInfisicalSecrets = async ( secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, includeImports = true @@ -416,15 +454,11 @@ export const secretSyncQueueFactory = ({ return importedSecretMap; }; - const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => { + const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO, secretSync: TSecretSyncRaw) => { const { data: { syncId, auditLogInfo } } = job; - const secretSync = await secretSyncDAL.findById(syncId); - - if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); - await enterpriseSyncCheck( licenseService, secretSync.destination as SecretSync, @@ -566,15 +600,11 @@ export const secretSyncQueueFactory = ({ logger.info("SecretSync Sync Job with ID %s Completed", job.id); }; - const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => { + const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO, secretSync: TSecretSyncRaw) => { const { data: { syncId, auditLogInfo, importBehavior } } = job; - const secretSync = await secretSyncDAL.findById(syncId); - - if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); - await secretSyncDAL.updateById(syncId, { importStatus: SecretSyncStatus.Running }); @@ -683,15 +713,11 @@ export const secretSyncQueueFactory = ({ logger.info("SecretSync Import Job with ID %s Completed", job.id); }; - const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { + const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO, secretSync: TSecretSyncRaw) => { const { data: { syncId, auditLogInfo, deleteSyncOnComplete } } = job; - const secretSync = await secretSyncDAL.findById(syncId); - - if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); - await enterpriseSyncCheck( licenseService, secretSync.destination as SecretSync, @@ -894,6 +920,17 @@ export const secretSyncQueueFactory = ({ const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true }); + await secretSyncDAL.update( + { + $in: { + id: secretSyncs.map((sync) => sync.id) + } + }, + { + syncStatus: SecretSyncStatus.Pending + } + ); + await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id }))); }; @@ -904,7 +941,7 @@ export const secretSyncQueueFactory = ({ case QueueJobs.SecretSyncSyncSecrets: { const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO; - if (failedToAcquireLockCount < 10) { + if (failedToAcquireLockCount < REQUEUE_LIMIT) { await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); return; } @@ -974,6 +1011,26 @@ export const secretSyncQueueFactory = ({ | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO; + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + + const { connectionId } = secretSync; + + if (job.name === QueueJobs.SecretSyncSyncSecrets) { + const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId); + + if (isConcurrentLimitReached) { + logger.info( + `SecretSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]` + ); + + await $handleAcquireLockFailure(job as SecretSyncActionJob); + + return; + } + } + let lock: Awaited>; try { @@ -993,20 +1050,26 @@ export const secretSyncQueueFactory = ({ try { switch (job.name) { - case QueueJobs.SecretSyncSyncSecrets: - await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO); + case QueueJobs.SecretSyncSyncSecrets: { + await $incrementConnectionConcurrencyCount(connectionId); + await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO, secretSync); break; + } case QueueJobs.SecretSyncImportSecrets: - await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO); + await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO, secretSync); break; case QueueJobs.SecretSyncRemoveSecrets: - await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO); + await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO, secretSync); break; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled Secret Sync Job ${job.name}`); } } finally { + if (job.name === QueueJobs.SecretSyncSyncSecrets) { + await $decrementConnectionConcurrencyCount(connectionId); + } + await lock.release(); } }); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index b23cd0c56..48ab07816 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -108,7 +108,7 @@ type TSecretV2BridgeServiceFactoryDep = { | "findBySecretPathMultiEnv" | "findSecretPathByFolderIds" >; - secretImportDAL: Pick; + secretImportDAL: Pick; secretQueueService: Pick; secretApprovalPolicyService: Pick; secretApprovalRequestDAL: Pick; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index dc5713901..ed794a664 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -86,7 +86,7 @@ type TSecretQueueFactoryDep = { integrationAuthService: Pick; folderDAL: TSecretFolderDALFactory; secretDAL: TSecretDALFactory; - secretImportDAL: Pick; + secretImportDAL: Pick; webhookDAL: Pick; projectEnvDAL: Pick; projectDAL: TProjectDALFactory; diff --git a/backend/src/services/smtp/emails/OrganizationAssignmentTemplate.tsx b/backend/src/services/smtp/emails/OrganizationAssignmentTemplate.tsx new file mode 100644 index 000000000..14fb30d80 --- /dev/null +++ b/backend/src/services/smtp/emails/OrganizationAssignmentTemplate.tsx @@ -0,0 +1,69 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface OrganizationAssignmentTemplateProps extends Omit { + inviterFirstName?: string; + inviterUsername?: string; + organizationName: string; + callback_url: string; +} + +export const OrganizationAssignmentTemplate = ({ + organizationName, + inviterFirstName, + inviterUsername, + callback_url, + siteUrl +}: OrganizationAssignmentTemplateProps) => { + return ( + + + You've been added to the organization +
+ {organizationName} on Infisical +
+
+ + {inviterFirstName && inviterUsername ? ( + <> + {inviterFirstName} ( + {inviterUsername}) has added you as an + organization admin to {organizationName}. + + ) : ( + <> + An instance admin has added you as an organization admin to {organizationName}. + + )} + +
+
+ View Dashboard +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default OrganizationAssignmentTemplate; + +OrganizationAssignmentTemplate.PreviewProps = { + organizationName: "Example Organization", + inviterFirstName: "Jane", + inviterUsername: "jane@infisical.com", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com" +} as OrganizationAssignmentTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 209cd672b..71c338def 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate"; export * from "./NewDeviceLoginTemplate"; export * from "./OrgAdminBreakglassAccessTemplate"; export * from "./OrgAdminProjectGrantAccessTemplate"; +export * from "./OrganizationAssignmentTemplate"; export * from "./OrganizationInvitationTemplate"; export * from "./PasswordResetTemplate"; export * from "./PasswordSetupTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 500d0d89c..224f78265 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -18,6 +18,7 @@ import { NewDeviceLoginTemplate, OrgAdminBreakglassAccessTemplate, OrgAdminProjectGrantAccessTemplate, + OrganizationAssignmentTemplate, OrganizationInvitationTemplate, PasswordResetTemplate, PasswordSetupTemplate, @@ -61,6 +62,7 @@ export enum SmtpTemplates { // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", + OrgAssignment = "organizationAssignment", ResetPassword = "passwordReset", SetupPassword = "passwordSetup", SecretLeakIncident = "secretLeakIncident", @@ -94,6 +96,7 @@ export enum SmtpHost { // eslint-disable-next-line @typescript-eslint/no-explicit-any const EmailTemplateMap: Record> = { [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, + [SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 03091a255..b43e4e0a2 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,6 +1,13 @@ import { CronJob } from "cron"; -import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { + IdentityAuthMethod, + OrgMembershipRole, + OrgMembershipStatus, + TSuperAdmin, + TSuperAdminUpdate, + TUsers +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { @@ -13,7 +20,12 @@ import { import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { isDisposableEmail } from "@app/lib/validator"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; @@ -43,7 +55,9 @@ import { TAdminGetUsersDTO, TAdminIntegrationConfig, TAdminSignUpDTO, - TGetOrganizationsDTO + TCreateOrganizationDTO, + TGetOrganizationsDTO, + TResendOrgInviteDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { @@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = { authService: Pick; kmsService: Pick; kmsRootConfigDAL: TKmsRootConfigDALFactory; - orgService: Pick; + orgService: Pick; keyStore: Pick; - licenseService: Pick; + licenseService: Pick; microsoftTeamsService: Pick; invalidateCacheQueue: TInvalidateCacheQueueFactory; + smtpService: Pick; + tokenService: TAuthTokenServiceFactory; }; export type TSuperAdminServiceFactory = ReturnType; @@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({ identityTokenAuthDAL, identityOrgMembershipDAL, microsoftTeamsService, - invalidateCacheQueue + invalidateCacheQueue, + smtpService, + tokenService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -732,6 +750,159 @@ export const superAdminServiceFactory = ({ return organizations; }; + const createOrganization = async ( + { name, inviteAdminEmails: emails }: TCreateOrganizationDTO, + actor: OrgServiceActor + ) => { + const appCfg = getConfig(); + + const inviteAdminEmails = [...new Set(emails)]; + + if (!appCfg.isDevelopmentMode && appCfg.isCloud) + throw new BadRequestError({ message: "This endpoint is not supported for cloud instances" }); + + const serverAdmin = await userDAL.findById(actor.id); + const plan = licenseService.onPremFeatures; + + const isEmailInvalid = await isDisposableEmail(inviteAdminEmails); + if (isEmailInvalid) { + throw new BadRequestError({ + message: "Disposable emails are not allowed", + name: "InviteUser" + }); + } + + const { organization, users: usersToEmail } = await orgDAL.transaction(async (tx) => { + const org = await orgService.createOrganization( + { + orgName: name, + userEmail: serverAdmin?.email ?? serverAdmin?.username // identities can be server admins so we can't require this + }, + tx + ); + + const users: Pick[] = []; + + for await (const inviteeEmail of inviteAdminEmails) { + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; + + // if the user doesn't exist we create the user with the email + if (!inviteeUser) { + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } + } + + const inviteeUserId = inviteeUser?.id; + const existingEncryptionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); + + // when user is missing the encrytion keys + // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp + // So what we do is we generate a random secure password and then encrypt it with a random pub-private key + // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password + if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncryptionKey)) { + await userDAL.createUserEncryption( + { + userId: inviteeUserId, + encryptionVersion: 2 + }, + tx + ); + } + + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + name: "InviteUser", + message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." + }); + } + + await orgDAL.createMembership( + { + userId: inviteeUser.id, + inviteEmail: inviteeEmail, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, + isActive: true + }, + tx + ); + + users.push(inviteeUser); + } + + return { organization: org, users }; + }); + + await licenseService.updateSubscriptionOrgMemberCount(organization.id); + + await Promise.allSettled( + usersToEmail.map(async (user) => { + if (!user.email) return; + + if (user.isAccepted) { + return smtpService.sendMail({ + template: SmtpTemplates.OrgAssignment, + subjectLine: "You've been added to an Infisical organization", + recipients: [user.email], + substitutions: { + inviterFirstName: serverAdmin?.firstName, + inviterUsername: serverAdmin?.email, + organizationName: organization.name, + email: user.email, + organizationId: organization.id, + callback_url: `${appCfg.SITE_URL}/login?org_id=${organization.id}` + } + }); + } + + // new user, send regular invite + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: user.id, + orgId: organization.id + }); + + return smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [user.email], + substitutions: { + inviterFirstName: serverAdmin?.firstName, + inviterUsername: serverAdmin?.email, + organizationName: organization.name, + email: user.email, + organizationId: organization.id, + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + }) + ); + + return organization; + }; + const deleteOrganization = async (organizationId: string) => { const organization = await orgDAL.deleteById(organizationId); return organization; @@ -763,6 +934,86 @@ export const superAdminServiceFactory = ({ return organizationMembership; }; + const joinOrganization = async (orgId: string, actor: OrgServiceActor) => { + const serverAdmin = await userDAL.findById(actor.id); + + if (!serverAdmin) { + throw new NotFoundError({ message: "Could not find server admin user" }); + } + + const org = await orgDAL.findById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` }); + } + + const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId }); + + if (existingOrgMembership) { + throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` }); + } + + const orgMembership = await orgDAL.createMembership({ + userId: serverAdmin.id, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted, + isActive: true + }); + + return orgMembership; + }; + + const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => { + const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId }); + + if (!orgMembership) { + throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); + } + + if (orgMembership.status === OrgMembershipStatus.Accepted) { + throw new BadRequestError({ + message: "This user has already accepted their invitation." + }); + } + + if (!orgMembership.userId) { + throw new NotFoundError({ message: "Cannot find user associated with Org Membership." }); + } + + if (!orgMembership.inviteEmail) { + throw new BadRequestError({ message: "No invite email associated with user." }); + } + + const org = await orgDAL.findOrgById(orgMembership.orgId); + + const appCfg = getConfig(); + const serverAdmin = await userDAL.findById(actor.id); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: orgMembership.userId, + orgId: orgMembership.orgId + }); + + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [orgMembership.inviteEmail], + substitutions: { + inviterFirstName: serverAdmin?.firstName, + inviterUsername: serverAdmin?.email, + organizationName: org?.name, + email: orgMembership.inviteEmail, + organizationId: orgMembership.orgId, + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + + return orgMembership; + }; + const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const identities = await identityDAL.getIdentitiesByFilter({ limit, @@ -901,6 +1152,9 @@ export const superAdminServiceFactory = ({ initializeEnvConfigSync, getEnvOverrides, getEnvOverridesOrganized, - deleteUsers + deleteUsers, + createOrganization, + joinOrganization, + resendOrgInvite }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 919b0541c..6cfdd384d 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = { searchTerm: string; }; +export type TCreateOrganizationDTO = { + name: string; + inviteAdminEmails: string[]; +}; + +export type TResendOrgInviteDTO = { + organizationId: string; + membershipId: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", diff --git a/docs/.eslintrc.js b/docs/.eslintrc.js new file mode 100644 index 000000000..e44343a1c --- /dev/null +++ b/docs/.eslintrc.js @@ -0,0 +1,40 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + ], + parser: '@babel/eslint-parser', + parserOptions: { + ecmaVersion: 2021, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + requireConfigFile: false, + babelOptions: { + presets: ['@babel/preset-react'], + }, + }, + plugins: ['react'], + rules: { + 'react/jsx-uses-react': 'error', + 'react/jsx-uses-vars': 'error', + }, + settings: { + react: { + version: 'detect', + }, + }, + ignorePatterns: [ + 'node_modules/', + 'dist/', + 'build/', + '*.config.js', + ], +}; \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 336828000..9ea476487 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,7 @@ { "group": "App Connections", "pages": [ + "integrations/app-connections", "integrations/app-connections/overview", { "group": "Connections", @@ -184,6 +185,7 @@ { "group": "User Authentication", "pages": [ + "integrations/user-authentication", "documentation/platform/auth-methods/email-password", { "group": "SSO", @@ -243,6 +245,7 @@ { "group": "Machine Identities", "pages": [ + "integrations/machine-authentication", "documentation/platform/identities/alicloud-auth", "documentation/platform/identities/aws-auth", "documentation/platform/identities/azure-auth", @@ -417,6 +420,7 @@ { "group": "Secret Rotation", "pages": [ + "integrations/secret-rotations", "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/aws-iam-user-secret", @@ -432,6 +436,7 @@ { "group": "Dynamic Secrets", "pages": [ + "integrations/dynamic-secrets", "documentation/platform/dynamic-secrets/overview", "documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-iam", @@ -502,6 +507,7 @@ { "group": "Secret Syncs", "pages": [ + "integrations/secret-syncs", "integrations/secret-syncs/overview", { "group": "Syncs", @@ -607,6 +613,7 @@ { "group": "Framework Integrations", "pages": [ + "integrations/framework-integrations", "integrations/frameworks/spring-boot-maven", "integrations/frameworks/react", "integrations/frameworks/vue", @@ -2448,6 +2455,7 @@ "sdks/languages/cpp", "sdks/languages/rust", "sdks/languages/go", + "sdks/languages/php", "sdks/languages/ruby" ] } diff --git a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx index 2ee4dff92..fab5f16b7 100644 --- a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx +++ b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx @@ -6,84 +6,133 @@ description: "Learn how to stream Infisical Audit Logs to external logging provi Audit log streams is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact team@infisical.com to purchase an enterprise license to use it. + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. -Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. - -The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing. - +Infisical Audit Log Streaming enables you to transmit your organization's audit logs to external logging providers for monitoring and analysis. ## Overview - - - ![stream create](/images/platform/audit-log-streams/stream-create.png) - - - ![stream create](/images/platform/audit-log-streams/stream-inputs.png) + + 1. Navigate to **Organization Settings** + 2. Select the **Audit Log Streams** tab + 3. Click **Add Log Stream** - Provide the following values - - The HTTPS endpoint URL of the logging provider that collects the JSON stream. - - - The HTTP headers for the logging provider for identification and authentication. - + ![stream create](/images/platform/audit-log-streams/stream-create.png) + + + If your log provider is included in this list, select it. Otherwise click on **Custom** to input your own Endpoint URL and headers. + + ![select provider](/images/platform/audit-log-streams/select-provider.png) + + + Depending on your chosen provider, you'll be asked to input different credentials. + + For **Custom**, you need to input an endpoint URL and headers. + + ![custom provider](/images/platform/audit-log-streams/custom-provider.png) + + Once you're finished, click **Create Log Stream**. + + + Your audit logs are now ready to be streamed. + + ![stream list](/images/platform/audit-log-streams/stream-list.png) -![stream listt](/images/platform/audit-log-streams/stream-list.png) -Your Audit Logs are now ready to be streamed. - ## Example Providers -### Better Stack + + + You can stream to Better Stack using a **Custom** log stream. - - - ![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png) - - - - ![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png) + + + On Better Stack, select **Connect Source** and click **Create source** after providing a name. - 1. Copy the **endpoint** from Better Stack to the **Endpoint URL** field. - 3. Create a new header with key **Authorization** and set the value as **Bearer \**. - - + ![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png) -### Datadog + Once your source is created, take note of the **endpoint** and **Source token** for the next step. - - - ![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png) - - - ![api key form](/images/platform/audit-log-streams/data-create-api-key.png) - ![api key form](/images/platform/audit-log-streams/data-dog-api-key.png) - - - ![datadog url](/images/platform/audit-log-streams/datadog-logging-endpoint.png) + ![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png) + + + On Infisical, create a new audit log stream and select the **Custom** option. - 1. Navigate to the [Datadog Send Logs API documentation](https://docs.datadoghq.com/api/latest/logs/?code-lang=curl&site=us5#send-logs). - 2. Pick your Datadog account region. - 3. Obtain your Datadog logging endpoint URL. - - - ![datadog api key details](/images/platform/audit-log-streams/datadog-source-details.png) + ![select custom](/images/platform/audit-log-streams/select-custom.png) - 1. Copy the **logging endpoint** from Datadog to the **Endpoint URL** field. - 2. Copy the **API Key** from previous step - 3. Create a new header with key **DD-API-KEY** and set the value as **API Key**. - - + 1. Fill in the endpoint URL with your Better Stack source endpoint + 2. Create a new header with key `Authorization` and set the value as `Bearer ` -## Audit Log Stream Data + ![custom provider](/images/platform/audit-log-streams/custom-provider.png) -Each log entry sent to the external logging provider will follow the same structure. + Once you're finished, click **Create Log Stream**. + + + + + You can stream to Datadog using the **Datadog** provider log stream. + + + + ![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png) + + + ![api key form](/images/platform/audit-log-streams/data-create-api-key.png) + ![api key form](/images/platform/audit-log-streams/data-dog-api-key.png) + + + On Infisical, create a new audit log stream and select the **Datadog** provider option. + + Input your **Datadog Region** and the **Token** obtained from step 2. + + ![datadog details](/images/platform/audit-log-streams/datadog-details.png) + + Once you're finished, click **Create Log Stream**. + + + + + You can stream to Splunk using the **Splunk** provider log stream. + + + + Navigate to **Settings** > **Data Inputs**. + + ![splunk data inputs](/images/platform/audit-log-streams/splunk-data-inputs.png) + + Click on **HTTP Event Collector**. + + ![splunk http collector](/images/platform/audit-log-streams/splunk-http-collector.png) + + Click on **New Token** in the top left. + + ![splunk new token](/images/platform/audit-log-streams/splunk-new-token.png) + + Provide a name and click **Next**. + + ![splunk name](/images/platform/audit-log-streams/splunk-name.png) + + On the next page, click **Review** and then **Submit** at the top. On the final page you'll see your token. + + Copy the **Token Value** and your Splunk hostname from the URL to be used for later. + + ![splunk credentials](/images/platform/audit-log-streams/splunk-credentials.png) + + + On Infisical, create a new audit log stream and select the **Splunk** provider option. + + Input your **Splunk Hostname** and the **Token** obtained from step 1. + + ![splunk details](/images/platform/audit-log-streams/splunk-details.png) + + Once you're finished, click **Create Log Stream**. + + + + ### Example Log Entry @@ -117,106 +166,109 @@ Each log entry sent to the external logging provider will follow the same struct ``` ### Audit Logs Structure + + + Streamed audit log structure **varies based on provider**, but they all share the audit log fields shown below. + + - The unique identifier for the log entry. + The unique identifier for the log entry. - The entity responsible for performing or causing the event; this can be a user or service. + The entity responsible for performing or causing the event; this can be a user or service. - The metadata associated with the actor. This varies based on the actor type. + The metadata associated with the actor. This varies based on the actor type. - - This metadata is present when the `actor` field is set to `user`. + + + This metadata is present when the `actor` field is set to `user`. - - The unique identifier for the actor. - - - The email address of the actor. - - - The username of the actor. - - + + The unique identifier for the actor. + + + The email address of the actor. + + + The username of the actor. + + + + This metadata is present when the `actor` field is set to `identity`. - - This metadata is present when the `actor` field is set to `identity`. + + The unique identifier for the identity. + + + The name of the identity. + + + + This metadata is present when the `actor` field is set to `service`. - - The unique identifier for the identity. - - - The name of the identity. - - - - - This metadata is present when the `actor` field is set to `service`. - - - The unique identifier for the service. - - - The name of the service. - - - - - - If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. - + + The unique identifier for the service. + + + The name of the service. + + + + + If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. + - The IP address of the actor. + The IP address of the actor. - The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. + The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. - `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. + `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. - The metadata associated with the event. This varies based on the event type. + The metadata associated with the event. This varies based on the event type. - The user agent of the actor, if applicable. + The user agent of the actor, if applicable. - The type of user agent. + The type of user agent. - The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. + The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. - The creation date of the log entry. + The creation date of the log entry. - The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. + The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. - The unique identifier for the organization where the event occurred. + The unique identifier for the organization where the event occurred. - The unique identifier for the project where the event occurred. + The unique identifier for the project where the event occurred. - The `projectId` field will only be present if the event occurred at the project level, not the organization level. + The `projectId` field will only be present if the event occurred at the project level, not the organization level. - The name of the project where the event occurred. + The name of the project where the event occurred. - The `projectName` field will only be present if the event occurred at the project level, not the organization level. + The `projectName` field will only be present if the event occurred at the project level, not the organization level. diff --git a/docs/documentation/platform/secret-versioning.mdx b/docs/documentation/platform/secret-versioning.mdx index 6a0efb8b8..7ff8694f0 100644 --- a/docs/documentation/platform/secret-versioning.mdx +++ b/docs/documentation/platform/secret-versioning.mdx @@ -8,6 +8,7 @@ Every time a secret change is performed, a new version of the same secret is cre Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read) by specifying the `version` query parameter. +![secret versioning overview](../../images/platform/secret-versioning-overview.png) ![secret versioning](../../images/platform/secret-versioning.png) The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical. diff --git a/docs/images/platform/audit-log-streams/custom-provider.png b/docs/images/platform/audit-log-streams/custom-provider.png new file mode 100644 index 000000000..e860e2e90 Binary files /dev/null and b/docs/images/platform/audit-log-streams/custom-provider.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-details.png b/docs/images/platform/audit-log-streams/datadog-details.png new file mode 100644 index 000000000..29b1464be Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-details.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png deleted file mode 100644 index 7960b1145..000000000 Binary files a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/datadog-source-details.png b/docs/images/platform/audit-log-streams/datadog-source-details.png deleted file mode 100644 index 5ae25b0b3..000000000 Binary files a/docs/images/platform/audit-log-streams/datadog-source-details.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/select-custom.png b/docs/images/platform/audit-log-streams/select-custom.png new file mode 100644 index 000000000..7a55b24a7 Binary files /dev/null and b/docs/images/platform/audit-log-streams/select-custom.png differ diff --git a/docs/images/platform/audit-log-streams/select-provider.png b/docs/images/platform/audit-log-streams/select-provider.png new file mode 100644 index 000000000..c289fd3af Binary files /dev/null and b/docs/images/platform/audit-log-streams/select-provider.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-credentials.png b/docs/images/platform/audit-log-streams/splunk-credentials.png new file mode 100644 index 000000000..8b8624700 Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-credentials.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-data-inputs.png b/docs/images/platform/audit-log-streams/splunk-data-inputs.png new file mode 100644 index 000000000..3446f89f3 Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-data-inputs.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-details.png b/docs/images/platform/audit-log-streams/splunk-details.png new file mode 100644 index 000000000..b7ece41fc Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-details.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-http-collector.png b/docs/images/platform/audit-log-streams/splunk-http-collector.png new file mode 100644 index 000000000..d8090095c Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-http-collector.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-name.png b/docs/images/platform/audit-log-streams/splunk-name.png new file mode 100644 index 000000000..2c382539e Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-name.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-new-token.png b/docs/images/platform/audit-log-streams/splunk-new-token.png new file mode 100644 index 000000000..d4c53569d Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-new-token.png differ diff --git a/docs/images/platform/audit-log-streams/stream-create.png b/docs/images/platform/audit-log-streams/stream-create.png index 949278e3d..1244fa558 100644 Binary files a/docs/images/platform/audit-log-streams/stream-create.png and b/docs/images/platform/audit-log-streams/stream-create.png differ diff --git a/docs/images/platform/audit-log-streams/stream-inputs.png b/docs/images/platform/audit-log-streams/stream-inputs.png deleted file mode 100644 index 6b9d7c57b..000000000 Binary files a/docs/images/platform/audit-log-streams/stream-inputs.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/stream-list.png b/docs/images/platform/audit-log-streams/stream-list.png index c5cc5598b..ad355c3b0 100644 Binary files a/docs/images/platform/audit-log-streams/stream-list.png and b/docs/images/platform/audit-log-streams/stream-list.png differ diff --git a/docs/images/platform/secret-versioning-overview.png b/docs/images/platform/secret-versioning-overview.png new file mode 100644 index 000000000..94a50bd49 Binary files /dev/null and b/docs/images/platform/secret-versioning-overview.png differ diff --git a/docs/images/platform/secret-versioning.png b/docs/images/platform/secret-versioning.png index 593e8c96f..60b57cad9 100644 Binary files a/docs/images/platform/secret-versioning.png and b/docs/images/platform/secret-versioning.png differ diff --git a/docs/images/sdks/languages/php.svg b/docs/images/sdks/languages/php.svg new file mode 100644 index 000000000..37a5e6fe7 --- /dev/null +++ b/docs/images/sdks/languages/php.svg @@ -0,0 +1,96 @@ + + + Official PHP Logo + + + + image/svg+xml + + Official PHP Logo + + + Colin Viebrock + + + + + + + + + + + + Copyright Colin Viebrock 1997 - All rights reserved. + + + 1997 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/integrations/app-connections.mdx b/docs/integrations/app-connections.mdx new file mode 100644 index 000000000..9f4b5b80f --- /dev/null +++ b/docs/integrations/app-connections.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available app connections for Infisical." +--- + +import { AppConnectionsBrowser } from "/snippets/AppConnectionsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 92e6ab9d2..e698c4302 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -79,4 +79,4 @@ in the UI or by passing the associated `connectionId` when generating resources ## Platform Managed Credentials Some App Connections support the ability to have their credentials managed by Infisical. By enabling this option, -Infisical will modify the credentials to prevent external use of the configured access entity. \ No newline at end of file +Infisical will modify the credentials to prevent external use of the configured access entity. diff --git a/docs/integrations/dynamic-secrets.mdx b/docs/integrations/dynamic-secrets.mdx new file mode 100644 index 000000000..84ff43ab5 --- /dev/null +++ b/docs/integrations/dynamic-secrets.mdx @@ -0,0 +1,9 @@ +--- +title: "Dynamic Secrets" +sidebarTitle: "Explore Options" +description: "Browse and search through all available dynamic secrets for Infisical." +--- + +import { DynamicSecretsBrowser } from "/snippets/DynamicSecretsBrowser.jsx"; + + diff --git a/docs/integrations/framework-integrations.mdx b/docs/integrations/framework-integrations.mdx new file mode 100644 index 000000000..dfe78119a --- /dev/null +++ b/docs/integrations/framework-integrations.mdx @@ -0,0 +1,9 @@ +--- +title: "Framework Integrations" +sidebarTitle: "Explore Options" +description: "Browse and search through all available framework integrations for Infisical." +--- + +import { FrameworkIntegrationsBrowser } from "/snippets/FrameworkIntegrationsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/machine-authentication.mdx b/docs/integrations/machine-authentication.mdx new file mode 100644 index 000000000..da4ba1042 --- /dev/null +++ b/docs/integrations/machine-authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "Machine Authentication" +sidebarTitle: "Explore Options" +description: "Browse and search through all available machine authentication methods for Infisical." +--- + +import { MachineAuthenticationBrowser } from "/snippets/MachineAuthenticationBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/secret-rotations.mdx b/docs/integrations/secret-rotations.mdx new file mode 100644 index 000000000..00d1cbb6a --- /dev/null +++ b/docs/integrations/secret-rotations.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available secret rotations for Infisical." +--- + +import { RotationsBrowser } from "/snippets/RotationsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs.mdx b/docs/integrations/secret-syncs.mdx new file mode 100644 index 000000000..6d71517f1 --- /dev/null +++ b/docs/integrations/secret-syncs.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available secret syncs for Infisical." +--- + +import { SecretSyncsBrowser } from "/snippets/SecretSyncsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/user-authentication.mdx b/docs/integrations/user-authentication.mdx new file mode 100644 index 000000000..2b33fd7ba --- /dev/null +++ b/docs/integrations/user-authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "User Authentication" +sidebarTitle: "Explore Options" +description: "Browse and search through all available user authentication methods for Infisical." +--- + +import { UserAuthenticationBrowser } from "/snippets/UserAuthenticationBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/sdks/languages/php.mdx b/docs/sdks/languages/php.mdx new file mode 100644 index 000000000..e3db5e5cd --- /dev/null +++ b/docs/sdks/languages/php.mdx @@ -0,0 +1,204 @@ +--- +title: "Infisical PHP SDK" +sidebarTitle: "PHP" +icon: "/images/sdks/languages/php.svg" +--- + +If you're working with PHP, the official Infisical PHP SDK package is the easiest way to fetch and work with secrets for your application. + +## Installation + +```bash +composer require infisical/php-sdk +``` + +## Getting Started + +```php +auth()->universalAuth()->login( + "your-machine-identity-client-id", + "your-machine-identity-client-secret" +); + +// List secrets +$params = new \Infisical\SDK\Models\ListSecretsParameters( + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$secrets = $sdk->secrets()->list($params); +echo "Fetched secrets: " . count($secrets) . "\n"; +``` + +## Core Methods + +The SDK methods are organized into the following high-level categories: + +1. `auth`: Handles authentication methods. +2. `secrets`: Manages CRUD operations for secrets. + +### `Auth` + +The `auth` component provides methods for authentication: + +#### Universal Auth + +**Authenticating** +```php +$response = $sdk->auth()->universal_auth()->login( + "your-machine-identity-client-id", + "your-machine-identity-client-secret" +); +``` + +**Parameters:** +- `clientId` (string): The client ID of your Machine Identity. +- `clientSecret` (string): The client secret of your Machine Identity. + + + We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/overview). Setting them as environment variables would be best. + + +### `Secrets` + +This sub-class handles operations related to secrets: + +#### List Secrets + +```php +use Infisical\SDK\Models\ListSecretsParameters; + +$params = new ListSecretsParameters( + environment: "dev", + secretPath: "/", + projectId: "your-project-id", + tagSlugs: ["tag1", "tag2"], // Optional + recursive: true, // Optional + expandSecretReferences: true, // Optional + attachToProcessEnv: false, // Optional + skipUniqueValidation: false // Optional +); + +$secrets = $sdk->secrets()->list($params); +``` + +**Parameters:** +- `environment` (string): The environment in which to list secrets (e.g., "dev"). +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secrets. +- `tagSlugs` (array, optional): Tags to filter secrets. +- `recursive` (bool, optional): Whether to list secrets recursively. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `attachToProcessEnv` (bool, optional): Whether to attach secrets to process environment variables. +- `skipUniqueValidation` (bool, optional): Whether to skip unique validation. + +**Returns:** +- `Secret[]`: An array of secret objects. + +#### Create Secret + +```php +use Infisical\SDK\Models\CreateSecretParameters; + +$params = new CreateSecretParameters( + secretKey: "SECRET_NAME", + secretValue: "SECRET_VALUE", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$createdSecret = $sdk->secrets()->create($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to create. +- `secretValue` (string): The value of the secret. +- `environment` (string): The environment in which to create the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The created secret object. + +#### Get Secret + +```php +use Infisical\SDK\Models\GetSecretParameters; + +$params = new GetSecretParameters( + secretKey: "SECRET_NAME", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$secret = $sdk->secrets()->get($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to retrieve. +- `environment` (string): The environment in which to retrieve the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The retrieved secret object. + +#### Update Secret + +```php +use Infisical\SDK\Models\UpdateSecretParameters; + +$params = new UpdateSecretParameters( + secretKey: "SECRET_NAME", + newSecretValue: "UPDATED_SECRET_VALUE", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$updatedSecret = $sdk->secrets()->update($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to update. +- `newSecretValue` (string): The new value of the secret. +- `environment` (string): The environment in which to update the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The updated secret object. + +#### Delete Secret + +```php +use Infisical\SDK\Models\DeleteSecretParameters; + +$params = new DeleteSecretParameters( + secretKey: "SECRET_NAME", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$deletedSecret = $sdk->secrets()->delete($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to delete. +- `environment` (string): The environment in which to delete the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The deleted secret object. \ No newline at end of file diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 0b6949108..f9a8fcac3 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -32,6 +32,9 @@ From local development to production, Infisical SDKs provide the easiest way for Manage secrets for your Go application on demand + + + Manage secrets for your PHP application on demand Manage secrets for your Ruby application on demand diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 53adf95a4..8d351d7f2 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -142,7 +142,7 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup. - + Redis connection string. @@ -173,6 +173,49 @@ Redis is used for caching and background tasks. You can use either a standalone Authentication password for Redis Sentinel + + Authentication username for Redis Node + + + Authentication password for Redis Node + + + + + Comma-separated list of Redis Cluster host:port pairs. ``` + 192.168.65.254:26379,192.168.65.254:26380 ``` + + + Enable Redis TLS encryption on connection. + + + Enable this if you are using AWS encrypt on transit for Elasticache cluster. For more information refer ![here](https://github.com/redis/ioredis?tab=readme-ov-file#special-note-aws-elasticache-clusters-with-tls). + + + Authentication username for Redis Node + + + Authentication password for Redis Node + + + + + Comma-separated list of Redis read replicas host:port pairs. ``` + 192.168.65.254:26379,192.168.65.254:26380 ``` + + + The paramters like username, password, tls, redis type of the primary instance will be inherited. + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx new file mode 100644 index 000000000..72ad40cff --- /dev/null +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -0,0 +1,155 @@ +import React, { useState, useMemo } from 'react'; + +export const AppConnectionsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security']; + + const connections = [ + {"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Security"}, + {"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Security"}, + {"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"}, + {"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Data Analytics"}, + {"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"}, + {"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, + {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, + {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredConnections = useMemo(() => { + let filtered = connections; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(connection => connection.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(connection => + connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.description.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredConnections.length} app connection{filteredConnections.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Connections List */} + {filteredConnections.length > 0 ? ( + + ) : ( +
+
+

No app connections found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/DynamicSecretsBrowser.jsx b/docs/snippets/DynamicSecretsBrowser.jsx new file mode 100644 index 000000000..6438deea0 --- /dev/null +++ b/docs/snippets/DynamicSecretsBrowser.jsx @@ -0,0 +1,140 @@ +import React, { useState, useMemo } from 'react'; + +export const DynamicSecretsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Databases', 'Cloud Providers', 'Message Queues', 'Caches', 'Directory Services', 'CI/CD', 'Container Orchestration', 'Authentication']; + + const dynamicSecrets = [ + {"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"}, + {"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"}, + {"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"}, + {"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"}, + {"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"}, + {"name": "MongoDB", "slug": "mongodb", "path": "/documentation/platform/dynamic-secrets/mongo-db", "description": "Learn how to generate dynamic MongoDB database credentials on-demand.", "category": "Databases"}, + {"name": "MongoDB Atlas", "slug": "mongodb-atlas", "path": "/documentation/platform/dynamic-secrets/mongo-atlas", "description": "Learn how to generate dynamic MongoDB Atlas database credentials on-demand.", "category": "Databases"}, + {"name": "MySQL", "slug": "mysql", "path": "/documentation/platform/dynamic-secrets/mysql", "description": "Learn how to generate dynamic MySQL database credentials on-demand.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgresql", "path": "/documentation/platform/dynamic-secrets/postgresql", "description": "Learn how to generate dynamic PostgreSQL database credentials on-demand.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/documentation/platform/dynamic-secrets/mssql", "description": "Learn how to generate dynamic Microsoft SQL Server credentials on-demand.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracle", "path": "/documentation/platform/dynamic-secrets/oracle", "description": "Learn how to generate dynamic Oracle Database credentials on-demand.", "category": "Databases"}, + {"name": "SAP ASE", "slug": "sap-ase", "path": "/documentation/platform/dynamic-secrets/sap-ase", "description": "Learn how to generate dynamic SAP ASE database credentials on-demand.", "category": "Databases"}, + {"name": "SAP HANA", "slug": "sap-hana", "path": "/documentation/platform/dynamic-secrets/sap-hana", "description": "Learn how to generate dynamic SAP HANA database credentials on-demand.", "category": "Databases"}, + {"name": "Snowflake", "slug": "snowflake", "path": "/documentation/platform/dynamic-secrets/snowflake", "description": "Learn how to generate dynamic Snowflake database credentials on-demand.", "category": "Databases"}, + {"name": "Vertica", "slug": "vertica", "path": "/documentation/platform/dynamic-secrets/vertica", "description": "Learn how to generate dynamic Vertica database credentials on-demand.", "category": "Databases"}, + {"name": "Redis", "slug": "redis", "path": "/documentation/platform/dynamic-secrets/redis", "description": "Learn how to generate dynamic Redis credentials on-demand.", "category": "Caches"}, + {"name": "ElasticSearch", "slug": "elasticsearch", "path": "/documentation/platform/dynamic-secrets/elastic-search", "description": "Learn how to generate dynamic ElasticSearch credentials on-demand.", "category": "Databases"}, + {"name": "RabbitMQ", "slug": "rabbitmq", "path": "/documentation/platform/dynamic-secrets/rabbit-mq", "description": "Learn how to generate dynamic RabbitMQ credentials on-demand.", "category": "Message Queues"}, + {"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/dynamic-secrets/ldap", "description": "Learn how to generate dynamic LDAP credentials on-demand.", "category": "Directory Services"}, + {"name": "GitHub", "slug": "github", "path": "/documentation/platform/dynamic-secrets/github", "description": "Learn how to generate dynamic GitHub credentials on-demand.", "category": "CI/CD"}, + {"name": "Kubernetes", "slug": "kubernetes", "path": "/documentation/platform/dynamic-secrets/kubernetes", "description": "Learn how to generate dynamic Kubernetes credentials on-demand.", "category": "Container Orchestration"}, + {"name": "TOTP", "slug": "totp", "path": "/documentation/platform/dynamic-secrets/totp", "description": "Learn how to generate dynamic TOTP codes on-demand.", "category": "Authentication"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredDynamicSecrets = useMemo(() => { + let filtered = dynamicSecrets; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(secret => secret.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(secret => + secret.name.toLowerCase().includes(searchTerm.toLowerCase()) || + secret.description.toLowerCase().includes(searchTerm.toLowerCase()) || + secret.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredDynamicSecrets.length} dynamic secret{filteredDynamicSecrets.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Dynamic Secrets List */} + {filteredDynamicSecrets.length > 0 ? ( +
+ {filteredDynamicSecrets.map((secret, index) => ( + +
+
+

+ {secret.name} +

+ + {secret.category} + +
+

+ {secret.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No dynamic secrets found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/FrameworkIntegrationsBrowser.jsx b/docs/snippets/FrameworkIntegrationsBrowser.jsx new file mode 100644 index 000000000..f85da41ed --- /dev/null +++ b/docs/snippets/FrameworkIntegrationsBrowser.jsx @@ -0,0 +1,101 @@ +import React, { useState, useMemo } from 'react'; + +export const FrameworkIntegrationsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + + const integrations = [ + {"name": "React", "slug": "react", "path": "/integrations/frameworks/react", "description": "Learn how to integrate Infisical with React applications for secure secret management.", "category": "Web Frameworks"}, + {"name": "Next.js", "slug": "nextjs", "path": "/integrations/frameworks/nextjs", "description": "Learn how to integrate Infisical with Next.js applications.", "category": "Web Frameworks"}, + {"name": "Vue", "slug": "vuejs", "path": "/integrations/frameworks/vue", "description": "Learn how to integrate Infisical with Vue.js applications.", "category": "Web Frameworks"}, + {"name": "Nuxt", "slug": "nuxtjs", "path": "/integrations/frameworks/nuxt", "description": "Learn how to integrate Infisical with Nuxt.js applications.", "category": "Web Frameworks"}, + {"name": "SvelteKit", "slug": "sveltekit", "path": "/integrations/frameworks/sveltekit", "description": "Learn how to integrate Infisical with SvelteKit applications.", "category": "Web Frameworks"}, + {"name": "Express, Fastify, Koa", "slug": "express", "path": "/integrations/frameworks/express", "description": "Learn how to integrate Infisical with Express.js backend applications.", "category": "Web Frameworks"}, + {"name": "NestJS", "slug": "nestjs", "path": "/integrations/frameworks/nestjs", "description": "Learn how to integrate Infisical with NestJS applications.", "category": "Web Frameworks"}, + {"name": "Django", "slug": "django", "path": "/integrations/frameworks/django", "description": "Learn how to integrate Infisical with Django applications.", "category": "Web Frameworks"}, + {"name": "Flask", "slug": "flask", "path": "/integrations/frameworks/flask", "description": "Learn how to integrate Infisical with Flask applications.", "category": "Web Frameworks"}, + {"name": "Ruby on Rails", "slug": "rails", "path": "/integrations/frameworks/rails", "description": "Learn how to integrate Infisical with Ruby on Rails applications.", "category": "Web Frameworks"}, + {"name": "Spring Boot", "slug": "spring-boot-maven", "path": "/integrations/frameworks/spring-boot-maven", "description": "Learn how to integrate Infisical with Spring Boot applications.", "category": "Web Frameworks"}, + {"name": "Laravel", "slug": "laravel", "path": "/integrations/frameworks/laravel", "description": "Learn how to integrate Infisical with Laravel applications.", "category": "Web Frameworks"}, + {"name": ".NET", "slug": "dotnet", "path": "/integrations/frameworks/dotnet", "description": "Learn how to integrate Infisical with .NET applications.", "category": "Web Frameworks"}, + {"name": "Fiber", "slug": "fiber", "path": "/integrations/frameworks/fiber", "description": "Learn how to integrate Infisical with Fiber (Go) framework.", "category": "Web Frameworks"}, + {"name": "Gatsby", "slug": "gatsby", "path": "/integrations/frameworks/gatsby", "description": "Learn how to integrate Infisical with Gatsby applications.", "category": "Web Frameworks"}, + {"name": "Remix", "slug": "remix", "path": "/integrations/frameworks/remix", "description": "Learn how to integrate Infisical with Remix applications.", "category": "Web Frameworks"}, + {"name": "Vite", "slug": "vite", "path": "/integrations/frameworks/vite", "description": "Learn how to integrate Infisical with Vite applications.", "category": "Web Frameworks"}, + {"name": "AB Initio", "slug": "ab-initio", "path": "/integrations/frameworks/ab-initio", "description": "Learn how to integrate Infisical with AB Initio applications.", "category": "Web Frameworks"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredIntegrations = useMemo(() => { + if (searchTerm) { + return integrations.filter(integration => + integration.name.toLowerCase().includes(searchTerm.toLowerCase()) || + integration.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + return integrations; + }, [searchTerm]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Results Count */} +
+

+ {filteredIntegrations.length} framework integration{filteredIntegrations.length !== 1 ? 's' : ''} found + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Integrations List */} + {filteredIntegrations.length > 0 ? ( +
+ {filteredIntegrations.map((integration, index) => ( + +
+
+

+ {integration.name} +

+
+

+ {integration.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No framework integrations found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/MachineAuthenticationBrowser.jsx b/docs/snippets/MachineAuthenticationBrowser.jsx new file mode 100644 index 000000000..521493846 --- /dev/null +++ b/docs/snippets/MachineAuthenticationBrowser.jsx @@ -0,0 +1,136 @@ +import React, { useState, useMemo } from 'react'; + +export const MachineAuthenticationBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Token-based', 'Cloud Provider', 'Kubernetes', 'Certificate-based', 'Directory-based']; + + const authMethods = [ + {"name": "Universal Auth", "slug": "universal-auth", "path": "/documentation/platform/identities/universal-auth", "description": "Learn how to authenticate machines using Universal Auth tokens with client ID and secret.", "category": "Token-based"}, + {"name": "Token Auth", "slug": "token-auth", "path": "/documentation/platform/identities/token-auth", "description": "Learn how to authenticate machines using long-lived access tokens.", "category": "Token-based"}, + {"name": "JWT Auth", "slug": "jwt-auth", "path": "/documentation/platform/identities/jwt-auth", "description": "Learn how to authenticate machines using JSON Web Tokens (JWT).", "category": "Token-based"}, + {"name": "AWS Auth", "slug": "aws-iam-auth", "path": "/documentation/platform/identities/aws-auth", "description": "Learn how to authenticate AWS services and resources using IAM roles.", "category": "Cloud Provider"}, + {"name": "Azure Auth", "slug": "azure-auth", "path": "/documentation/platform/identities/azure-auth", "description": "Learn how to authenticate Azure services using managed identities.", "category": "Cloud Provider"}, + {"name": "GCP Auth", "slug": "gcp-auth", "path": "/documentation/platform/identities/gcp-auth", "description": "Learn how to authenticate GCP services using service accounts.", "category": "Cloud Provider"}, + {"name": "Alibaba Cloud Auth", "slug": "alicloud-auth", "path": "/documentation/platform/identities/alicloud-auth", "description": "Learn how to authenticate Alibaba Cloud services using RAM roles.", "category": "Cloud Provider"}, + {"name": "OCI Auth", "slug": "oci-auth", "path": "/documentation/platform/identities/oci-auth", "description": "Learn how to authenticate Oracle Cloud Infrastructure services.", "category": "Cloud Provider"}, + {"name": "Kubernetes Auth", "slug": "kubernetes-auth", "path": "/documentation/platform/identities/kubernetes-auth", "description": "Learn how to authenticate Kubernetes workloads using service account tokens.", "category": "Kubernetes"}, + {"name": "OIDC Auth", "slug": "oidc-auth-general", "path": "/documentation/platform/identities/oidc-auth/general", "description": "Learn how to authenticate machines using OpenID Connect (OIDC) providers.", "category": "Token-based"}, + {"name": "OIDC Auth for GitHub Actions", "slug": "oidc-auth-github", "path": "/documentation/platform/identities/oidc-auth/github", "description": "Learn how to authenticate GitHub Actions using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for GitLab CI/CD", "slug": "oidc-auth-gitlab", "path": "/documentation/platform/identities/oidc-auth/gitlab", "description": "Learn how to authenticate GitLab CI/CD using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for Azure", "slug": "oidc-auth-azure", "path": "/documentation/platform/identities/oidc-auth/azure", "description": "Learn how to authenticate Azure services using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for CircleCI", "slug": "oidc-auth-circleci", "path": "/documentation/platform/identities/oidc-auth/circleci", "description": "Learn how to authenticate CircleCI workflows using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for Terraform Cloud", "slug": "oidc-auth-terraform", "path": "/documentation/platform/identities/oidc-auth/terraform-cloud", "description": "Learn how to authenticate Terraform Cloud using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for SPIRE", "slug": "oidc-auth-spire", "path": "/documentation/platform/identities/oidc-auth/spire", "description": "Learn how to authenticate workloads using SPIFFE/SPIRE OIDC.", "category": "Token-based"}, + {"name": "TLS Certificate Auth", "slug": "tls-cert-auth", "path": "/documentation/platform/identities/tls-cert-auth", "description": "Learn how to authenticate machines using TLS client certificates.", "category": "Certificate-based"}, + {"name": "LDAP Auth", "slug": "ldap-auth-general", "path": "/documentation/platform/identities/ldap-auth/general", "description": "Learn how to authenticate machines using LDAP credentials.", "category": "Directory-based"}, + {"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredAuthMethods = useMemo(() => { + let filtered = authMethods; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(method => method.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(method => + method.name.toLowerCase().includes(searchTerm.toLowerCase()) || + method.description.toLowerCase().includes(searchTerm.toLowerCase()) || + method.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredAuthMethods.length} authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Authentication Methods List */} + {filteredAuthMethods.length > 0 ? ( +
+ {filteredAuthMethods.map((method, index) => ( + +
+
+

+ {method.name} +

+ + {method.category} + +
+

+ {method.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No authentication methods found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/RotationsBrowser.jsx b/docs/snippets/RotationsBrowser.jsx new file mode 100644 index 000000000..3d50656b6 --- /dev/null +++ b/docs/snippets/RotationsBrowser.jsx @@ -0,0 +1,126 @@ +import React, { useState, useMemo } from 'react'; + +export const RotationsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Databases', 'Identity & Auth', 'Cloud Providers']; + + const rotations = [ + {"name": "AWS IAM User", "slug": "aws-iam-user", "path": "/documentation/platform/secret-rotation/aws-iam-user-secret", "description": "Learn how to automatically rotate AWS IAM user access keys.", "category": "Cloud Providers"}, + {"name": "Azure Client Secret", "slug": "azure-client-secret", "path": "/documentation/platform/secret-rotation/azure-client-secret", "description": "Learn how to automatically rotate Azure client secrets.", "category": "Cloud Providers"}, + {"name": "Auth0 Client Secret", "slug": "auth0-client-secret", "path": "/documentation/platform/secret-rotation/auth0-client-secret", "description": "Learn how to automatically rotate Auth0 client secrets.", "category": "Identity & Auth"}, + {"name": "Okta Client Secret", "slug": "okta-client-secret", "path": "/documentation/platform/secret-rotation/okta-client-secret", "description": "Learn how to automatically rotate Okta client secrets.", "category": "Identity & Auth"}, + {"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"}, + {"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredRotations = useMemo(() => { + let filtered = rotations; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(rotation => rotation.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(rotation => + rotation.name.toLowerCase().includes(searchTerm.toLowerCase()) || + rotation.description.toLowerCase().includes(searchTerm.toLowerCase()) || + rotation.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredRotations.length} secret rotation{filteredRotations.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Rotations List */} + {filteredRotations.length > 0 ? ( + + ) : ( +
+
+

No secret rotations found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx new file mode 100644 index 000000000..d32f5ac04 --- /dev/null +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -0,0 +1,147 @@ +import React, { useState, useMemo } from 'react'; + +export const SecretSyncsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security']; + + const syncs = [ + {"name": "AWS Parameter Store", "slug": "aws-parameter-store", "path": "/integrations/secret-syncs/aws-parameter-store", "description": "Learn how to sync secrets from Infisical to AWS Parameter Store.", "category": "Cloud Providers"}, + {"name": "AWS Secrets Manager", "slug": "aws-secrets-manager", "path": "/integrations/secret-syncs/aws-secrets-manager", "description": "Learn how to sync secrets from Infisical to AWS Secrets Manager.", "category": "Cloud Providers"}, + {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/secret-syncs/azure-key-vault", "description": "Learn how to sync secrets from Infisical to Azure Key Vault.", "category": "Cloud Providers"}, + {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/secret-syncs/azure-app-configuration", "description": "Learn how to sync secrets from Infisical to Azure App Configuration.", "category": "Cloud Providers"}, + {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/secret-syncs/azure-devops", "description": "Learn how to sync secrets from Infisical to Azure DevOps.", "category": "CI/CD"}, + {"name": "GCP Secret Manager", "slug": "gcp-secret-manager", "path": "/integrations/secret-syncs/gcp-secret-manager", "description": "Learn how to sync secrets from Infisical to GCP Secret Manager.", "category": "Cloud Providers"}, + {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/secret-syncs/hashicorp-vault", "description": "Learn how to sync secrets from Infisical to HashiCorp Vault.", "category": "Security"}, + {"name": "1Password", "slug": "1password", "path": "/integrations/secret-syncs/1password", "description": "Learn how to sync secrets from Infisical to 1Password.", "category": "Security"}, + {"name": "Vercel", "slug": "vercel", "path": "/integrations/secret-syncs/vercel", "description": "Learn how to sync secrets from Infisical to Vercel.", "category": "Hosting"}, + {"name": "Netlify", "slug": "netlify", "path": "/integrations/secret-syncs/netlify", "description": "Learn how to sync secrets from Infisical to Netlify.", "category": "Hosting"}, + {"name": "Railway", "slug": "railway", "path": "/integrations/secret-syncs/railway", "description": "Learn how to sync secrets from Infisical to Railway.", "category": "Hosting"}, + {"name": "Fly.io", "slug": "flyio", "path": "/integrations/secret-syncs/flyio", "description": "Learn how to sync secrets from Infisical to Fly.io.", "category": "Hosting"}, + {"name": "Render", "slug": "render", "path": "/integrations/secret-syncs/render", "description": "Learn how to sync secrets from Infisical to Render.", "category": "Hosting"}, + {"name": "Heroku", "slug": "heroku", "path": "/integrations/secret-syncs/heroku", "description": "Learn how to sync secrets from Infisical to Heroku.", "category": "Hosting"}, + {"name": "DigitalOcean App Platform", "slug": "digital-ocean-app-platform", "path": "/integrations/secret-syncs/digital-ocean-app-platform", "description": "Learn how to sync secrets from Infisical to DigitalOcean App Platform.", "category": "Hosting"}, + {"name": "Supabase", "slug": "supabase", "path": "/integrations/secret-syncs/supabase", "description": "Learn how to sync secrets from Infisical to Supabase.", "category": "Databases"}, + {"name": "Checkly", "slug": "checkly", "path": "/integrations/secret-syncs/checkly", "description": "Learn how to sync secrets from Infisical to Checkly.", "category": "Monitoring"}, + {"name": "GitHub", "slug": "github", "path": "/integrations/secret-syncs/github", "description": "Learn how to sync secrets from Infisical to GitHub.", "category": "CI/CD"}, + {"name": "GitLab", "slug": "gitlab", "path": "/integrations/secret-syncs/gitlab", "description": "Learn how to sync secrets from Infisical to GitLab.", "category": "CI/CD"}, + {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/secret-syncs/teamcity", "description": "Learn how to sync secrets from Infisical to TeamCity.", "category": "CI/CD"}, + {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/secret-syncs/bitbucket", "description": "Learn how to sync secrets from Infisical to Bitbucket.", "category": "CI/CD"}, + {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/secret-syncs/terraform-cloud", "description": "Learn how to sync secrets from Infisical to Terraform Cloud.", "category": "DevOps Tools"}, + {"name": "Cloudflare Pages", "slug": "cloudflare-pages", "path": "/integrations/secret-syncs/cloudflare-pages", "description": "Learn how to sync secrets from Infisical to Cloudflare Pages.", "category": "Hosting"}, + {"name": "Cloudflare Workers", "slug": "cloudflare-workers", "path": "/integrations/secret-syncs/cloudflare-workers", "description": "Learn how to sync secrets from Infisical to Cloudflare Workers.", "category": "Cloud Providers"}, + {"name": "Databricks", "slug": "databricks", "path": "/integrations/secret-syncs/databricks", "description": "Learn how to sync secrets from Infisical to Databricks.", "category": "Data Analytics"}, + {"name": "Windmill", "slug": "windmill", "path": "/integrations/secret-syncs/windmill", "description": "Learn how to sync secrets from Infisical to Windmill.", "category": "DevOps Tools"}, + {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"}, + {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, + {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredSyncs = useMemo(() => { + let filtered = syncs; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(sync => sync.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(sync => + sync.name.toLowerCase().includes(searchTerm.toLowerCase()) || + sync.description.toLowerCase().includes(searchTerm.toLowerCase()) || + sync.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredSyncs.length} secret sync{filteredSyncs.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Secret Syncs List */} + {filteredSyncs.length > 0 ? ( + + ) : ( +
+
+

No secret syncs found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/UserAuthenticationBrowser.jsx b/docs/snippets/UserAuthenticationBrowser.jsx new file mode 100644 index 000000000..76b77ad93 --- /dev/null +++ b/docs/snippets/UserAuthenticationBrowser.jsx @@ -0,0 +1,135 @@ +import React, { useState, useMemo } from 'react'; + +export const UserAuthenticationBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'SSO', 'LDAP', 'SCIM', 'General']; + + const authMethods = [ + {"name": "Auth0 OIDC", "slug": "auth0-oidc-sso", "path": "/documentation/platform/sso/auth0-oidc", "description": "Learn how to configure Auth0 OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Auth0 SAML", "slug": "auth0-saml-sso", "path": "/documentation/platform/sso/auth0-saml", "description": "Learn how to configure Auth0 SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Entra ID / Azure AD SAML", "slug": "azure-ad-sso", "path": "/documentation/platform/sso/azure", "description": "Learn how to configure Azure Active Directory (Entra ID) SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Google", "slug": "google-sso", "path": "/documentation/platform/sso/google", "description": "Learn how to configure Google SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Google SAML", "slug": "google-saml-sso", "path": "/documentation/platform/sso/google-saml", "description": "Learn how to configure Google SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "GitHub", "slug": "github-sso", "path": "/documentation/platform/sso/github", "description": "Learn how to configure GitHub SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "GitLab", "slug": "gitlab-sso", "path": "/documentation/platform/sso/gitlab", "description": "Learn how to configure GitLab SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "JumpCloud", "slug": "jumpcloud-sso", "path": "/documentation/platform/sso/jumpcloud", "description": "Learn how to configure JumpCloud SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Keycloak OIDC", "slug": "keycloak-oidc-sso", "path": "/documentation/platform/sso/keycloak-oidc/overview", "description": "Learn how to configure Keycloak OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Keycloak SAML", "slug": "keycloak-saml-sso", "path": "/documentation/platform/sso/keycloak-saml", "description": "Learn how to configure Keycloak SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Okta", "slug": "okta-oidc-sso", "path": "/documentation/platform/sso/okta", "description": "Learn how to configure Okta OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Okta SAML", "slug": "okta-saml-sso", "path": "/documentation/platform/sso/okta-saml", "description": "Learn how to configure Okta SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "OneLogin SAML", "slug": "onelogin-saml-sso", "path": "/documentation/platform/sso/onelogin-saml", "description": "Learn how to configure OneLogin SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "General OIDC", "slug": "general-oidc-sso", "path": "/documentation/platform/sso/general-oidc", "description": "Learn how to configure generic OIDC providers for SSO in Infisical.", "category": "SSO"}, + {"name": "General SAML 2.0", "slug": "general-saml-sso", "path": "/documentation/platform/sso/general-saml", "description": "Learn how to configure generic SAML 2.0 providers for SSO in Infisical.", "category": "SSO"}, + {"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/ldap/overview", "description": "Learn how to configure LDAP authentication for user login in Infisical.", "category": "LDAP"}, + {"name": "SCIM", "slug": "scim", "path": "/documentation/platform/scim/overview", "description": "Learn how to configure SCIM provisioning for automated user management in Infisical.", "category": "SCIM"}, + {"name": "Email/Password", "slug": "email-password", "path": "/documentation/getting-started/introduction", "description": "Learn how to use standard email and password authentication in Infisical.", "category": "General"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredAuthMethods = useMemo(() => { + let filtered = authMethods; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(method => method.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(method => + method.name.toLowerCase().includes(searchTerm.toLowerCase()) || + method.description.toLowerCase().includes(searchTerm.toLowerCase()) || + method.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredAuthMethods.length} user authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Authentication Methods List */} + {filteredAuthMethods.length > 0 ? ( +
+ {filteredAuthMethods.map((method, index) => ( + +
+
+

+ {method.name} +

+ + {method.category} + +
+

+ {method.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No user authentication methods found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/public/images/integrations/Datadog.png b/frontend/public/images/integrations/Datadog.png new file mode 100644 index 000000000..7c1b33de0 Binary files /dev/null and b/frontend/public/images/integrations/Datadog.png differ diff --git a/frontend/public/images/integrations/Splunk.png b/frontend/public/images/integrations/Splunk.png new file mode 100644 index 000000000..54f13cd25 Binary files /dev/null and b/frontend/public/images/integrations/Splunk.png differ diff --git a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx index 53b53d5c0..e0d541138 100644 --- a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx @@ -1,6 +1,7 @@ import { faCheck, faExclamationTriangle, + faHourglass, faRotate, IconDefinition } from "@fortawesome/free-solid-svg-icons"; @@ -29,7 +30,11 @@ export const SecretSyncStatusBadge = ({ status }: Props) => { text = "Synced"; icon = faCheck; break; - case SecretSyncStatus.Pending: // no need to differentiate from user perspective + case SecretSyncStatus.Pending: + variant = "primary"; + text = "Queued"; + icon = faHourglass; + break; case SecretSyncStatus.Running: default: variant = "primary"; @@ -42,11 +47,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => { {text} diff --git a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx index f60668aec..c9a3a1bea 100644 --- a/frontend/src/components/v2/ContentLoader/ContentLoader.tsx +++ b/frontend/src/components/v2/ContentLoader/ContentLoader.tsx @@ -11,9 +11,10 @@ type Props = { text?: string | string[]; frequency?: number; className?: string; + lottieClassName?: string; }; -export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { +export const ContentLoader = ({ text, frequency = 2000, className, lottieClassName }: Props) => { const [pos, setPos] = useState(0); const isTextArray = Array.isArray(text); useEffect(() => { @@ -33,7 +34,11 @@ export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { className )} > - + {text && isTextArray && ( ({ isSelected && "text-mineshaft-200", "px-3 py-2 text-xs hover:cursor-pointer" ), - noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" + noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md", + loadingMessage: () => "text-mineshaft-400 p-2 rounded-md" }} {...props} /> diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 7ab8b0f3c..8d0849a04 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -91,7 +91,7 @@ export const FilterableSelect = ({ ), placeholder: () => `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, - input: () => "pl-1", + input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`, valueContainer: () => `px-1 max-h-[8.2rem] ${ isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]" @@ -114,7 +114,8 @@ export const FilterableSelect = ({ isSelected && "text-mineshaft-200", "rounded px-3 py-2 text-xs hover:cursor-pointer" ), - noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" + noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md", + loadingMessage: () => "text-mineshaft-400 p-2 rounded-md" }} {...props} /> diff --git a/frontend/src/components/v2/Tooltip/Tooltip.tsx b/frontend/src/components/v2/Tooltip/Tooltip.tsx index 12d4397d9..acd0f67f8 100644 --- a/frontend/src/components/v2/Tooltip/Tooltip.tsx +++ b/frontend/src/components/v2/Tooltip/Tooltip.tsx @@ -16,6 +16,7 @@ export type TooltipProps = Omit // just render children if tooltip content is empty content ? ( = { + [LogProvider.Custom]: { name: "Custom", icon: faCode }, + [LogProvider.Datadog]: { name: "Datadog", image: "Datadog.png" }, + [LogProvider.Splunk]: { name: "Splunk", image: "Splunk.png", size: 65 } +}; + +// Strictly for showing to the client in the front-end +export function getProviderUrl( + logStream: DiscriminativePick +) { + switch (logStream.provider) { + case LogProvider.Custom: + return logStream.credentials.url; + case LogProvider.Datadog: + return logStream.credentials.url; + case LogProvider.Splunk: + return `https://${logStream.credentials.hostname}:8088/services/collector/event`; + default: + throw new Error( + `Unhandled provider in getProviderUrl: ${(logStream as TAuditLogStream).provider}` + ); + } +} diff --git a/frontend/src/helpers/download.ts b/frontend/src/helpers/download.ts index 50adf4fe2..d1659a956 100644 --- a/frontend/src/helpers/download.ts +++ b/frontend/src/helpers/download.ts @@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, filename); }; + +export const downloadFile = (content: string, filename: string, mimeType: string = "text/csv") => { + const blob = new Blob([content], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +}; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b196f19e7..918a0ecd5 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { Organization } from "@app/hooks/api/organization/types"; import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; @@ -8,9 +9,12 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { RootKeyEncryptionStrategy, TCreateAdminUserDTO, + TCreateOrganizationDTO, TInvalidateCacheDTO, + TResendOrgInviteDTO, TServerConfig, - TUpdateServerConfigDTO + TUpdateServerConfigDTO, + TUsageReportResponse } from "./types"; export const useCreateAdminUser = () => { @@ -193,3 +197,58 @@ export const useInvalidateCache = () => { } }); }; + +export const useServerAdminCreateOrganization = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (opt: TCreateOrganizationDTO) => { + const { data } = await apiRequest.post<{ organization: Organization }>( + "/api/v1/admin/organization-management/organizations", + opt + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() }); + } + }); +}; + +export const useServerAdminResendOrgInvite = () => { + return useMutation({ + mutationFn: async ({ organizationId, membershipId }: TResendOrgInviteDTO) => { + await apiRequest.post( + `/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}/resend-invite` + ); + } + }); +}; + +export const useServerAdminAccessOrg = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (orgId: string) => { + const { data } = await apiRequest.post( + `/api/v1/admin/organization-management/organizations/${orgId}/access` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations }); + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() }); + } + }); +}; + +export const useGenerateUsageReport = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post( + "/api/v1/admin/usage-report/generate" + ); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 871c7288c..4b10ba3ce 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -1,4 +1,11 @@ -import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { + DefaultError, + InfiniteData, + UndefinedInitialDataInfiniteOptions, + useInfiniteQuery, + useQuery, + UseQueryOptions +} from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { Identity } from "@app/hooks/api/identities/types"; @@ -25,8 +32,8 @@ export const adminStandaloneKeys = { export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, - getOrganizations: (filters: AdminGetOrganizationsFilters) => - [adminStandaloneKeys.getOrganizations, { filters }] as const, + getOrganizations: (filters?: AdminGetOrganizationsFilters) => + [adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const, getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const, @@ -83,7 +90,18 @@ export const useGetServerConfig = ({ enabled: options?.enabled ?? true }); -export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { +export const useAdminGetUsers = ( + filters: AdminGetUsersFilters, + options?: Partial< + UndefinedInitialDataInfiniteOptions< + User[], + DefaultError, + InfiniteData, + ReturnType, + number + > + > +) => { return useInfiniteQuery({ initialPageParam: 0, queryKey: adminQueryKeys.getUsers(filters), @@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { return data.users; }, getNextPageParam: (lastPage, pages) => - lastPage.length !== 0 ? pages.length * filters.limit : undefined + lastPage.length !== 0 ? pages.length * filters.limit : undefined, + ...options }); }; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index aca8b5bb3..f8336fcac 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -1,3 +1,5 @@ +import { OrgMembershipStatus } from "@app/hooks/api/organization/types"; + import { Organization } from "../types"; export enum LoginMethod { @@ -20,6 +22,7 @@ export type OrganizationWithProjects = Organization & { lastName: string | null; }; membershipId: string; + status: OrgMembershipStatus; role: string; roleId: string | null; }[]; @@ -53,6 +56,7 @@ export type TServerConfig = { fipsEnabled: boolean; envOverrides?: Record; paramsFolderSecretDetectionEnabled: boolean; + isOfflineUsageReportsEnabled: boolean; }; export type TUpdateServerConfigDTO = { @@ -142,3 +146,19 @@ export interface TGetEnvOverrides { fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[]; }; } + +export type TUsageReportResponse = { + filename: string; + csvContent: string; + signature: string; +}; + +export type TCreateOrganizationDTO = { + name: string; + inviteAdminEmails: string[]; +}; + +export type TResendOrgInviteDTO = { + organizationId: string; + membershipId: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/enums.ts b/frontend/src/hooks/api/auditLogStreams/enums.ts new file mode 100644 index 000000000..9c03dc2bd --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/enums.ts @@ -0,0 +1,5 @@ +export enum LogProvider { + Datadog = "datadog", + Splunk = "splunk", + Custom = "custom" +} diff --git a/frontend/src/hooks/api/auditLogStreams/index.tsx b/frontend/src/hooks/api/auditLogStreams/index.tsx index 72b1fba1a..0c2adeab0 100644 --- a/frontend/src/hooks/api/auditLogStreams/index.tsx +++ b/frontend/src/hooks/api/auditLogStreams/index.tsx @@ -1,6 +1,2 @@ -export { - useCreateAuditLogStream, - useDeleteAuditLogStream, - useUpdateAuditLogStream -} from "./mutations"; -export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries"; +export * from "./mutations"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/auditLogStreams/mutations.tsx b/frontend/src/hooks/api/auditLogStreams/mutations.tsx index 1ed93f95b..24253ca63 100644 --- a/frontend/src/hooks/api/auditLogStreams/mutations.tsx +++ b/frontend/src/hooks/api/auditLogStreams/mutations.tsx @@ -12,50 +12,54 @@ import { export const useCreateAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TCreateAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ provider, ...params }: TCreateAuditLogStreamDTO) => { const { data } = await apiRequest.post<{ auditLogStream: TAuditLogStream }>( - "/api/v1/audit-log-streams", - dto + `/api/v1/audit-log-streams/${provider}`, + params ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); - } + onSuccess: () => queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }) }); }; export const useUpdateAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TUpdateAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ auditLogStreamId, provider, ...params }: TUpdateAuditLogStreamDTO) => { const { data } = await apiRequest.patch<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${dto.id}`, - dto + `/api/v1/audit-log-streams/${provider}/${auditLogStreamId}`, + params ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); + onSuccess: (_, { auditLogStreamId, provider }) => { + queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }); + queryClient.invalidateQueries({ + queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId) + }); } }); }; export const useDeleteAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TDeleteAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ auditLogStreamId, provider }: TDeleteAuditLogStreamDTO) => { const { data } = await apiRequest.delete<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${dto.id}` + `/api/v1/audit-log-streams/${provider}/${auditLogStreamId}` ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); + onSuccess: (_, { auditLogStreamId, provider }) => { + queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }); + queryClient.invalidateQueries({ + queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId) + }); } }); }; diff --git a/frontend/src/hooks/api/auditLogStreams/queries.tsx b/frontend/src/hooks/api/auditLogStreams/queries.tsx index ff7d8b499..97d7d6267 100644 --- a/frontend/src/hooks/api/auditLogStreams/queries.tsx +++ b/frontend/src/hooks/api/auditLogStreams/queries.tsx @@ -1,40 +1,89 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TAuditLogStream } from "./types"; +import { TAuditLogStreamProviderOption } from "./types/provider-options"; +import { LogProvider } from "./enums"; +import { TAuditLogStream, TAuditLogStreamProviderMap } from "./types"; export const auditLogStreamKeys = { - list: (orgId: string) => ["audit-log-stream", { orgId }], - getById: (id: string) => ["audit-log-stream-details", { id }] + all: ["audit-log-stream"] as const, + options: () => [...auditLogStreamKeys.all, "options"] as const, + list: () => [...auditLogStreamKeys.all, "list"] as const, + getById: (provider: string, id: string) => + [...auditLogStreamKeys.all, provider, "get-by-id", id] as const }; -const fetchAuditLogStreams = async () => { - const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( - "/api/v1/audit-log-streams" - ); +export const useGetAuditLogStreamOptions = ( + options?: Omit< + UseQueryOptions< + TAuditLogStreamProviderOption[], + unknown, + TAuditLogStreamProviderOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get<{ providerOptions: TAuditLogStreamProviderOption[] }>( + "/api/v1/audit-log-streams/options" + ); - return data.auditLogStreams; -}; - -export const useGetAuditLogStreams = (orgId: string) => - useQuery({ - queryKey: auditLogStreamKeys.list(orgId), - queryFn: () => fetchAuditLogStreams(), - enabled: Boolean(orgId) + return data.providerOptions; + }, + ...options }); - -const fetchAuditLogStreamDetails = async (id: string) => { - const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${id}` - ); - - return data.auditLogStream; }; -export const useGetAuditLogStreamDetails = (id: string) => - useQuery({ - queryKey: auditLogStreamKeys.getById(id), - queryFn: () => fetchAuditLogStreamDetails(id), - enabled: Boolean(id) +export const useListAuditLogStreams = ( + options?: Omit< + UseQueryOptions< + TAuditLogStream[], + unknown, + TAuditLogStream[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( + "/api/v1/audit-log-streams" + ); + + return data.auditLogStreams; + }, + ...options }); +}; + +export const useGetAuditLogStreamById = ( + provider: T, + logStreamId: string, + options?: Omit< + UseQueryOptions< + TAuditLogStreamProviderMap[T], + unknown, + TAuditLogStreamProviderMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.getById(provider, logStreamId), + queryFn: async () => { + const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStreamProviderMap[T] }>( + `/api/v1/audit-log-streams/${provider}/${logStreamId}` + ); + + return data.auditLogStream; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types.ts b/frontend/src/hooks/api/auditLogStreams/types.ts deleted file mode 100644 index 8e21a3209..000000000 --- a/frontend/src/hooks/api/auditLogStreams/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type LogStreamHeaders = { - key: string; - value: string; -}; - -export type TAuditLogStream = { - id: string; - url: string; - headers?: LogStreamHeaders[]; -}; - -export type TCreateAuditLogStreamDTO = { - url: string; - headers?: LogStreamHeaders[]; - orgId: string; -}; - -export type TUpdateAuditLogStreamDTO = { - id: string; - url?: string; - headers?: LogStreamHeaders[]; - orgId: string; -}; - -export type TDeleteAuditLogStreamDTO = { - id: string; - orgId: string; -}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/index.ts b/frontend/src/hooks/api/auditLogStreams/types/index.ts new file mode 100644 index 000000000..d3dffe6a9 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/index.ts @@ -0,0 +1,25 @@ +import { LogProvider } from "../enums"; +import { TCustomProviderLogStream } from "./providers/custom-provider"; +import { TDatadogProviderLogStream } from "./providers/datadog-provider"; +import { TSplunkProviderLogStream } from "./providers/splunk-provider"; + +export type TAuditLogStream = + | TCustomProviderLogStream + | TDatadogProviderLogStream + | TSplunkProviderLogStream; + +export type TAuditLogStreamProviderMap = { + [LogProvider.Custom]: TCustomProviderLogStream; + [LogProvider.Datadog]: TDatadogProviderLogStream; + [LogProvider.Splunk]: TSplunkProviderLogStream; +}; + +export type TCreateAuditLogStreamDTO = Pick; +export type TUpdateAuditLogStreamDTO = Pick & { + provider: LogProvider; + auditLogStreamId: string; +}; +export type TDeleteAuditLogStreamDTO = { + provider: LogProvider; + auditLogStreamId: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts b/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts new file mode 100644 index 000000000..e9b06dfef --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts @@ -0,0 +1,11 @@ +import { LogProvider } from "../enums"; + +export type TAuditLogStreamProviderOptionBase = { + name: string; +}; + +export type TAuditLogStreamProviderOption = { + [P in keyof typeof LogProvider]: TAuditLogStreamProviderOptionBase & { + provider: (typeof LogProvider)[P]; + }; +}[keyof typeof LogProvider]; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts new file mode 100644 index 000000000..83ff9e010 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TCustomProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Custom; + credentials: { + url: string; + headers: { key: string; value: string }[]; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts new file mode 100644 index 000000000..85198bb1a --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TDatadogProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Datadog; + credentials: { + url: string; + token: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts new file mode 100644 index 000000000..5ea3137e5 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts @@ -0,0 +1,6 @@ +export type TRootProviderLogStream = { + id: string; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts new file mode 100644 index 000000000..bf5c676ef --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TSplunkProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Splunk; + credentials: { + hostname: string; + token: string; + }; +}; diff --git a/frontend/src/hooks/api/migration/index.ts b/frontend/src/hooks/api/migration/index.ts index f8dd99d03..0c2adeab0 100644 --- a/frontend/src/hooks/api/migration/index.ts +++ b/frontend/src/hooks/api/migration/index.ts @@ -1 +1,2 @@ export * from "./mutations"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx new file mode 100644 index 000000000..e96533b09 --- /dev/null +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { ExternalMigrationProviders } from "./types"; + +const externalMigrationQueryKeys = { + customMigrationAvailable: (provider: ExternalMigrationProviders) => [ + "custom-migration-available", + provider + ] +}; + +export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.customMigrationAvailable(provider), + queryFn: () => + apiRequest.get<{ enabled: boolean }>( + `/api/v3/external-migration/custom-migration-enabled/${provider}` + ) + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts new file mode 100644 index 000000000..945f18d8e --- /dev/null +++ b/frontend/src/hooks/api/migration/types.ts @@ -0,0 +1,4 @@ +export enum ExternalMigrationProviders { + Vault = "vault", + EnvKey = "env-key" +} diff --git a/frontend/src/hooks/api/notifications/mutations.tsx b/frontend/src/hooks/api/notifications/mutations.tsx new file mode 100644 index 000000000..34ab7aa26 --- /dev/null +++ b/frontend/src/hooks/api/notifications/mutations.tsx @@ -0,0 +1,70 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { useOrganization } from "@app/context"; + +import { notificationKeys } from "./queries"; +import { TUserNotification } from "./types"; + +export const useMarkAllNotificationsAsRead = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await apiRequest.post("/api/v1/notifications/user/mark-as-read"); + }, + onSuccess: () => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { + if (!oldData) return oldData; + return oldData.map((notification) => ({ + ...notification, + isRead: true + })); + }); + } + }); +}; + +export const useUpdateNotification = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ notificationId, isRead }: { notificationId: string; isRead: boolean }) => { + const { data } = await apiRequest.patch<{ notification: TUserNotification }>( + `/api/v1/notifications/user/${notificationId}`, + { isRead } + ); + return data.notification; + }, + onSuccess: (updatedNotification) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { + if (!oldData) return oldData; + return oldData.map((notification) => + notification.id === updatedNotification.id ? updatedNotification : notification + ); + }); + } + }); +}; + +export const useDeleteNotification = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (notificationId: string) => { + await apiRequest.delete(`/api/v1/notifications/user/${notificationId}`); + }, + onSuccess: (_, notificationId) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { + if (!oldData) return oldData; + return oldData.filter((notification) => notification.id !== notificationId); + }); + } + }); +}; diff --git a/frontend/src/hooks/api/notifications/queries.tsx b/frontend/src/hooks/api/notifications/queries.tsx new file mode 100644 index 000000000..f7a31afe6 --- /dev/null +++ b/frontend/src/hooks/api/notifications/queries.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { useOrganization } from "@app/context"; + +import { TUserNotification } from "./types"; + +export const notificationKeys = { + all: ["notifications"] as const, + list: (orgId: string) => [...notificationKeys.all, "list", { orgId }] as const +}; + +export const useGetMyNotifications = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + return useQuery({ + queryKey: notificationKeys.list(orgId), + queryFn: async () => { + const { + data: { notifications } + } = await apiRequest.get<{ notifications: TUserNotification[] }>( + "/api/v1/notifications/user" + ); + return notifications; + }, + refetchInterval: 30 * 1000 // Poll every 30 seconds + }); +}; diff --git a/frontend/src/hooks/api/notifications/types.ts b/frontend/src/hooks/api/notifications/types.ts new file mode 100644 index 000000000..97552046c --- /dev/null +++ b/frontend/src/hooks/api/notifications/types.ts @@ -0,0 +1,10 @@ +export interface TUserNotification { + id: string; + userId: string; + type: string; + title: string; + body?: string | null; + link?: string | null; + isRead: boolean; + createdAt: string; +} diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 71f2a8b90..e9c36fada 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -159,3 +159,8 @@ export enum OrgIdentityOrderBy { Name = "name", Role = "role" } + +export enum OrgMembershipStatus { + Invited = "invited", + Accepted = "accepted" +} diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 0a43e850e..5a90d4b64 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -19,10 +19,10 @@ export type User = { createdAt: Date; updatedAt: Date; username: string; - email?: string; + email?: string | null; superAdmin: boolean; - firstName?: string; - lastName?: string; + firstName?: string | null; + lastName?: string | null; authProvider?: AuthMethod; authMethods: AuthMethod[]; isMfaEnabled: boolean; diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 097654f26..21be0bb82 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -1,11 +1,15 @@ import { useTranslation } from "react-i18next"; import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Outlet, useRouterState } from "@tanstack/react-router"; +import { Outlet } from "@tanstack/react-router"; import { Banner } from "@app/components/page-frames/Banner"; -import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2"; -import { useServerConfig } from "@app/context"; +import { useServerConfig, useSubscription } from "@app/context"; +import { useFetchServerStatus } from "@app/hooks/api"; +import { AuditLogBanner } from "@app/layouts/OrganizationLayout/components/AuditLogBanner"; +import { Navbar } from "@app/layouts/OrganizationLayout/components/NavBar"; +import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBanner"; +import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; import { AdminSidebar } from "./Sidebar"; @@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar"; export const AdminLayout = () => { const { t } = useTranslation(); const { config } = useServerConfig(); - - const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); - - const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; + const { data: serverDetails, isLoading } = useFetchServerStatus(); + const { subscription } = useSubscription(); const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; return ( <> -
diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx index e1cd223f8..25b327102 100644 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ b/frontend/src/layouts/AdminLayout/Sidebar.tsx @@ -1,57 +1,62 @@ -import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faBuilding, + faChevronLeft, + faCog, + faDatabase, + faKey, + faLock, + faPlug, + faUserTie +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useMatchRoute } from "@tanstack/react-router"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Menu, MenuGroup, MenuItem } from "@app/components/v2"; const generalTabs = [ { label: "General", - icon: "settings-cog", + icon: faCog, link: "/admin/" }, { label: "Encryption", - icon: "lock-closed", + icon: faLock, link: "/admin/encryption" }, { label: "Authentication", - icon: "check", + icon: faCheckCircle, link: "/admin/authentication" }, { label: "Integrations", - icon: "sliding-carousel", + icon: faPlug, link: "/admin/integrations" }, { label: "Caching", - icon: "note", + icon: faDatabase, link: "/admin/caching" }, { label: "Environment Variables", - icon: "unlock", + icon: faKey, link: "/admin/environment" } ]; -const resourceTabs = [ +const othersTabs = [ { - label: "Organizations", - icon: "groups", - link: "/admin/resources/organizations" + label: "Access Controls", + icon: faUserTie, + link: "/admin/access-management" }, { - label: "User Identities", - icon: "user", - link: "/admin/resources/user-identities" - }, - { - label: "Machine Identities", - icon: "wrench", - link: "/admin/resources/machine-identities" + label: "Resource Overview", + icon: faBuilding, + link: "/admin/resources/overview" } ]; @@ -61,6 +66,44 @@ export const AdminSidebar = () => { return ( ); diff --git a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx index 9f7494076..755611587 100644 --- a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx @@ -1,12 +1,10 @@ -import { useOrganization } from "@app/context"; -import { useFetchServerStatus, useGetAuditLogStreams } from "@app/hooks/api"; +import { useFetchServerStatus, useListAuditLogStreams } from "@app/hooks/api"; import { OrgAlertBanner } from "../OrgAlertBanner"; export const AuditLogBanner = () => { - const org = useOrganization(); const { data: status, isLoading: isLoadingStatus } = useFetchServerStatus(); - const { data: streams, isLoading: isLoadingStreams } = useGetAuditLogStreams(org.currentOrg.id); + const { data: streams, isLoading: isLoadingStreams } = useListAuditLogStreams(); if (isLoadingStreams || isLoadingStatus || !streams) return null; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 5c37d4404..63da8b533 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -10,13 +10,14 @@ import { faEnvelope, faInfo, faInfoCircle, + faServer, faSignOut, faUser, faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; -import { Link, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import { Mfa } from "@app/components/auth/Mfa"; import { createNotification } from "@app/components/notifications"; @@ -47,6 +48,7 @@ import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; +import { NotificationDropdown } from "./NotificationDropdown"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.groups) return "Enterprise"; @@ -118,6 +120,7 @@ export const Navbar = () => { const router = useRouter(); const queryClient = useQueryClient(); + const location = useLocation(); const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; @@ -179,6 +182,8 @@ export const Navbar = () => { ); } + const isServerAdminPanel = location.pathname.startsWith("/admin"); + return (
@@ -187,100 +192,121 @@ export const Navbar = () => {

/

-
- - -
-
- -
-
{currentOrg?.name}
-
- {getPlan(subscription)} -
-
- - -
- - - -
-
- + -
organizations
- {orgs?.map((org) => { - return ( - - + + +
+ + +
organizations
+ {orgs?.map((org) => { + return ( + + + + ); + })} +
+ } onClick={logOutUser}> + Log Out - ); - })} -
- } onClick={logOutUser}> - Log Out - - - -
-

/

- {breadcrumbs ? ( - - ) : null} + + +
+

/

+ {breadcrumbs ? ( + + ) : null} + + )}
-
+
@@ -334,9 +360,10 @@ export const Navbar = () => { )} + -
+
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx new file mode 100644 index 000000000..59e6861fc --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx @@ -0,0 +1,60 @@ +import Markdown from "react-markdown"; +import { faCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { TUserNotification } from "@app/hooks/api/notifications/types"; + +type Props = { + notification: TUserNotification; + onDelete: (notificationId: string) => void; +}; + +export const Notification = ({ notification, onDelete }: Props) => { + return ( +
+
+
+ {!notification.isRead && ( + + )} + {notification.title}} delayDuration={300}> + + {notification.title} + + + + {formatDistance(notification.createdAt, new Date())} ago + +
+ {notification.body && ( + + {notification.body} + + )} +
+
+ { + e.stopPropagation(); + onDelete(notification.id); + }} + > + + +
+
+ ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/NotificationDropdown.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/NotificationDropdown.tsx new file mode 100644 index 000000000..a5e9166d9 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/NotificationDropdown.tsx @@ -0,0 +1,116 @@ +import { useMemo } from "react"; +import { faBell } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useRouter } from "@tanstack/react-router"; + +import { + ContentLoader, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/v2"; +import { + useDeleteNotification, + useMarkAllNotificationsAsRead, + useUpdateNotification +} from "@app/hooks/api/notifications/mutations"; +import { useGetMyNotifications } from "@app/hooks/api/notifications/queries"; + +import { Notification } from "./Notification"; + +export const NotificationDropdown = () => { + const router = useRouter(); + + const { data: notifications, isLoading } = useGetMyNotifications(); + const { mutate: markAllAsRead } = useMarkAllNotificationsAsRead(); + const { mutate: updateNotification } = useUpdateNotification(); + const { mutate: deleteNotification } = useDeleteNotification(); + + const unreadCount = useMemo( + () => notifications?.filter((n) => !n.isRead).length || 0, + [notifications] + ); + + return ( + + +
+ + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} +
+
+ +
+
+ Notifications + +
+
+ {isLoading && ( +
+ +
+ )} + {!isLoading && notifications?.length === 0 && ( +
+ + No new notifications + + We'll let you know when something important happens. + +
+ )} + {!isLoading && notifications && notifications.length > 0 && ( +
+ {notifications.map((notification) => ( +
{ + if (!notification.isRead) { + updateNotification({ notificationId: notification.id, isRead: true }); + } + if (notification.link) { + router.navigate({ to: notification.link }); + } + }} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + if (!notification.isRead) { + updateNotification({ notificationId: notification.id, isRead: true }); + } + if (notification.link) { + router.navigate({ to: notification.link }); + } + }} + > + +
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx index de4b65a71..80adce989 100644 --- a/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx @@ -27,11 +27,11 @@ export const OrgAlertBanner = ({ text, link }: Props) => { target="_blank" className="group flex items-center" > - + here diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/OrganizationResourcesPage.tsx b/frontend/src/pages/admin/AccessManagementPage/AccessManagementPage.tsx similarity index 60% rename from frontend/src/pages/admin/OrganizationResourcesPage/OrganizationResourcesPage.tsx rename to frontend/src/pages/admin/AccessManagementPage/AccessManagementPage.tsx index 34bc0e57b..8128a53e0 100644 --- a/frontend/src/pages/admin/OrganizationResourcesPage/OrganizationResourcesPage.tsx +++ b/frontend/src/pages/admin/AccessManagementPage/AccessManagementPage.tsx @@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; -import { OrganizationsTable } from "./components"; +import { ServerAdminsTable } from "./components"; -export const OrganizationResourcesPage = () => { +export const AccessManagementPage = () => { const { t } = useTranslation(); return (
- {t("common.head-title", { title: "Admin" })} + {t("common.head-title", { title: "Access Control" })}
- +
diff --git a/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx b/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx new file mode 100644 index 000000000..aa02d40e8 --- /dev/null +++ b/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx @@ -0,0 +1,141 @@ +import { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; +import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onClose: () => void; +}; + +const getUserLabel = (user: Pick) => { + const { firstName, lastName, username, email } = user; + + const name = `${firstName ?? ""} ${lastName ?? ""}`.trim(); + const userEmail = email || username; + + if (!name) return userEmail; + + return `${name}${userEmail ? ` (${userEmail})` : ""}`; +}; + +const AddServerAdminSchema = z.object({ + user: z.object({ + id: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), + email: z.string().nullish(), + username: z.string() + }) +}); + +type FormData = z.infer; + +const Content = ({ onClose }: ContentProps) => { + const grantAdmin = useAdminGrantServerAdminAccess(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(AddServerAdminSchema) + }); + + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { data, isFetching } = useAdminGetUsers( + { + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: false + }, + { + placeholderData: (prev) => prev + } + ); + + const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]); + + const onSubmit = async ({ user }: FormData) => { + try { + await grantAdmin.mutateAsync(user.id); + + createNotification({ + type: "success", + text: "Successfully granted server admin status" + }); + onClose(); + } catch { + createNotification({ + text: "Failed to grant server admin status", + type: "error" + }); + } + }; + + return ( +
+ ( + + getUserLabel(user)} + getOptionValue={(user) => user.id} + value={field.value} + onChange={field.onChange} + onInputChange={(value) => { + setSearchUserFilter(value); + if (!value) setDebouncedSearchTerm(""); + }} + /> + + )} + control={control} + name="user" + /> +
+ + +
+ + ); +}; + +export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx b/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx new file mode 100644 index 000000000..e9298c62b --- /dev/null +++ b/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx @@ -0,0 +1,476 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { + faEllipsisV, + faMagnifyingGlass, + faPlus, + faShieldHalved, + faTrash, + faUsers, + faUserXmark, + faWarning, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { InfiniteData } from "@tanstack/react-query"; +import { twMerge } from "tailwind-merge"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { + Badge, + Button, + Checkbox, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useSubscription, useUser } from "@app/context"; +import { useDebounce, usePopUp } from "@app/hooks"; +import { + useAdminBulkDeleteUsers, + useAdminDeleteUser, + useAdminGetUsers, + useRemoveUserServerAdminAccess +} from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal"; + +const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; + +const ServerAdminsPanelTable = ({ + handlePopUpOpen, + users: usersPages, + isPending, + searchUserFilter, + setSearchUserFilter, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + selectedUsers, + setSelectedUsers +}: { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"] + >, + data?: { + username: string; + id: string; + message?: string; + } + ) => void; + isPending: boolean; + users: InfiniteData | undefined; + searchUserFilter: string; + setSearchUserFilter: (filter: string) => void; + selectedUsers: User[]; + setSelectedUsers: Dispatch>; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; +}) => { + const { subscription } = useSubscription(); + + const users = usersPages?.pages.flat(); + + const isEmpty = !isPending && !users?.length; + + const selectedUserIds = selectedUsers.map((user) => user.id); + + const isPageSelected = users?.length + ? users.every((user) => selectedUserIds.includes(user.id)) + : false; + + // eslint-disable-next-line no-nested-ternary + const isPageIndeterminate = isPageSelected + ? false + : users?.length + ? users?.some((user) => selectedUserIds.includes(user.id)) + : false; + + return ( + <> +
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search admins..." + className="flex-1" + /> + +
+
+ + + + + + + + + + + {isPending && } + {!isPending && + users?.map((user) => { + const { username, email, firstName, lastName, id } = user; + const name = firstName || lastName ? `${firstName} ${lastName}` : null; + + const isSelected = selectedUserIds.includes(id); + return ( + + + + + + + ); + })} + +
+ { + if (isPageSelected) { + setSelectedUsers((prev) => + prev.filter((u) => !users?.find((user) => user.id === u.id)) + ); + } else { + setSelectedUsers((prev) => [ + ...prev, + ...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? []) + ]); + } + }} + /> + NameUsername +
+ { + e.stopPropagation(); + setSelectedUsers((prev) => + isSelected ? prev.filter((u) => u.id !== id) : [...prev, user] + ); + }} + /> + +

+ {name ?? Not Set} +

+
+

{username || email}

+
+
+ + + + + + + + { + e.stopPropagation(); + handlePopUpOpen("removeUser", { username, id }); + }} + icon={} + > + Remove User + + + + +
+ } + onClick={(e) => { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: removeServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("removeServerAdmin", { username, id }); + }} + > + Remove Server Admin + + + + +
+ {!isPending && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ + ); +}; + +export const ServerAdminsTable = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeUser", + "upgradePlan", + "addServerAdmin", + "removeServerAdmin", + "removeUsers" + ] as const); + + const { + user: { id: userId } + } = useUser(); + + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers(); + const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); + + const [selectedUsers, setSelectedUsers] = useState([]); + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { + data: users, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage + } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: true + }); + + const handleRemoveUser = async () => { + const { id } = popUp?.removeUser?.data as { id: string; username: string }; + + try { + await deleteUser(id); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + } catch { + createNotification({ + type: "error", + text: "Error deleting user" + }); + } + + handlePopUpClose("removeUser"); + }; + + const handleRemoveServerAdminAccess = async () => { + const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string }; + + try { + await removeAdminAccess(id); + createNotification({ + type: "success", + text: "Successfully removed server admin access from user" + }); + } catch { + createNotification({ + type: "error", + text: "Error removing server admin access from user" + }); + } + + handlePopUpClose("removeServerAdmin"); + }; + + const handleRemoveUsers = async () => { + try { + await deleteUsers(selectedUsers.map((user) => user.id)); + + createNotification({ + text: "Successfully removed users", + type: "success" + }); + + setSelectedUsers([]); + handlePopUpClose("removeUsers"); + } catch { + createNotification({ + text: "Failed to remove users", + type: "error" + }); + } + }; + + return ( + <> +
0 && "h-16" + )} + > +
+
{selectedUsers.length} Selected
+ + +
+
+
+ + handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdminAccess} + buttonText="Remove Access" + /> + handlePopUpToggle("addServerAdmin", isOpen)} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + /> + handlePopUpToggle("removeUsers", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleRemoveUsers()} + buttonText="Remove" + > +
+ The following users will be deleted: +
+
+
    + {selectedUsers?.map((user) => { + const email = user.email ?? user.username; + return ( +
  • +
    +

    + {user.firstName || user.lastName ? ( + <> + {`${`${user.firstName} ${user.lastName}`.trim()} `}( + {email}) + + ) : ( + {email} + )}{" "} +

    + {userId === user.id && ( + +
    + + + Deleting Yourself + +
    +
    + )} +
    +
  • + ); + })} +
+
+
+
+ + ); +}; diff --git a/frontend/src/pages/admin/AccessManagementPage/components/index.tsx b/frontend/src/pages/admin/AccessManagementPage/components/index.tsx new file mode 100644 index 000000000..fa73b9d70 --- /dev/null +++ b/frontend/src/pages/admin/AccessManagementPage/components/index.tsx @@ -0,0 +1 @@ +export * from "./ServerAdminsTable"; diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/route.tsx b/frontend/src/pages/admin/AccessManagementPage/route.tsx similarity index 55% rename from frontend/src/pages/admin/UserIdentitiesResourcesPage/route.tsx rename to frontend/src/pages/admin/AccessManagementPage/route.tsx index 6d1420ee5..0980fd7e3 100644 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/route.tsx +++ b/frontend/src/pages/admin/AccessManagementPage/route.tsx @@ -1,11 +1,11 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; -import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage"; +import { AccessManagementPage } from "./AccessManagementPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities" + "/_authenticate/_inject-org-details/admin/_admin-layout/access-management" )({ - component: UserIdentitiesResourcesPage, + component: AccessManagementPage, beforeLoad: async () => { return { breadcrumbs: [ @@ -14,9 +14,9 @@ export const Route = createFileRoute( link: linkOptions({ to: "/admin" }) }, { - label: "User Identities", + label: "Access Control", link: linkOptions({ - to: "/admin/resources/user-identities" + to: "/admin/access-management" }) } ] diff --git a/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx b/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx index c9215a165..e5a76e93c 100644 --- a/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx +++ b/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx @@ -2,11 +2,13 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; +import { useGetServerConfig } from "@app/hooks/api/admin"; -import { GeneralPageForm } from "./components"; +import { GeneralPageForm, UsageReportSection } from "./components"; export const GeneralPage = () => { const { t } = useTranslation(); + const { data: serverConfig } = useGetServerConfig(); return (
@@ -19,7 +21,10 @@ export const GeneralPage = () => { title="General" description="Manage general settings for your Infisical instance." /> - +
+ + {serverConfig?.isOfflineUsageReportsEnabled && } +
diff --git a/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx b/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx index febc7f328..da15f5eea 100644 --- a/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx +++ b/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx @@ -103,7 +103,7 @@ export const GeneralPageForm = () => { return (
diff --git a/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx b/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx new file mode 100644 index 000000000..d05800996 --- /dev/null +++ b/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx @@ -0,0 +1,53 @@ +import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Card, CardTitle } from "@app/components/v2"; +import { downloadFile } from "@app/helpers/download"; +import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation"; + +export const UsageReportSection = () => { + const generateUsageReport = useGenerateUsageReport(); + + const handleGenerateReport = async () => { + try { + const response = await generateUsageReport.mutateAsync(); + const { csvContent, filename } = response; + + downloadFile(csvContent, filename, "text/csv"); + + createNotification({ + text: `Usage report downloaded: "${filename}"`, + type: "success" + }); + } catch (error) { + console.error("Failed to generate usage report:", error); + createNotification({ + text: "Failed to generate usage report. Please try again.", + type: "error" + }); + } + }; + + return ( + + + + Offline Usage Reports + + +
+ Generate secure usage reports for offline license compliance and billing verification. +
+ + +
+ ); +}; diff --git a/frontend/src/pages/admin/GeneralPage/components/index.ts b/frontend/src/pages/admin/GeneralPage/components/index.ts index 15ea2f5ba..2ec57dadf 100644 --- a/frontend/src/pages/admin/GeneralPage/components/index.ts +++ b/frontend/src/pages/admin/GeneralPage/components/index.ts @@ -1 +1,2 @@ export { GeneralPageForm } from "./GeneralPageForm"; +export { UsageReportSection } from "./UsageReportSection"; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/MachineIdentitiesResourcesPage.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/MachineIdentitiesResourcesPage.tsx deleted file mode 100644 index b3faefcbf..000000000 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/MachineIdentitiesResourcesPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; - -import { PageHeader } from "@app/components/v2"; - -import { MachineIdentitiesTable } from "./components"; - -export const MachineIdentitiesResourcesPage = () => { - const { t } = useTranslation(); - - return ( -
- - {t("common.head-title", { title: "Admin" })} - -
-
- - -
-
-
- ); -}; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/index.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/index.tsx deleted file mode 100644 index 007cc568a..000000000 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MachineIdentitiesTable } from "./MachineIdentitiesTable"; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/route.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/route.tsx deleted file mode 100644 index 359db53b2..000000000 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/route.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createFileRoute, linkOptions } from "@tanstack/react-router"; - -import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage"; - -export const Route = createFileRoute( - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities" -)({ - component: MachineIdentitiesResourcesPage, - beforeLoad: async () => { - return { - breadcrumbs: [ - { - label: "Admin", - link: linkOptions({ to: "/admin" }) - }, - { - label: "Machine Identities", - link: linkOptions({ - to: "/admin/resources/machine-identities" - }) - } - ] - }; - } -}); diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/components/OrganizationsTable.tsx b/frontend/src/pages/admin/OrganizationResourcesPage/components/OrganizationsTable.tsx deleted file mode 100644 index 0740d43bc..000000000 --- a/frontend/src/pages/admin/OrganizationResourcesPage/components/OrganizationsTable.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { useState } from "react"; -import { - faBuilding, - faCircleQuestion, - faEllipsis, - faMagnifyingGlass -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { - Badge, - Button, - DeleteActionModal, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - EmptyState, - Input, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tooltip, - Tr -} from "@app/components/v2"; -import { useDebounce, usePopUp } from "@app/hooks"; -import { - useAdminDeleteOrganization, - useAdminDeleteOrganizationMembership, - useAdminDeleteUser, - useAdminGetOrganizations -} from "@app/hooks/api"; -import { OrganizationWithProjects } from "@app/hooks/api/admin/types"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -const ViewMembersModalContent = ({ - popUp, - handlePopUpOpen -}: { - popUp: UsePopUpState<["viewMembers"]>; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>, - data?: { - username?: string; - membershipId?: string; - userId?: string; - orgName?: string; - orgId?: string; - organization?: OrganizationWithProjects; - } - ) => void; -}) => { - const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects; - - return ( -
- {organization?.members?.map((member) => ( -
-
-
-

-

- {member.user.firstName ? ( -
- {member.user.firstName} {member.user.lastName} -
- ) : ( -

Not set

- )} -
-
-
{member.user.username || member.user.email}
- -
- {member.role.replace("-", " ")} - {Boolean(member.roleId) && ( - - - - )} -
-
-
-

-
-
-
- - - - - - - handlePopUpOpen("deleteOrganizationMembership", { - membershipId: member.membershipId, - orgId: organization.id, - username: member.user.username, - orgName: organization.name - }) - } - > - Remove From Organization - - handlePopUpOpen("deleteUser", { userId: member.user.id })} - > - Delete User - - - -
-
- ))} -
- ); -}; - -const ViewMembersModal = ({ - isOpen, - onOpenChange, - popUp, - handlePopUpOpen -}: { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]> - ) => void; -}) => { - return ( - - { - event.preventDefault(); - }} - title="Organization Members" - subTitle="View the members of the organization." - > - - - - ); -}; - -const OrganizationsPanelTable = ({ - popUp, - handlePopUpOpen, - handlePopUpToggle -}: { - popUp: UsePopUpState< - ["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"] - >; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState< - ["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"] - >, - data?: { - orgName?: string; - orgId?: string; - message?: string; - organization?: OrganizationWithProjects; - } - ) => void; - handlePopUpToggle: ( - popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>, - isOpen?: boolean - ) => void; -}) => { - const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState(""); - const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500); - - const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = - useAdminGetOrganizations({ - limit: 20, - searchTerm: debouncedSearchTerm - }); - - const isEmpty = !isPending && !data?.pages?.[0].length; - - return ( - <> -
- setSearchOrganizationsFilter(e.target.value)} - leftIcon={} - placeholder="Search organizations..." - className="flex-1" - /> -
-
- - - - - - - - - - - {isPending && } - {!isPending && - data?.pages?.map((orgs) => - orgs.map((org) => { - return ( - - - - - - - ); - }) - )} - -
NameMembersProjects -
- {org.name ? ( - org.name - ) : ( - Not set - )} - - {org.members.length} {org.members.length === 1 ? "member" : "members"} - - - {org.projects.length} {org.projects.length === 1 ? "project" : "projects"} - -
- - -
- -
-
- - { - e.stopPropagation(); - handlePopUpOpen("deleteOrganization", { - orgId: org.id, - orgName: org.name - }); - }} - > - Delete Organization - - -
-
-
- {!isPending && isEmpty && } -
- {!isEmpty && ( - - )} -
- handlePopUpToggle("viewMembers", isOpen)} - /> - - ); -}; - -export const OrganizationsTable = () => { - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "deleteOrganization", - "deleteOrganizationMembership", - "deleteUser", - "viewMembers" - ] as const); - - const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization(); - const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership(); - const { mutateAsync: deleteUser } = useAdminDeleteUser(); - - const handleDeleteOrganization = async () => { - const { orgId } = popUp?.deleteOrganization?.data as { orgId: string }; - - await deleteOrganization(orgId); - createNotification({ - type: "success", - text: "Successfully deleted organization" - }); - - handlePopUpClose("deleteOrganization"); - }; - - const handleDeleteOrganizationMembership = async () => { - const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as { - orgId: string; - membershipId: string; - }; - - if (!orgId || !membershipId) { - return; - } - - await deleteOrganizationMembership({ organizationId: orgId, membershipId }); - createNotification({ - type: "success", - text: "Successfully removed user from organization" - }); - - handlePopUpClose("viewMembers"); - handlePopUpClose("deleteOrganizationMembership"); - }; - - const handleDeleteUser = async () => { - const { userId } = popUp?.deleteUser?.data as { userId: string }; - - if (!userId) { - return; - } - - await deleteUser(userId); - createNotification({ - type: "success", - text: "Successfully deleted user" - }); - - handlePopUpClose("viewMembers"); - handlePopUpClose("deleteUser"); - }; - - return ( -
- - handlePopUpToggle("deleteOrganization", isOpen)} - onDeleteApproved={handleDeleteOrganization} - /> - handlePopUpToggle("deleteOrganizationMembership", isOpen)} - onDeleteApproved={handleDeleteOrganizationMembership} - /> - handlePopUpToggle("deleteUser", isOpen)} - onDeleteApproved={handleDeleteUser} - /> -
- ); -}; diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/components/index.tsx b/frontend/src/pages/admin/OrganizationResourcesPage/components/index.tsx deleted file mode 100644 index 9a054c599..000000000 --- a/frontend/src/pages/admin/OrganizationResourcesPage/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrganizationsTable } from "./OrganizationsTable"; diff --git a/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx b/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx new file mode 100644 index 000000000..e9096d39a --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx @@ -0,0 +1,42 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; + +import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components"; + +export const ResourceOverviewPage = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Resource Overview" })} + +
+
+ + + + Organizations + Users + Identities + + + + + + + + + + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/AddOrganizationModal.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/AddOrganizationModal.tsx new file mode 100644 index 000000000..9b96ac5c3 --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/AddOrganizationModal.tsx @@ -0,0 +1,203 @@ +import { useMemo, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { CreatableSelect } from "@app/components/v2/CreatableSelect"; +import { useDebounce } from "@app/hooks"; +import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onClose: () => void; +}; + +type Invitee = Pick; +type NewOption = { label: string; value: string }; + +const getUserLabel = (user: Invitee | NewOption) => { + if (Object.prototype.hasOwnProperty.call(user, "value")) { + return (user as NewOption).label; + } + + const { firstName, lastName, username, email } = user as Invitee; + + const name = `${firstName ?? ""} ${lastName ?? ""}`.trim(); + const userEmail = email || username; + + if (!name) return userEmail; + + return `${name}${userEmail ? ` (${userEmail})` : ""}`; +}; + +const AddOrgSchema = z.object({ + name: GenericResourceNameSchema.nonempty("Organization name required"), + invitees: z + .object({ + id: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), + email: z.string().nullish(), + username: z.string().nullish() + }) + .array() + .min(1, "At least one admin is required") +}); + +type FormData = z.infer; + +const Content = ({ onClose }: ContentProps) => { + const createOrg = useServerAdminCreateOrganization(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + defaultValues: { + name: "", + invitees: [] + }, + resolver: zodResolver(AddOrgSchema) + }); + + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { data, isFetching } = useAdminGetUsers( + { + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: false + }, + { + placeholderData: (prev) => prev + } + ); + + const users = useMemo(() => data?.pages.flat() ?? [], [data]); + + const onSubmit = async ({ name, invitees }: FormData) => { + try { + await createOrg.mutateAsync({ + name, + inviteAdminEmails: invitees + .filter((user) => Boolean(user.email)) + .map((user) => user.email) as string[] + }); + + createNotification({ + type: "success", + text: "Successfully created organization" + }); + onClose(); + } catch { + createNotification({ + text: "Failed to create organization", + type: "error" + }); + } + }; + + const { append } = useFieldArray({ control, name: "invitees" }); + + return ( + + ( + + + + )} + control={control} + name="name" + /> + ( + + ( +

Invite new users to this organization by typing out their email address.

+ )} + onCreateOption={(inputValue) => + append({ id: `${inputValue}_${Math.random()}`, email: inputValue }) + } + formatCreateLabel={(inputValue) => `Invite "${inputValue}"`} + isValidNewOption={(input) => + Boolean(input) && + z.string().email().safeParse(input).success && + !users + ?.flatMap((user) => { + const emails: string[] = []; + + if (user.email) { + emails.push(user.email); + } + + if (user.username) { + emails.push(user.username); + } + + return emails; + }) + .includes(input) + } + isLoading={searchUserFilter !== debouncedSearchTerm || isFetching} + className="w-full" + placeholder="Search users or invite new ones..." + isMulti + name="members" + options={users} + getOptionLabel={(user) => getUserLabel(user)} + getOptionValue={(user) => user.id} + value={field.value} + onChange={field.onChange} + onInputChange={(value) => { + setSearchUserFilter(value); + if (!value) setDebouncedSearchTerm(""); + }} + /> +
+ )} + control={control} + name="invitees" + /> +
+ + +
+ + ); +}; + +export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx similarity index 95% rename from frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx rename to frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx index 9c44b72c4..6370bab54 100644 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { faEllipsisV, faMagnifyingGlass, - faServer, faShieldHalved, + faWrench, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -136,7 +136,7 @@ const IdentityPanelTable = ({ )} - {!isPending && isEmpty && } + {!isPending && isEmpty && } {!isEmpty && ( + )} +
+ handlePopUpToggle("viewMembers", isOpen)} + /> + + ); +}; + +export const OrganizationsTable = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "deleteOrganization", + "deleteOrganizationMembership", + "deleteUser", + "viewMembers", + "createOrganization" + ] as const); + + const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization(); + const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership(); + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + + const handleDeleteOrganization = async () => { + const { orgId } = popUp?.deleteOrganization?.data as { orgId: string }; + + await deleteOrganization(orgId); + createNotification({ + type: "success", + text: "Successfully deleted organization" + }); + + handlePopUpClose("deleteOrganization"); + }; + + const handleDeleteOrganizationMembership = async () => { + const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as { + orgId: string; + membershipId: string; + }; + + if (!orgId || !membershipId) { + return; + } + + await deleteOrganizationMembership({ organizationId: orgId, membershipId }); + createNotification({ + type: "success", + text: "Successfully removed user from organization" + }); + + handlePopUpClose("viewMembers"); + handlePopUpClose("deleteOrganizationMembership"); + }; + + const handleDeleteUser = async () => { + const { userId } = popUp?.deleteUser?.data as { userId: string }; + + if (!userId) { + return; + } + + await deleteUser(userId); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + + handlePopUpClose("viewMembers"); + handlePopUpClose("deleteUser"); + }; + + return ( +
+
+
+

Organizations

+

+ Manage, join and view organizations across your instance. +

+
+ +
+ + handlePopUpToggle("deleteOrganization", isOpen)} + onDeleteApproved={handleDeleteOrganization} + /> + handlePopUpToggle("deleteOrganizationMembership", isOpen)} + onDeleteApproved={handleDeleteOrganizationMembership} + /> + handlePopUpToggle("deleteUser", isOpen)} + onDeleteApproved={handleDeleteUser} + /> + handlePopUpToggle("createOrganization", isOpen)} + /> +
+ ); +}; diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx similarity index 97% rename from frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx rename to frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx index 1f2471aa2..9856ee497 100644 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx @@ -218,7 +218,7 @@ const UserPanelTable = ({
-

{email}

+

{username || email}

@@ -463,6 +463,12 @@ export const UserIdentitiesTable = () => {
+
+
+

User Identities

+

Manage user identities across your instance.

+
+
{ /> handlePopUpToggle("removeUsers", isOpen)} deleteKey="confirm" onDeleteApproved={() => handleRemoveUsers()} - buttonText="Remove" + buttonText="Delete" >
- The following members will be removed: + The following users will be deleted:
    @@ -549,7 +555,7 @@ export const UserIdentitiesTable = () => { className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap" > - Removing Yourself + Deleting Yourself
diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/index.ts b/frontend/src/pages/admin/ResourceOverviewPage/components/index.ts new file mode 100644 index 000000000..ce70dee05 --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/index.ts @@ -0,0 +1,3 @@ +export * from "./MachineIdentitiesTable"; +export * from "./OrganizationsTable"; +export * from "./UserIdentitiesTable"; diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/route.tsx b/frontend/src/pages/admin/ResourceOverviewPage/route.tsx similarity index 66% rename from frontend/src/pages/admin/OrganizationResourcesPage/route.tsx rename to frontend/src/pages/admin/ResourceOverviewPage/route.tsx index 9b0bf8f12..ea27c1a06 100644 --- a/frontend/src/pages/admin/OrganizationResourcesPage/route.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/route.tsx @@ -1,11 +1,11 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; -import { OrganizationResourcesPage } from "./OrganizationResourcesPage"; +import { ResourceOverviewPage } from "./ResourceOverviewPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations" + "/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview" )({ - component: OrganizationResourcesPage, + component: ResourceOverviewPage, beforeLoad: async () => { return { breadcrumbs: [ @@ -14,9 +14,9 @@ export const Route = createFileRoute( link: linkOptions({ to: "/admin" }) }, { - label: "Organizations", + label: "Resource Overview", link: linkOptions({ - to: "/admin/resources/organizations" + to: "/admin/resources/overview" }) } ] diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/UserIdentitiesResourcesPage.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/UserIdentitiesResourcesPage.tsx deleted file mode 100644 index 35c4d9466..000000000 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/UserIdentitiesResourcesPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; - -import { PageHeader } from "@app/components/v2"; - -import { UserIdentitiesTable } from "./components"; - -export const UserIdentitiesResourcesPage = () => { - const { t } = useTranslation(); - - return ( -
- - {t("common.head-title", { title: "Admin" })} - -
-
- - -
-
-
- ); -}; diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/index.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/index.tsx deleted file mode 100644 index 3fe6b81f3..000000000 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { UserIdentitiesTable } from "./UserIdentitiesTable"; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index 9e6850f82..4dd0bbe24 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -88,7 +88,15 @@ export const SelectOrganizationSection = () => { // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO let url = ""; - if (organization.orgAuthMethod === AuthMethod.OIDC) { + if (organization.googleSsoAuthEnforced) { + if (authToken.authMethod !== AuthMethod.GOOGLE) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } + } + } else if (organization.orgAuthMethod === AuthMethod.OIDC) { url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ callbackPort ? `&callbackPort=${callbackPort}` : "" }`; @@ -98,15 +106,6 @@ export const SelectOrganizationSection = () => { if (callbackPort) { url += `?callback_port=${callbackPort}`; } - } else if ( - organization.googleSsoAuthEnforced && - authToken.authMethod !== AuthMethod.GOOGLE - ) { - url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; - - if (callbackPort) { - url += `&callback_port=${callbackPort}`; - } } // we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO diff --git a/frontend/src/pages/middlewares/restrict-login-signup.tsx b/frontend/src/pages/middlewares/restrict-login-signup.tsx index c3d58f248..294bece26 100644 --- a/frontend/src/pages/middlewares/restrict-login-signup.tsx +++ b/frontend/src/pages/middlewares/restrict-login-signup.tsx @@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery"; const QueryParamsSchema = z.object({ callback_port: z.coerce.number().optional().catch(undefined), - force: z.boolean().optional() + force: z.boolean().optional(), + org_id: z.string().optional().catch(undefined) }); export const AuthConsentWrapper = () => { @@ -102,6 +103,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({ return; } + if (search.org_id) { + if (location.pathname.endsWith("select-organization")) return; + + throw redirect({ to: "/login/select-organization", search: { org_id: search.org_id } }); + } + if (!data.organizationId) { if ( location.pathname.endsWith("select-organization") || diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx deleted file mode 100644 index 7e16afd60..000000000 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, FormLabel, IconButton, Input, Spinner } from "@app/components/v2"; -import { useOrganization } from "@app/context"; -import { - useCreateAuditLogStream, - useGetAuditLogStreamDetails, - useUpdateAuditLogStream -} from "@app/hooks/api"; - -type Props = { - id?: string; - onClose: () => void; -}; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const formSchema = z.object({ - url: z.string().url().min(1), - headers: z - .object({ - key: z.string(), - value: z.string() - }) - .array() - .optional() -}); -type TForm = z.infer; - -export const AuditLogStreamForm = ({ id = "", onClose }: Props) => { - const isEdit = Boolean(id); - const { currentOrg } = useOrganization(); - const orgId = currentOrg?.id || ""; - - const auditLogStream = useGetAuditLogStreamDetails(id); - const createAuditLogStream = useCreateAuditLogStream(); - const updateAuditLogStream = useUpdateAuditLogStream(); - - const { - handleSubmit, - control, - setValue, - getValues, - formState: { isSubmitting } - } = useForm({ - values: auditLogStream?.data, - defaultValues: { - headers: [{ key: "", value: "" }] - } - }); - - const headerFields = useFieldArray({ - control, - name: "headers" - }); - - const handleAuditLogStreamEdit = async ({ headers, url }: TForm) => { - if (!id) return; - try { - await updateAuditLogStream.mutateAsync({ - id, - orgId, - headers, - url - }); - createNotification({ - type: "success", - text: "Successfully updated stream" - }); - onClose(); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to update stream" - }); - } - }; - - const handleFormSubmit = async ({ headers = [], url }: TForm) => { - if (isSubmitting) return; - const sanitizedHeaders = headers.filter(({ key, value }) => Boolean(key) && Boolean(value)); - const streamHeaders = sanitizedHeaders.length ? sanitizedHeaders : undefined; - if (isEdit) { - await handleAuditLogStreamEdit({ headers: streamHeaders, url }); - return; - } - try { - await createAuditLogStream.mutateAsync({ - orgId, - headers: streamHeaders, - url - }); - createNotification({ - type: "success", - text: "Successfully created stream" - }); - onClose(); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: (err as Error)?.message ?? "Failed to create stream" - }); - } - }; - - if (isEdit && auditLogStream.isPending) { - return ( -
- -
- ); - } - - return ( -
-
- ( - - - - )} - /> - - {headerFields.fields.map(({ id: headerFieldId }, i) => ( -
- ( - - - - )} - /> - ( - - - - )} - /> - { - const header = getValues("headers"); - if (header && header?.length > 1) { - headerFields.remove(i); - } else { - setValue("headers", [{ key: "", value: "" }]); - } - }} - > - - -
- ))} -
- -
-
-
- - -
-
- ); -}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/AuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/AuditLogStreamForm.tsx new file mode 100644 index 000000000..1af5e8d1a --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/AuditLogStreamForm.tsx @@ -0,0 +1,125 @@ +import { createNotification } from "@app/components/notifications"; +import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams"; +import { useCreateAuditLogStream, useUpdateAuditLogStream } from "@app/hooks/api"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TAuditLogStream } from "@app/hooks/api/types"; +import { DiscriminativePick } from "@app/types"; + +import { AuditLogStreamHeader } from "../components/AuditLogStreamHeader"; +import { CustomProviderAuditLogStreamForm } from "./CustomProviderAuditLogStreamForm"; +import { DatadogProviderAuditLogStreamForm } from "./DatadogProviderAuditLogStreamForm"; +import { SplunkProviderAuditLogStreamForm } from "./SplunkProviderAuditLogStreamForm"; + +type FormProps = { + onComplete: (auditLogStream: TAuditLogStream) => void; +}; + +type CreateFormProps = FormProps & { provider: LogProvider }; +type UpdateFormProps = FormProps & { + auditLogStream: TAuditLogStream; +}; + +const CreateForm = ({ provider, onComplete }: CreateFormProps) => { + const createAuditLogStream = useCreateAuditLogStream(); + const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[provider]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const logStream = await createAuditLogStream.mutateAsync(formData); + createNotification({ + text: `Successfully created ${providerName} Log Stream`, + type: "success" + }); + onComplete(logStream); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to create ${providerName} Log Stream`, + text: err.message, + type: "error" + }); + } + }; + + switch (provider) { + case LogProvider.Custom: + return ; + case LogProvider.Datadog: + return ; + case LogProvider.Splunk: + return ; + default: + throw new Error(`Unhandled Provider: ${provider}`); + } +}; + +const UpdateForm = ({ auditLogStream, onComplete }: UpdateFormProps) => { + const updateAuditLogStream = useUpdateAuditLogStream(); + const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await updateAuditLogStream.mutateAsync({ + auditLogStreamId: auditLogStream.id, + ...formData + }); + createNotification({ + text: `Successfully updated ${providerName} Log Stream`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${providerName} Log Stream`, + text: err.message, + type: "error" + }); + } + }; + + switch (auditLogStream.provider) { + case LogProvider.Custom: + return ( + + ); + case LogProvider.Datadog: + return ( + + ); + case LogProvider.Splunk: + return ( + + ); + default: + throw new Error(`Unhandled Provider: ${(auditLogStream as TAuditLogStream).provider}`); + } +}; + +type Props = { onBack?: () => void } & Pick & + ( + | { provider: LogProvider; auditLogStream?: undefined } + | { provider?: undefined; auditLogStream: TAuditLogStream } + ); +export const AuditLogStreamForm = ({ onBack, ...props }: Props) => { + const { provider, auditLogStream } = props; + + return ( +
+ + {auditLogStream ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx new file mode 100644 index 000000000..258af14b9 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx @@ -0,0 +1,176 @@ +import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, FormLabel, IconButton, Input, ModalClose } from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TCustomProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/custom-provider"; + +type Props = { + auditLogStream?: TCustomProviderLogStream; + onSubmit: (formData: FormData) => void; +}; + +const formSchema = z.object({ + provider: z.literal(LogProvider.Custom), + credentials: z.object({ + url: z.string().url().trim().min(1).max(255), + headers: z + .object({ + key: z.string().min(1), + value: z.string().min(1) + }) + .array() + }) +}); + +type FormData = z.infer; + +export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const isUpdate = Boolean(auditLogStream); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: auditLogStream ?? { + provider: LogProvider.Custom + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty }, + getValues, + setValue + } = form; + + const headerFields = useFieldArray({ + control, + name: "credentials.headers" + }); + + return ( + +
+ ( + + + + )} + /> + + + {headerFields.fields.map(({ id: headerFieldId }, i) => ( +
+ ( + + + + )} + /> + ( + + { + if ( + auditLogStream && + auditLogStream.credentials.headers[i] && + auditLogStream.credentials.headers[i].value === "******" && + field.value === "******" + ) { + field.onChange(""); + } + e.target.type = "text"; + }} + onBlur={(e) => { + if ( + auditLogStream && + auditLogStream.credentials.headers[i] && + auditLogStream.credentials.headers[i].value === "******" && + field.value === "" + ) { + field.onChange("******"); + } + e.target.type = "password"; + }} + /> + + )} + /> + { + const header = getValues("credentials.headers"); + if (header && header?.length > 1) { + headerFields.remove(i); + } else { + setValue("credentials.headers", [{ key: "", value: "" }]); + } + }} + > + + +
+ ))} +
+ +
+ +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/DatadogProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/DatadogProviderAuditLogStreamForm.tsx new file mode 100644 index 000000000..2db315416 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/DatadogProviderAuditLogStreamForm.tsx @@ -0,0 +1,132 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TDatadogProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/datadog-provider"; + +type Props = { + auditLogStream?: TDatadogProviderLogStream; + onSubmit: (formData: FormData) => void; +}; + +const formSchema = z.object({ + provider: z.literal(LogProvider.Datadog), + credentials: z.object({ + url: z.string().url().trim().min(1).max(255), + token: z + .string() + .trim() + .regex(/^[a-fA-F0-9]{32}$/, "Invalid Datadog API key format") + }) +}); + +type FormData = z.infer; + +const DATADOG_ENDPOINTS = { + "Datadog US1": "https://http-intake.logs.datadoghq.com/api/v2/logs", + "Datadog US3": "https://http-intake.logs.us3.datadoghq.com/api/v2/logs", + "Datadog US5": "https://http-intake.logs.us5.datadoghq.com/api/v2/logs", + "Datadog EU": "https://http-intake.logs.datadoghq.eu/api/v2/logs", + "Datadog AP1": "https://http-intake.logs.ap1.datadoghq.com/api/v2/logs", + "Datadog AP2": "https://http-intake.logs.ap2.datadoghq.com/api/v2/logs", + "Datadog GovCloud (US1-FED)": "https://http-intake.logs.ddog-gov.com/api/v2/logs" +}; + +export const DatadogProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const isUpdate = Boolean(auditLogStream); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: auditLogStream ?? { + provider: LogProvider.Datadog, + credentials: { + url: DATADOG_ENDPOINTS["Datadog US1"] + } + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/SplunkProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/SplunkProviderAuditLogStreamForm.tsx new file mode 100644 index 000000000..c9d248d61 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/SplunkProviderAuditLogStreamForm.tsx @@ -0,0 +1,120 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, SecretInput } from "@app/components/v2"; +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TSplunkProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/splunk-provider"; + +type Props = { + auditLogStream?: TSplunkProviderLogStream; + onSubmit: (formData: FormData) => void; +}; + +const formSchema = z.object({ + provider: z.literal(LogProvider.Splunk), + credentials: z.object({ + hostname: z + .string() + .trim() + .min(1) + .max(255) + .superRefine((val, ctx) => { + if (val.includes("://")) { + ctx.addIssue({ + code: "custom", + message: "Hostname should not include protocol" + }); + return; + } + + try { + const url = new URL(`https://${val}`); + if (url.hostname !== val) { + ctx.addIssue({ + code: "custom", + message: "Must be a valid hostname without port or path" + }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid hostname" }); + } + }), + token: z.string().uuid().trim().min(1) + }) +}); + +type FormData = z.infer; + +export const SplunkProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const isUpdate = Boolean(auditLogStream); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: auditLogStream ?? { + provider: LogProvider.Splunk + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx index beb0f63db..b58d1a6eb 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx @@ -1,72 +1,24 @@ -import { faPlug, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { - Button, - DeleteActionModal, - EmptyState, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - THead, - Tr -} from "@app/components/v2"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - useOrganization, - useSubscription -} from "@app/context"; +import { Button } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; -import { useDeleteAuditLogStream, useGetAuditLogStreams } from "@app/hooks/api"; -import { AuditLogStreamForm } from "./AuditLogStreamForm"; +import { AuditLogStreamTable } from "./components/AuditLogStreamTable"; +import { AddAuditLogStreamModal } from "./components"; export const AuditLogStreamsTab = withPermission( () => { - const { currentOrg } = useOrganization(); - const orgId = currentOrg?.id || ""; - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "auditLogStreamForm", - "deleteAuditLogStream", - "upgradePlan" - ] as const); const { subscription } = useSubscription(); - const { data: auditLogStreams, isPending: isAuditLogStreamsLoading } = - useGetAuditLogStreams(orgId); - - // mutation - const { mutateAsync: deleteAuditLogStream } = useDeleteAuditLogStream(); - - const handleAuditLogStreamDelete = async () => { - try { - const auditLogStreamId = popUp?.deleteAuditLogStream?.data as string; - await deleteAuditLogStream({ - id: auditLogStreamId, - orgId - }); - handlePopUpClose("deleteAuditLogStream"); - createNotification({ - type: "success", - text: "Successfully deleted stream" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to delete stream" - }); - } - }; + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "auditLogStreamForm", + "upgradePlan" + ] as const); return (
@@ -84,8 +36,10 @@ export const AuditLogStreamsTab = withPermission( }} leftIcon={} isDisabled={!isAllowed} + variant="outline_bg" + colorSchema="secondary" > - Create + Add Log Stream )} @@ -93,102 +47,15 @@ export const AuditLogStreamsTab = withPermission(

Send audit logs from Infisical to external logging providers via HTTP

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

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

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

{url}

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

Loading options...

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

Infisical is constantly adding support for more providers.

+

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

+ + } + > +
+ + Don't see the third-party provider you're looking for? + + +
+ + } + count={filteredOptions.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + perPageList={[16]} + /> + )} */} +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/index.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/index.tsx new file mode 100644 index 000000000..69febb4a9 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/index.tsx @@ -0,0 +1 @@ +export * from "./AddAuditLogStreamModal"; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx index 137e765d2..e6f142644 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx @@ -15,7 +15,9 @@ import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext/types"; import { gatewaysQueryKeys } from "@app/hooks/api"; +import { useHasCustomMigrationAvailable } from "@app/hooks/api/migration"; import { useImportVault } from "@app/hooks/api/migration/mutations"; +import { ExternalMigrationProviders } from "@app/hooks/api/migration/types"; type Props = { id?: string; @@ -24,11 +26,13 @@ type Props = { enum VaultMappingType { KeyVault = "key-vault", - Namespace = "namespace" + Namespace = "namespace", + Custom = "custom" } const MAPPING_TYPE_MENU_ITEMS = [ { + isCustom: false, value: VaultMappingType.KeyVault, label: "Key Vaults", tooltip: ( @@ -48,6 +52,7 @@ const MAPPING_TYPE_MENU_ITEMS = [ ) }, { + isCustom: false, value: VaultMappingType.Namespace, label: "Namespaces", tooltip: ( @@ -63,10 +68,25 @@ const MAPPING_TYPE_MENU_ITEMS = [
) + }, + { + isCustom: true, + value: VaultMappingType.Custom, + label: "Custom Migration", + tooltip: ( +
+ Custom migrations allow you to shape your Vault migration to your specific needs. Please + contact our sales team to get started with custom migrations. +
+ ) } ]; export const VaultPlatformModal = ({ onClose }: Props) => { + const { data: isCustomMigrationAvailable } = useHasCustomMigrationAvailable( + ExternalMigrationProviders.Vault + ); + const formSchema = z.object({ vaultUrl: z.string().min(1), gatewayId: z.string().optional(), @@ -230,31 +250,44 @@ export const VaultPlatformModal = ({ onClose }: Props) => { errorText={error?.message} className="flex-1" > -
+
{MAPPING_TYPE_MENU_ITEMS.map((el) => (
field.onChange(el.value)} + onClick={() => { + if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return; + + field.onChange(el.value); + }} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return; + + field.onChange(el.value); + }} role="button" tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter") { - field.onChange(el.value); - } - }} >
{el.label}
{el.tooltip && (
- +
)} @@ -272,7 +305,7 @@ export const VaultPlatformModal = ({ onClose }: Props) => { isLoading={isLoading} isDisabled={!isDirty || isSubmitting || isLoading || !isValid} > - Import data + Import Data

- Enforce users to authenticate via Google to access this organization. + Enforce users to authenticate via Google OAuth SSO to access this organization.
- When this is enabled your organization members will only be able to login with Google. + When this is enabled your organization members will only be able to login with Google + SSO (not Google SAML).

diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 957091fe5..ad905c1f6 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -93,7 +93,7 @@ export const AccessApprovalRequest = ({ }) => { const [selectedRequest, setSelectedRequest] = useState< | (TAccessApprovalRequest & { - user: { firstName?: string; lastName?: string; email?: string } | null; + user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null; isRequestedByCurrentUser: boolean; isSelfApproveAllowed: boolean; isApprover: boolean; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index b1cd906bb..e7269298a 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -85,7 +85,7 @@ export const ReviewAccessRequestModal = ({ isOpen: boolean; onOpenChange: (isOpen: boolean) => void; request: TAccessApprovalRequest & { - user: { firstName?: string; lastName?: string; email?: string } | null; + user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null; isRequestedByCurrentUser: boolean; isSelfApproveAllowed: boolean; isApprover: boolean; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index d79a107db..a6ae7be62 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -29,6 +29,7 @@ import { useWorkspace } from "@app/context"; import { + ProjectPermissionCommitsActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; @@ -106,7 +107,7 @@ const Page = () => { }); const { permission } = useProjectPermission(); - const { mutateAsync: createCommit } = useCreateCommit(); + const { mutateAsync: createCommit, isPending: isCommitPending } = useCreateCommit(); const tableRef = useRef(null); @@ -213,6 +214,11 @@ const Page = () => { ProjectPermissionSub.SecretRollback ); + const canReadCommits = permission.can( + ProjectPermissionCommitsActions.Read, + ProjectPermissionSub.Commits + ); + const defaultFilterState = { tags: {}, searchFilter: (routerQueryParams.search as string) || "", @@ -377,7 +383,7 @@ const Page = () => { directory: secretPath, workspaceId, environment, - isPaused: !canDoReadRollback + isPaused: !canReadCommits }); const { @@ -997,7 +1003,7 @@ const Page = () => { environment={environment} workspaceId={workspaceId} secretPath={secretPath} - isCommitting={false} + isCommitting={isCommitPending} /> )} {noAccessSecretCount > 0 && } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx index 2a4f54c82..31cf18a65 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx @@ -75,7 +75,7 @@ type Props = { onCreateTag: () => void; environment: string; secretPath: string; - handleSecretShare: () => void; + onShareSecret: (sec: SecretV3RawSanitized) => void; importedBy?: { environment: { name: string; slug: string }; folders: { @@ -102,7 +102,7 @@ export const SecretItem = memo( onToggleSecretSelect, environment, secretPath, - handleSecretShare, + onShareSecret, importedBy, isPending, pendingAction, @@ -682,7 +682,7 @@ export const SecretItem = memo( variant="plain" size="md" ariaLabel="share-secret" - onClick={handleSecretShare} + onClick={() => onShareSecret(secret)} > { + pendingChangesRef.current = pendingChanges; + }, [pendingChanges]); + const handleSecretOperation = async ( operation: "create" | "update" | "delete", type: SecretType, @@ -337,7 +342,10 @@ export const SecretListView = ({ secretPath }); } else { - const trueOriginalSecret = getTrueOriginalSecret(orgSecret, pendingChanges.secrets); + const trueOriginalSecret = getTrueOriginalSecret( + orgSecret, + pendingChangesRef.current.secrets + ); const updateChange: PendingSecretUpdate = { id: orgSecret.id, @@ -442,15 +450,7 @@ export const SecretListView = ({ }); } }, - [ - environment, - secretPath, - isProtectedBranch, - isBatchMode, - workspaceId, - addPendingChange, - pendingChanges.secrets - ] + [environment, secretPath, isProtectedBranch, isBatchMode, workspaceId, addPendingChange] ); const handleSecretDelete = useCallback(async () => { @@ -535,6 +535,13 @@ export const SecretListView = ({ (sec: SecretV3RawSanitized) => handlePopUpOpen("secretDetail", sec), [] ); + const onShareSecret = useCallback( + (sec: SecretV3RawSanitized) => + handlePopUpOpen("createSharedSecret", { + value: sec.valueOverride ?? sec.value + }), + [] + ); return ( <> @@ -557,11 +564,7 @@ export const SecretListView = ({ onDetailViewSecret={onDetailViewSecret} importedBy={importedBy} onCreateTag={onCreateTag} - handleSecretShare={() => - handlePopUpOpen("createSharedSecret", { - value: secret.valueOverride ?? secret.value - }) - } + onShareSecret={onShareSecret} isPending={secret.isPending} pendingAction={secret.pendingAction} /> diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index c2f1f78a9..1f62ba7b9 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -47,15 +47,14 @@ import { Route as adminEnvironmentPageRouteImport } from './pages/admin/Environm import { Route as adminEncryptionPageRouteImport } from './pages/admin/EncryptionPage/route' import { Route as adminCachingPageRouteImport } from './pages/admin/CachingPage/route' import { Route as adminAuthenticationPageRouteImport } from './pages/admin/AuthenticationPage/route' +import { Route as adminAccessManagementPageRouteImport } from './pages/admin/AccessManagementPage/route' import { Route as organizationProjectsPageRouteImport } from './pages/organization/ProjectsPage/route' import { Route as organizationBillingPageRouteImport } from './pages/organization/BillingPage/route' import { Route as organizationAuditLogsPageRouteImport } from './pages/organization/AuditLogsPage/route' import { Route as organizationAccessManagementPageRouteImport } from './pages/organization/AccessManagementPage/route' import { Route as adminGeneralPageRouteImport } from './pages/admin/GeneralPage/route' import { Route as secretManagerRedirectsRedirectApprovalPageImport } from './pages/secret-manager/redirects/redirect-approval-page' -import { Route as adminUserIdentitiesResourcesPageRouteImport } from './pages/admin/UserIdentitiesResourcesPage/route' -import { Route as adminOrganizationResourcesPageRouteImport } from './pages/admin/OrganizationResourcesPage/route' -import { Route as adminMachineIdentitiesResourcesPageRouteImport } from './pages/admin/MachineIdentitiesResourcesPage/route' +import { Route as adminResourceOverviewPageRouteImport } from './pages/admin/ResourceOverviewPage/route' import { Route as organizationSecretSharingSettingsPageRouteImport } from './pages/organization/SecretSharingSettingsPage/route' import { Route as organizationRoleByIDPageRouteImport } from './pages/organization/RoleByIDPage/route' import { Route as organizationUserDetailsByIDPageRouteImport } from './pages/organization/UserDetailsByIDPage/route' @@ -606,6 +605,13 @@ const adminAuthenticationPageRouteRoute = getParentRoute: () => adminLayoutRoute, } as any) +const adminAccessManagementPageRouteRoute = + adminAccessManagementPageRouteImport.update({ + id: '/access-management', + path: '/access-management', + getParentRoute: () => adminLayoutRoute, + } as any) + const organizationProjectsPageRouteRoute = organizationProjectsPageRouteImport.update({ id: '/projects', @@ -693,24 +699,10 @@ const AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdRoute } as any, ) -const adminUserIdentitiesResourcesPageRouteRoute = - adminUserIdentitiesResourcesPageRouteImport.update({ - id: '/resources/user-identities', - path: '/resources/user-identities', - getParentRoute: () => adminLayoutRoute, - } as any) - -const adminOrganizationResourcesPageRouteRoute = - adminOrganizationResourcesPageRouteImport.update({ - id: '/resources/organizations', - path: '/resources/organizations', - getParentRoute: () => adminLayoutRoute, - } as any) - -const adminMachineIdentitiesResourcesPageRouteRoute = - adminMachineIdentitiesResourcesPageRouteImport.update({ - id: '/resources/machine-identities', - path: '/resources/machine-identities', +const adminResourceOverviewPageRouteRoute = + adminResourceOverviewPageRouteImport.update({ + id: '/resources/overview', + path: '/resources/overview', getParentRoute: () => adminLayoutRoute, } as any) @@ -2304,6 +2296,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof organizationProjectsPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationImport } + '/_authenticate/_inject-org-details/admin/_admin-layout/access-management': { + id: '/_authenticate/_inject-org-details/admin/_admin-layout/access-management' + path: '/access-management' + fullPath: '/admin/access-management' + preLoaderRoute: typeof adminAccessManagementPageRouteImport + parentRoute: typeof adminLayoutImport + } '/_authenticate/_inject-org-details/admin/_admin-layout/authentication': { id: '/_authenticate/_inject-org-details/admin/_admin-layout/authentication' path: '/authentication' @@ -2437,25 +2436,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof organizationSecretSharingSettingsPageRouteImport parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationSecretSharingImport } - '/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities': { - id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities' - path: '/resources/machine-identities' - fullPath: '/admin/resources/machine-identities' - preLoaderRoute: typeof adminMachineIdentitiesResourcesPageRouteImport - parentRoute: typeof adminLayoutImport - } - '/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations': { - id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations' - path: '/resources/organizations' - fullPath: '/admin/resources/organizations' - preLoaderRoute: typeof adminOrganizationResourcesPageRouteImport - parentRoute: typeof adminLayoutImport - } - '/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities': { - id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities' - path: '/resources/user-identities' - fullPath: '/admin/resources/user-identities' - preLoaderRoute: typeof adminUserIdentitiesResourcesPageRouteImport + '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview': { + id: '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview' + path: '/resources/overview' + fullPath: '/admin/resources/overview' + preLoaderRoute: typeof adminResourceOverviewPageRouteImport parentRoute: typeof adminLayoutImport } '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId': { @@ -4438,29 +4423,24 @@ const organizationLayoutRouteWithChildren = interface adminLayoutRouteChildren { adminGeneralPageRouteRoute: typeof adminGeneralPageRouteRoute + adminAccessManagementPageRouteRoute: typeof adminAccessManagementPageRouteRoute adminAuthenticationPageRouteRoute: typeof adminAuthenticationPageRouteRoute adminCachingPageRouteRoute: typeof adminCachingPageRouteRoute adminEncryptionPageRouteRoute: typeof adminEncryptionPageRouteRoute adminEnvironmentPageRouteRoute: typeof adminEnvironmentPageRouteRoute adminIntegrationsPageRouteRoute: typeof adminIntegrationsPageRouteRoute - adminMachineIdentitiesResourcesPageRouteRoute: typeof adminMachineIdentitiesResourcesPageRouteRoute - adminOrganizationResourcesPageRouteRoute: typeof adminOrganizationResourcesPageRouteRoute - adminUserIdentitiesResourcesPageRouteRoute: typeof adminUserIdentitiesResourcesPageRouteRoute + adminResourceOverviewPageRouteRoute: typeof adminResourceOverviewPageRouteRoute } const adminLayoutRouteChildren: adminLayoutRouteChildren = { adminGeneralPageRouteRoute: adminGeneralPageRouteRoute, + adminAccessManagementPageRouteRoute: adminAccessManagementPageRouteRoute, adminAuthenticationPageRouteRoute: adminAuthenticationPageRouteRoute, adminCachingPageRouteRoute: adminCachingPageRouteRoute, adminEncryptionPageRouteRoute: adminEncryptionPageRouteRoute, adminEnvironmentPageRouteRoute: adminEnvironmentPageRouteRoute, adminIntegrationsPageRouteRoute: adminIntegrationsPageRouteRoute, - adminMachineIdentitiesResourcesPageRouteRoute: - adminMachineIdentitiesResourcesPageRouteRoute, - adminOrganizationResourcesPageRouteRoute: - adminOrganizationResourcesPageRouteRoute, - adminUserIdentitiesResourcesPageRouteRoute: - adminUserIdentitiesResourcesPageRouteRoute, + adminResourceOverviewPageRouteRoute: adminResourceOverviewPageRouteRoute, } const adminLayoutRouteWithChildren = adminLayoutRoute._addFileChildren( @@ -4652,6 +4632,7 @@ export interface FileRoutesByFullPath { '/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute '/organization/billing': typeof organizationBillingPageRouteRoute '/organization/projects': typeof organizationProjectsPageRouteRoute + '/admin/access-management': typeof adminAccessManagementPageRouteRoute '/admin/authentication': typeof adminAuthenticationPageRouteRoute '/admin/caching': typeof adminCachingPageRouteRoute '/admin/encryption': typeof adminEncryptionPageRouteRoute @@ -4671,9 +4652,7 @@ export interface FileRoutesByFullPath { '/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute '/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute '/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute - '/admin/resources/machine-identities': typeof adminMachineIdentitiesResourcesPageRouteRoute - '/admin/resources/organizations': typeof adminOrganizationResourcesPageRouteRoute - '/admin/resources/user-identities': typeof adminUserIdentitiesResourcesPageRouteRoute + '/admin/resources/overview': typeof adminResourceOverviewPageRouteRoute '/projects/cert-management/$projectId': typeof certManagerLayoutRouteWithChildren '/projects/kms/$projectId': typeof kmsLayoutRouteWithChildren '/projects/secret-management/$projectId': typeof secretManagerLayoutRouteWithChildren @@ -4870,6 +4849,7 @@ export interface FileRoutesByTo { '/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute '/organization/billing': typeof organizationBillingPageRouteRoute '/organization/projects': typeof organizationProjectsPageRouteRoute + '/admin/access-management': typeof adminAccessManagementPageRouteRoute '/admin/authentication': typeof adminAuthenticationPageRouteRoute '/admin/caching': typeof adminCachingPageRouteRoute '/admin/encryption': typeof adminEncryptionPageRouteRoute @@ -4885,9 +4865,7 @@ export interface FileRoutesByTo { '/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute '/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute '/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute - '/admin/resources/machine-identities': typeof adminMachineIdentitiesResourcesPageRouteRoute - '/admin/resources/organizations': typeof adminOrganizationResourcesPageRouteRoute - '/admin/resources/user-identities': typeof adminUserIdentitiesResourcesPageRouteRoute + '/admin/resources/overview': typeof adminResourceOverviewPageRouteRoute '/projects/cert-management/$projectId': typeof certManagerLayoutRouteWithChildren '/projects/kms/$projectId': typeof kmsLayoutRouteWithChildren '/projects/secret-management/$projectId': typeof secretManagerLayoutRouteWithChildren @@ -5088,6 +5066,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/organization/audit-logs': typeof organizationAuditLogsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/billing': typeof organizationBillingPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/projects': typeof organizationProjectsPageRouteRoute + '/_authenticate/_inject-org-details/admin/_admin-layout/access-management': typeof adminAccessManagementPageRouteRoute '/_authenticate/_inject-org-details/admin/_admin-layout/authentication': typeof adminAuthenticationPageRouteRoute '/_authenticate/_inject-org-details/admin/_admin-layout/caching': typeof adminCachingPageRouteRoute '/_authenticate/_inject-org-details/admin/_admin-layout/encryption': typeof adminEncryptionPageRouteRoute @@ -5107,9 +5086,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId': typeof organizationUserDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId': typeof organizationRoleByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings': typeof organizationSecretSharingSettingsPageRouteRoute - '/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities': typeof adminMachineIdentitiesResourcesPageRouteRoute - '/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations': typeof adminOrganizationResourcesPageRouteRoute - '/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities': typeof adminUserIdentitiesResourcesPageRouteRoute + '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview': typeof adminResourceOverviewPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsKmsProjectIdRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutProjectsSecretManagementProjectIdRouteWithChildren @@ -5317,6 +5294,7 @@ export interface FileRouteTypes { | '/organization/audit-logs' | '/organization/billing' | '/organization/projects' + | '/admin/access-management' | '/admin/authentication' | '/admin/caching' | '/admin/encryption' @@ -5336,9 +5314,7 @@ export interface FileRouteTypes { | '/organization/members/$membershipId' | '/organization/roles/$roleId' | '/organization/secret-sharing/settings' - | '/admin/resources/machine-identities' - | '/admin/resources/organizations' - | '/admin/resources/user-identities' + | '/admin/resources/overview' | '/projects/cert-management/$projectId' | '/projects/kms/$projectId' | '/projects/secret-management/$projectId' @@ -5534,6 +5510,7 @@ export interface FileRouteTypes { | '/organization/audit-logs' | '/organization/billing' | '/organization/projects' + | '/admin/access-management' | '/admin/authentication' | '/admin/caching' | '/admin/encryption' @@ -5549,9 +5526,7 @@ export interface FileRouteTypes { | '/organization/members/$membershipId' | '/organization/roles/$roleId' | '/organization/secret-sharing/settings' - | '/admin/resources/machine-identities' - | '/admin/resources/organizations' - | '/admin/resources/user-identities' + | '/admin/resources/overview' | '/projects/cert-management/$projectId' | '/projects/kms/$projectId' | '/projects/secret-management/$projectId' @@ -5750,6 +5725,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/organization/audit-logs' | '/_authenticate/_inject-org-details/_org-layout/organization/billing' | '/_authenticate/_inject-org-details/_org-layout/organization/projects' + | '/_authenticate/_inject-org-details/admin/_admin-layout/access-management' | '/_authenticate/_inject-org-details/admin/_admin-layout/authentication' | '/_authenticate/_inject-org-details/admin/_admin-layout/caching' | '/_authenticate/_inject-org-details/admin/_admin-layout/encryption' @@ -5769,9 +5745,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId' | '/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId' | '/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings' - | '/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities' - | '/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations' - | '/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities' + | '/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview' | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId' | '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId' | '/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId' @@ -6195,14 +6169,13 @@ export const routeTree = rootRoute "parent": "/_authenticate/_inject-org-details/admin", "children": [ "/_authenticate/_inject-org-details/admin/_admin-layout/", + "/_authenticate/_inject-org-details/admin/_admin-layout/access-management", "/_authenticate/_inject-org-details/admin/_admin-layout/authentication", "/_authenticate/_inject-org-details/admin/_admin-layout/caching", "/_authenticate/_inject-org-details/admin/_admin-layout/encryption", "/_authenticate/_inject-org-details/admin/_admin-layout/environment", "/_authenticate/_inject-org-details/admin/_admin-layout/integrations", - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities", - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations", - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities" + "/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview" ] }, "/_authenticate/_inject-org-details/admin/_admin-layout/": { @@ -6225,6 +6198,10 @@ export const routeTree = rootRoute "filePath": "organization/ProjectsPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organization" }, + "/_authenticate/_inject-org-details/admin/_admin-layout/access-management": { + "filePath": "admin/AccessManagementPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" + }, "/_authenticate/_inject-org-details/admin/_admin-layout/authentication": { "filePath": "admin/AuthenticationPage/route.tsx", "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" @@ -6319,16 +6296,8 @@ export const routeTree = rootRoute "filePath": "organization/SecretSharingSettingsPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing" }, - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities": { - "filePath": "admin/MachineIdentitiesResourcesPage/route.tsx", - "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" - }, - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations": { - "filePath": "admin/OrganizationResourcesPage/route.tsx", - "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" - }, - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities": { - "filePath": "admin/UserIdentitiesResourcesPage/route.tsx", + "/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview": { + "filePath": "admin/ResourceOverviewPage/route.tsx", "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" }, "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId": { diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index cca823f21..70c34edc6 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -11,9 +11,8 @@ const adminRoute = route("/admin", [ route("/environment", "admin/EnvironmentPage/route.tsx"), route("/integrations", "admin/IntegrationsPage/route.tsx"), route("/caching", "admin/CachingPage/route.tsx"), - route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"), - route("/resources/user-identities", "admin/UserIdentitiesResourcesPage/route.tsx"), - route("/resources/machine-identities", "admin/MachineIdentitiesResourcesPage/route.tsx") + route("/resources/overview", "admin/ResourceOverviewPage/route.tsx"), + route("/access-management", "admin/AccessManagementPage/route.tsx") ]) ]); diff --git a/sink/redis-cluster/README.md b/sink/redis-cluster/README.md new file mode 100644 index 000000000..6f4a40a80 --- /dev/null +++ b/sink/redis-cluster/README.md @@ -0,0 +1,42 @@ +# Redis Cluster Setup + +## Quick Start + +1. **Update IP Address**: Replace `192.168.1.33` with your system's IP address in `docker-compose.yml`: + ```bash + # Find your IP + ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -1 + ``` + +2. **Start Cluster**: + ```bash + docker compose up -d + ``` + +3. **Verify Cluster**: + ```bash + docker exec redis-node-1 redis-cli -p 7001 cluster info + ``` + +## Connection Details + +- **Redis Cluster**: `YOUR_IP:7001`, `YOUR_IP:7002`, `YOUR_IP:7003` +- **RedisInsight UI**: `localhost:5540` + +## Clean Restart + +To completely reset the cluster: +```bash +docker compose down -v +# Update IP in docker-compose.yml if needed +docker compose up -d +``` + +## External Docker Compose Usage + +```yaml +environment: + - REDIS_CLUSTER_URLS=redis://YOUR_IP:7001,redis://YOUR_IP:7002,redis://YOUR_IP:7003 +``` + +**Important**: Always replace `YOUR_IP` with your actual system IP address. \ No newline at end of file diff --git a/sink/redis-cluster/docker-compose.yml b/sink/redis-cluster/docker-compose.yml new file mode 100644 index 000000000..05c7f7cc7 --- /dev/null +++ b/sink/redis-cluster/docker-compose.yml @@ -0,0 +1,87 @@ +version: "3.8" + +services: + redis-node-1: + image: redis:7 + container_name: redis-node-1 + ports: + - "7001:7001" + - "17001:17001" + volumes: + - redis-node-1-data:/data + command: > + redis-server + --port 7001 + --cluster-enabled yes + --cluster-config-file nodes.conf + --cluster-node-timeout 5000 + --appendonly yes + --bind 0.0.0.0 + --cluster-announce-ip 192.168.1.33 + --cluster-announce-port 7001 + --cluster-announce-bus-port 17001 + + redis-node-2: + image: redis:7 + container_name: redis-node-2 + ports: + - "7002:7002" + - "17002:17002" + volumes: + - redis-node-2-data:/data + command: > + redis-server + --port 7002 + --cluster-enabled yes + --cluster-config-file nodes.conf + --cluster-node-timeout 5000 + --appendonly yes + --bind 0.0.0.0 + --cluster-announce-ip 192.168.1.33 + --cluster-announce-port 7002 + --cluster-announce-bus-port 17002 + + redis-node-3: + image: redis:7 + container_name: redis-node-3 + ports: + - "7003:7003" + - "17003:17003" + volumes: + - redis-node-3-data:/data + command: > + redis-server + --port 7003 + --cluster-enabled yes + --cluster-config-file nodes.conf + --cluster-node-timeout 5000 + --appendonly yes + --bind 0.0.0.0 + --cluster-announce-ip 192.168.1.33 + --cluster-announce-port 7003 + --cluster-announce-bus-port 17003 + + redis-insight: + container_name: redis-insight + image: redis/redisinsight + ports: + - "5540:5540" + + redis-cluster-init: + image: redis:7 + depends_on: + - redis-node-1 + - redis-node-2 + - redis-node-3 + restart: "no" + command: > + sh -c " + sleep 10 + redis-cli --cluster create 192.168.1.33:7001 192.168.1.33:7002 192.168.1.33:7003 --cluster-yes + echo 'Cluster initialized' + " + +volumes: + redis-node-1-data: + redis-node-2-data: + redis-node-3-data: \ No newline at end of file