diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index 3fe0fbe21..1c16b00b3 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -83,7 +83,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} goreleaser: - runs-on: ubuntu-latest + runs-on: ubuntu-latest-8-cores needs: [cli-integration-tests] steps: - uses: actions/checkout@v3 diff --git a/backend/src/db/instance.ts b/backend/src/db/instance.ts index 3ce8148aa..b1ecf7d65 100644 --- a/backend/src/db/instance.ts +++ b/backend/src/db/instance.ts @@ -110,7 +110,8 @@ export const initAuditLogDbConnection = ({ }, migrations: { tableName: "infisical_migrations" - } + }, + pool: { min: 0, max: 10 } }); // we add these overrides so that auditLogDb and the primary DB are interchangeable diff --git a/backend/src/db/migrations/20250627010508_env-overrides.ts b/backend/src/db/migrations/20250627010508_env-overrides.ts new file mode 100644 index 000000000..535360a80 --- /dev/null +++ b/backend/src/db/migrations/20250627010508_env-overrides.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.binary("encryptedEnvOverrides").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides"); + if (hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("encryptedEnvOverrides"); + }); + } +} diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index de4975b20..b5e160096 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -34,7 +34,8 @@ export const SuperAdminSchema = z.object({ encryptedGitHubAppConnectionClientSecret: zodBuffer.nullable().optional(), encryptedGitHubAppConnectionSlug: zodBuffer.nullable().optional(), encryptedGitHubAppConnectionId: zodBuffer.nullable().optional(), - encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional() + encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional(), + encryptedEnvOverrides: zodBuffer.nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index ab9d1be6c..8d8cc4817 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -111,15 +111,38 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { params: z.object({ workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId) }), - querystring: z.object({ - eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), - userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), - startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), - endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), - offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), - limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), - actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) - }), + querystring: z + .object({ + eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }) + .superRefine((el, ctx) => { + if (el.endDate && el.startDate) { + const startDate = new Date(el.startDate); + const endDate = new Date(el.endDate); + const maxAllowedDate = new Date(startDate); + maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3); + if (endDate < startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date cannot be before start date" + }); + } + if (endDate > maxAllowedDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "Dates must be within 3 months" + }); + } + } + }), response: { 200: z.object({ auditLogs: AuditLogsSchema.omit({ @@ -161,7 +184,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { filter: { ...req.query, projectId: req.params.workspaceId, - endDate: req.query.endDate, + endDate: req.query.endDate || new Date().toISOString(), startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActorId: req.query.actor, eventType: req.query.eventType ? [req.query.eventType] : undefined diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 874460b36..2df779795 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -30,10 +30,10 @@ type TFindQuery = { actor?: string; projectId?: string; environment?: string; - orgId?: string; + orgId: string; eventType?: string; - startDate?: string; - endDate?: string; + startDate: string; + endDate: string; userAgentType?: string; limit?: number; offset?: number; @@ -61,18 +61,15 @@ export const auditLogDALFactory = (db: TDbClient) => { }, tx ) => { - if (!orgId && !projectId) { - throw new Error("Either orgId or projectId must be provided"); - } - try { // Find statements const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog) + .where(`${TableName.AuditLog}.orgId`, orgId) + .whereRaw(`"${TableName.AuditLog}"."createdAt" >= ?::timestamptz`, [startDate]) + .andWhereRaw(`"${TableName.AuditLog}"."createdAt" < ?::timestamptz`, [endDate]) // eslint-disable-next-line func-names .where(function () { - if (orgId) { - void this.where(`${TableName.AuditLog}.orgId`, orgId); - } else if (projectId) { + if (projectId) { void this.where(`${TableName.AuditLog}.projectId`, projectId); } }); @@ -135,14 +132,6 @@ export const auditLogDALFactory = (db: TDbClient) => { void sqlQuery.whereIn("eventType", eventType); } - // Filter by date range - if (startDate) { - void sqlQuery.whereRaw(`"${TableName.AuditLog}"."createdAt" >= ?::timestamptz`, [startDate]); - } - if (endDate) { - void sqlQuery.whereRaw(`"${TableName.AuditLog}"."createdAt" <= ?::timestamptz`, [endDate]); - } - // we timeout long running queries to prevent DB resource issues (2 minutes) const docs = await sqlQuery.timeout(1000 * 120); @@ -174,6 +163,8 @@ export const auditLogDALFactory = (db: TDbClient) => { try { const findExpiredLogSubQuery = (tx || db)(TableName.AuditLog) .where("expiresAt", "<", today) + .where("createdAt", "<", today) // to use audit log partition + .orderBy(`${TableName.AuditLog}.createdAt`, "desc") .select("id") .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index bf00a499a..333847734 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -67,7 +67,8 @@ export const auditLogServiceFactory = ({ secretPath: filter.secretPath, secretKey: filter.secretKey, environment: filter.environment, - ...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId }) + orgId: actorOrgId, + ...(filter.projectId ? { projectId: filter.projectId } : {}) }); return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({ diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index a2acb62c4..625dd6556 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -56,8 +56,8 @@ export type TListProjectAuditLogDTO = { eventType?: EventType[]; offset?: number; limit: number; - endDate?: string; - startDate?: string; + endDate: string; + startDate: string; projectId?: string; environment?: string; auditLogActorId?: string; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 938d50a77..417417c74 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -318,7 +318,7 @@ export const secretScanningV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 20, + workerCount: 2, pollingIntervalSeconds: 1 } ); @@ -539,7 +539,7 @@ export const secretScanningV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 20, + workerCount: 2, pollingIntervalSeconds: 1 } ); @@ -613,7 +613,7 @@ export const secretScanningV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 5, + workerCount: 2, pollingIntervalSeconds: 1 } ); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 145a50b23..b973ff5fd 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2272,6 +2272,10 @@ export const AppConnections = { BITBUCKET: { email: "The email used to access Bitbucket.", apiToken: "The API token used to access Bitbucket." + }, + ZABBIX: { + apiToken: "The API Token used to access Zabbix.", + instanceUrl: "The Zabbix instance URL to connect with." } } }; @@ -2461,6 +2465,12 @@ export const SecretSyncs = { CLOUDFLARE_PAGES: { projectName: "The name of the Cloudflare Pages project to sync secrets to.", environment: "The environment of the Cloudflare Pages project to sync secrets to." + }, + ZABBIX: { + scope: "The Zabbix scope that secrets should be synced to.", + hostId: "The ID of the Zabbix host to sync secrets to.", + hostName: "The name of the Zabbix host to sync secrets to.", + macroType: "The type of macro to sync secrets to. (0: Text, 1: Secret)" } } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8db5c16c4..2523c763c 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { QueueWorkerProfile } from "@app/lib/types"; +import { BadRequestError } from "../errors"; import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; @@ -341,8 +342,11 @@ const envSchema = z export type TEnvConfig = Readonly>; let envCfg: TEnvConfig; +let originalEnvConfig: TEnvConfig; export const getConfig = () => envCfg; +export const getOriginalConfig = () => originalEnvConfig; + // cannot import singleton logger directly as it needs config to load various transport export const initEnvConfig = (logger?: CustomLogger) => { const parsedEnv = envSchema.safeParse(process.env); @@ -352,10 +356,115 @@ export const initEnvConfig = (logger?: CustomLogger) => { process.exit(-1); } - envCfg = Object.freeze(parsedEnv.data); + const config = Object.freeze(parsedEnv.data); + envCfg = config; + + if (!originalEnvConfig) { + originalEnvConfig = config; + } + return envCfg; }; +// A list of environment variables that can be overwritten +export const overwriteSchema: { + [key: string]: { + name: string; + fields: { key: keyof TEnvConfig; description?: string }[]; + }; +} = { + azure: { + name: "Azure", + fields: [ + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_ID", + description: "The Application (Client) ID of your Azure application." + }, + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRET", + description: "The Client Secret of your Azure application." + } + ] + }, + google_sso: { + name: "Google SSO", + fields: [ + { + key: "CLIENT_ID_GOOGLE_LOGIN", + description: "The Client ID of your GCP OAuth2 application." + }, + { + key: "CLIENT_SECRET_GOOGLE_LOGIN", + description: "The Client Secret of your GCP OAuth2 application." + } + ] + }, + github_sso: { + name: "GitHub SSO", + fields: [ + { + key: "CLIENT_ID_GITHUB_LOGIN", + description: "The Client ID of your GitHub OAuth application." + }, + { + key: "CLIENT_SECRET_GITHUB_LOGIN", + description: "The Client Secret of your GitHub OAuth application." + } + ] + }, + gitlab_sso: { + name: "GitLab SSO", + fields: [ + { + key: "CLIENT_ID_GITLAB_LOGIN", + description: "The Client ID of your GitLab application." + }, + { + key: "CLIENT_SECRET_GITLAB_LOGIN", + description: "The Secret of your GitLab application." + }, + { + key: "CLIENT_GITLAB_LOGIN_URL", + description: + "The URL of your self-hosted instance of GitLab where the OAuth application is registered. If no URL is passed in, this will default to https://gitlab.com." + } + ] + } +}; + +export const overridableKeys = new Set( + Object.values(overwriteSchema).flatMap(({ fields }) => fields.map(({ key }) => key)) +); + +export const validateOverrides = (config: Record) => { + const allowedOverrides = Object.fromEntries( + Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input)) + ); + + const tempEnv: Record = { ...process.env, ...allowedOverrides }; + const parsedResult = envSchema.safeParse(tempEnv); + + if (!parsedResult.success) { + const errorDetails = parsedResult.error.issues + .map((issue) => `Key: "${issue.path.join(".")}", Error: ${issue.message}`) + .join("\n"); + throw new BadRequestError({ message: errorDetails }); + } +}; + +export const overrideEnvConfig = (config: Record) => { + const allowedOverrides = Object.fromEntries( + Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input)) + ); + + const tempEnv: Record = { ...process.env, ...allowedOverrides }; + const parsedResult = envSchema.safeParse(tempEnv); + + if (parsedResult.success) { + envCfg = Object.freeze(parsedResult.data); + } +}; + export const formatSmtpConfig = () => { const tlsOptions: { rejectUnauthorized: boolean; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0efe3f475..4704449fc 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -300,6 +300,7 @@ import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; import { registerV1Routes } from "./v1"; +import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; @@ -2046,6 +2047,16 @@ export const registerRoutes = async ( } } + const configSyncJob = await superAdminService.initializeEnvConfigSync(); + if (configSyncJob) { + cronJobs.push(configSyncJob); + } + + const oauthConfigSyncJob = await initializeOauthConfigSync(); + if (oauthConfigSyncJob) { + cronJobs.push(oauthConfigSyncJob); + } + server.decorate("store", { user: userDAL, kmipClient: kmipClientDAL diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index a21587db1..81e911621 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -8,7 +8,7 @@ import { SuperAdminSchema, UsersSchema } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; +import { getConfig, overridableKeys } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; @@ -42,7 +42,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { encryptedGitHubAppConnectionClientSecret: true, encryptedGitHubAppConnectionSlug: true, encryptedGitHubAppConnectionId: true, - encryptedGitHubAppConnectionPrivateKey: true + encryptedGitHubAppConnectionPrivateKey: true, + encryptedEnvOverrides: true }).extend({ isMigrationModeOn: z.boolean(), defaultAuthOrgSlug: z.string().nullable(), @@ -110,11 +111,14 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { .refine((content) => DOMPurify.sanitize(content) === content, { message: "Page frame content contains unsafe HTML." }) - .optional() + .optional(), + envOverrides: z.record(z.enum(Array.from(overridableKeys) as [string, ...string[]]), z.string()).optional() }), response: { 200: z.object({ - config: SuperAdminSchema.extend({ + config: SuperAdminSchema.omit({ + encryptedEnvOverrides: true + }).extend({ defaultAuthOrgSlug: z.string().nullable() }) }) @@ -381,6 +385,41 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/env-overrides", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.record( + z.string(), + z.object({ + name: z.string(), + fields: z + .object({ + key: z.string(), + value: z.string(), + hasEnvEntry: z.boolean(), + description: z.string().optional() + }) + .array() + }) + ) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const envOverrides = await server.services.superAdmin.getEnvOverridesOrganized(); + return envOverrides; + } + }); + server.route({ method: "DELETE", url: "/user-management/users/:userId", diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 8767c0680..35ec330e8 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -88,6 +88,7 @@ import { SanitizedWindmillConnectionSchema, WindmillConnectionListItemSchema } from "@app/services/app-connection/windmill"; +import { SanitizedZabbixConnectionSchema, ZabbixConnectionListItemSchema } from "@app/services/app-connection/zabbix"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -121,7 +122,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedFlyioConnectionSchema.options, ...SanitizedGitLabConnectionSchema.options, ...SanitizedCloudflareConnectionSchema.options, - ...SanitizedBitbucketConnectionSchema.options + ...SanitizedBitbucketConnectionSchema.options, + ...SanitizedZabbixConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -154,7 +156,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ FlyioConnectionListItemSchema, GitLabConnectionListItemSchema, CloudflareConnectionListItemSchema, - BitbucketConnectionListItemSchema + BitbucketConnectionListItemSchema, + ZabbixConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 0d33afb34..56005beac 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -30,6 +30,7 @@ import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; import { registerWindmillConnectionRouter } from "./windmill-connection-router"; +import { registerZabbixConnectionRouter } from "./zabbix-connection-router"; export * from "./app-connection-router"; @@ -64,5 +65,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Zabbix, + server, + sanitizedResponseSchema: SanitizedZabbixConnectionSchema, + createSchema: CreateZabbixConnectionSchema, + updateSchema: UpdateZabbixConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/hosts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + hostId: z.string(), + host: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const hosts = await server.services.appConnection.zabbix.listHosts(connectionId, req.permission); + return hosts; + } + }); +}; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index b3fceb201..e1669c784 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -113,52 +113,73 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.AuditLogs], description: "Get all audit logs for an organization", - querystring: z.object({ - projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), - environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment), - actorType: z.nativeEnum(ActorType).optional(), - secretPath: z - .string() - .optional() - .transform((val) => (!val ? val : removeTrailingSlash(val))) - .describe(AUDIT_LOGS.EXPORT.secretPath), - secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey), + querystring: z + .object({ + projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), + environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment), + actorType: z.nativeEnum(ActorType).optional(), + secretPath: z + .string() + .optional() + .transform((val) => (!val ? val : removeTrailingSlash(val))) + .describe(AUDIT_LOGS.EXPORT.secretPath), + secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey), + // eventType is split with , for multiple values, we need to transform it to array + eventType: z + .string() + .optional() + .transform((val) => (val ? val.split(",") : undefined)), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + eventMetadata: z + .string() + .optional() + .transform((val) => { + if (!val) { + return undefined; + } - // eventType is split with , for multiple values, we need to transform it to array - eventType: z - .string() - .optional() - .transform((val) => (val ? val.split(",") : undefined)), - userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), - eventMetadata: z - .string() - .optional() - .transform((val) => { - if (!val) { - return undefined; + const pairs = val.split(","); + + return pairs.reduce( + (acc, pair) => { + const [key, value] = pair.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + }) + .describe(AUDIT_LOGS.EXPORT.eventMetadata), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }) + .superRefine((el, ctx) => { + if (el.endDate && el.startDate) { + const startDate = new Date(el.startDate); + const endDate = new Date(el.endDate); + const maxAllowedDate = new Date(startDate); + maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3); + if (endDate < startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date cannot be before start date" + }); } - - const pairs = val.split(","); - - return pairs.reduce( - (acc, pair) => { - const [key, value] = pair.split("="); - if (key && value) { - acc[key] = value; - } - return acc; - }, - {} as Record - ); - }) - .describe(AUDIT_LOGS.EXPORT.eventMetadata), - startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), - endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), - offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), - limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), - actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) - }), - + if (endDate > maxAllowedDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "Dates must be within 3 months" + }); + } + } + }), response: { 200: z.object({ auditLogs: AuditLogsSchema.omit({ @@ -188,14 +209,13 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const auditLogs = await server.services.auditLog.listAuditLogs({ filter: { ...req.query, - endDate: req.query.endDate, + endDate: req.query.endDate || new Date().toISOString(), projectId: req.query.projectId, startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActorId: req.query.actor, actorType: req.query.actorType, eventType: req.query.eventType as EventType[] | undefined }, - actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 4675a1a40..67e1ac720 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -22,6 +22,7 @@ import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; import { registerWindmillSyncRouter } from "./windmill-sync-router"; +import { registerZabbixSyncRouter } from "./zabbix-sync-router"; export * from "./secret-sync-router"; @@ -47,5 +48,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/zabbix-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/zabbix-sync-router.ts new file mode 100644 index 000000000..cfd029623 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/zabbix-sync-router.ts @@ -0,0 +1,13 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { CreateZabbixSyncSchema, UpdateZabbixSyncSchema, ZabbixSyncSchema } from "@app/services/secret-sync/zabbix"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerZabbixSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Zabbix, + server, + responseSchema: ZabbixSyncSchema, + createSchema: CreateZabbixSyncSchema, + updateSchema: UpdateZabbixSyncSchema + }); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index b6b3cb8aa..5e2518362 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -9,6 +9,7 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; +import { CronJob } from "cron"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; import { Strategy as OAuth2Strategy } from "passport-oauth2"; @@ -25,27 +26,14 @@ import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; -export const registerSsoRouter = async (server: FastifyZodProvider) => { +const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); + +let serverInstance: FastifyZodProvider | null = null; + +export const registerOauthMiddlewares = (server: FastifyZodProvider) => { + serverInstance = server; const appCfg = getConfig(); - const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); - const redisStore = new RedisStore({ - client: server.redis, - prefix: "oauth-session:", - ttl: 600 // 10 minutes - }); - - await server.register(fastifySession, { - secret: appCfg.COOKIE_SECRET_SIGN_KEY, - store: redisStore, - cookie: { - secure: appCfg.HTTPS_ENABLED, - sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server - } - }); - await server.register(passport.initialize()); - await server.register(passport.secureSession()); - // passport oauth strategy for Google const isGoogleOauthActive = Boolean(appCfg.CLIENT_ID_GOOGLE_LOGIN && appCfg.CLIENT_SECRET_GOOGLE_LOGIN); if (isGoogleOauthActive) { @@ -176,6 +164,49 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { ) ); } +}; + +export const refreshOauthConfig = () => { + if (!serverInstance) { + logger.warn("Cannot refresh OAuth config: server instance not available"); + return; + } + + logger.info("Refreshing OAuth configuration..."); + registerOauthMiddlewares(serverInstance); +}; + +export const initializeOauthConfigSync = async () => { + logger.info("Setting up background sync process for oauth configuration"); + + // sync every 5 minutes + const job = new CronJob("*/5 * * * *", refreshOauthConfig); + job.start(); + + return job; +}; + +export const registerSsoRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + const redisStore = new RedisStore({ + client: server.redis, + prefix: "oauth-session:", + ttl: 600 // 10 minutes + }); + + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, + cookie: { + secure: appCfg.HTTPS_ENABLED, + sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server + } + }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + registerOauthMiddlewares(server); server.route({ url: "/redirect/google", diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 16acabc08..a0f4c7aac 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -28,7 +28,8 @@ export enum AppConnection { Flyio = "flyio", GitLab = "gitlab", Cloudflare = "cloudflare", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + Zabbix = "zabbix" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index f2a9a4734..c5c2b325f 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -110,6 +110,7 @@ import { validateWindmillConnectionCredentials, WindmillConnectionMethod } from "./windmill"; +import { getZabbixConnectionListItem, validateZabbixConnectionCredentials, ZabbixConnectionMethod } from "./zabbix"; export const listAppConnectionOptions = () => { return [ @@ -142,7 +143,8 @@ export const listAppConnectionOptions = () => { getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), - getBitbucketConnectionListItem() + getBitbucketConnectionListItem(), + getZabbixConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -223,7 +225,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -261,6 +264,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case OnePassConnectionMethod.ApiToken: case CloudflareConnectionMethod.APIToken: case BitbucketConnectionMethod.ApiToken: + case ZabbixConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -341,7 +345,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Flyio]: platformManagedCredentialsNotSupported, [AppConnection.GitLab]: platformManagedCredentialsNotSupported, [AppConnection.Cloudflare]: platformManagedCredentialsNotSupported, - [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported + [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported, + [AppConnection.Zabbix]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index cb4351fc7..cf5ed1a42 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -30,7 +30,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", - [AppConnection.Bitbucket]: "Bitbucket" + [AppConnection.Bitbucket]: "Bitbucket", + [AppConnection.Zabbix]: "Zabbix" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -63,5 +64,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -240,6 +247,7 @@ export type TAppConnectionInput = { id: string } & ( | TGitLabConnectionInput | TCloudflareConnectionInput | TBitbucketConnectionInput + | TZabbixConnectionInput ); export type TSqlConnectionInput = @@ -285,6 +293,7 @@ export type TAppConnectionConfig = | TGitLabConnectionConfig | TCloudflareConnectionConfig | TBitbucketConnectionConfig; + | TZabbixConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -316,7 +325,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateFlyioConnectionCredentialsSchema | TValidateGitLabConnectionCredentialsSchema | TValidateCloudflareConnectionCredentialsSchema - | TValidateBitbucketConnectionCredentialsSchema; + | TValidateBitbucketConnectionCredentialsSchema + | TValidateZabbixConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/zabbix/index.ts b/backend/src/services/app-connection/zabbix/index.ts new file mode 100644 index 000000000..0de17bde7 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/index.ts @@ -0,0 +1,4 @@ +export * from "./zabbix-connection-enums"; +export * from "./zabbix-connection-fns"; +export * from "./zabbix-connection-schemas"; +export * from "./zabbix-connection-types"; diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-enums.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-enums.ts new file mode 100644 index 000000000..690d7c609 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-enums.ts @@ -0,0 +1,3 @@ +export enum ZabbixConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-fns.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-fns.ts new file mode 100644 index 000000000..a34a6c381 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-fns.ts @@ -0,0 +1,108 @@ +import { AxiosError } from "axios"; +import RE2 from "re2"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { ZabbixConnectionMethod } from "./zabbix-connection-enums"; +import { + TZabbixConnection, + TZabbixConnectionConfig, + TZabbixHost, + TZabbixHostListResponse +} from "./zabbix-connection-types"; + +const TRAILING_SLASH_REGEX = new RE2("/+$"); + +export const getZabbixConnectionListItem = () => { + return { + name: "Zabbix" as const, + app: AppConnection.Zabbix as const, + methods: Object.values(ZabbixConnectionMethod) as [ZabbixConnectionMethod.ApiToken] + }; +}; + +export const validateZabbixConnectionCredentials = async (config: TZabbixConnectionConfig) => { + const { apiToken, instanceUrl } = config.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + try { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + const payload = { + jsonrpc: "2.0", + method: "authentication.get", + params: { + output: "extend" + }, + id: 1 + }; + + const response: { data: { error?: { message: string }; result?: string } } = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + if (response.data.error) { + throw new BadRequestError({ + message: response.data.error.message + }); + } + + return config.credentials; + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to connect to Zabbix instance: ${error.message}` + }); + } + throw error; + } +}; + +export const listZabbixHosts = async (appConnection: TZabbixConnection): Promise => { + const { apiToken, instanceUrl } = appConnection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + try { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + const payload = { + jsonrpc: "2.0", + method: "host.get", + params: { + output: ["hostid", "host"], + sortfield: "host", + sortorder: "ASC" + }, + id: 1 + }; + + const response: { data: TZabbixHostListResponse } = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + return response.data.result + ? response.data.result.map((host) => ({ + hostId: host.hostid, + host: host.host + })) + : []; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } +}; diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts new file mode 100644 index 000000000..23bffd859 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts @@ -0,0 +1,62 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { ZabbixConnectionMethod } from "./zabbix-connection-enums"; + +export const ZabbixConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z + .string() + .trim() + .min(1, "API Token required") + .max(1000) + .describe(AppConnections.CREDENTIALS.ZABBIX.apiToken), + instanceUrl: z.string().trim().url("Invalid Instance URL").describe(AppConnections.CREDENTIALS.ZABBIX.instanceUrl) +}); + +const BaseZabbixConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Zabbix) }); + +export const ZabbixConnectionSchema = BaseZabbixConnectionSchema.extend({ + method: z.literal(ZabbixConnectionMethod.ApiToken), + credentials: ZabbixConnectionApiTokenCredentialsSchema +}); + +export const SanitizedZabbixConnectionSchema = z.discriminatedUnion("method", [ + BaseZabbixConnectionSchema.extend({ + method: z.literal(ZabbixConnectionMethod.ApiToken), + credentials: ZabbixConnectionApiTokenCredentialsSchema.pick({ instanceUrl: true }) + }) +]); + +export const ValidateZabbixConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(ZabbixConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.Zabbix).method), + credentials: ZabbixConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Zabbix).credentials + ) + }) +]); + +export const CreateZabbixConnectionSchema = ValidateZabbixConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Zabbix) +); + +export const UpdateZabbixConnectionSchema = z + .object({ + credentials: ZabbixConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Zabbix).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Zabbix)); + +export const ZabbixConnectionListItemSchema = z.object({ + name: z.literal("Zabbix"), + app: z.literal(AppConnection.Zabbix), + methods: z.nativeEnum(ZabbixConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-service.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-service.ts new file mode 100644 index 000000000..e8c8f8018 --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listZabbixHosts } from "./zabbix-connection-fns"; +import { TZabbixConnection } from "./zabbix-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const zabbixConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listHosts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Zabbix, connectionId, actor); + + try { + const hosts = await listZabbixHosts(appConnection); + return hosts; + } catch (error) { + logger.error(error, "Failed to establish connection with zabbix"); + return []; + } + }; + + return { + listHosts + }; +}; diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-types.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-types.ts new file mode 100644 index 000000000..08b4c685f --- /dev/null +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-types.ts @@ -0,0 +1,33 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateZabbixConnectionSchema, + ValidateZabbixConnectionCredentialsSchema, + ZabbixConnectionSchema +} from "./zabbix-connection-schemas"; + +export type TZabbixConnection = z.infer; + +export type TZabbixConnectionInput = z.infer & { + app: AppConnection.Zabbix; +}; + +export type TValidateZabbixConnectionCredentialsSchema = typeof ValidateZabbixConnectionCredentialsSchema; + +export type TZabbixConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TZabbixHost = { + hostId: string; + host: string; +}; + +export type TZabbixHostListResponse = { + jsonrpc: string; + result: { hostid: string; host: string }[]; + error?: { message: string }; +}; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index deb4d0cb2..39926488f 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -47,7 +47,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); await secretDAL.pruneSecretReminders(queueService); - await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); await secretSharingDAL.pruneExpiredSharedSecrets(); @@ -58,6 +57,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretFolderVersionDAL.pruneExcessVersions(); await serviceTokenService.notifyExpiringTokens(); await orgService.notifyInvitedUsers(); + await auditLogDAL.pruneAuditLog(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 37a595daf..e06cfcb01 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; -import { TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; +import { TProjectEnvironments, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -469,15 +469,41 @@ export const secretFolderServiceFactory = ({ const $checkFolderPolicy = async ({ projectId, - environment, - parentId + env, + parentId, + idOrName }: { projectId: string; - environment: string; + env: TProjectEnvironments; parentId: string; + idOrName: string; }) => { + let targetFolder = await folderDAL + .findOne({ + envId: env.id, + name: idOrName, + parentId, + isReserved: false + }) + .catch(() => null); + + if (!targetFolder && uuidValidate(idOrName)) { + targetFolder = await folderDAL + .findOne({ + envId: env.id, + id: idOrName, + parentId, + isReserved: false + }) + .catch(() => null); + } + + if (!targetFolder) { + throw new NotFoundError({ message: `Target folder not found` }); + } + // get environment root folder (as it's needed to get all folders under it) - const rootFolder = await folderDAL.findBySecretPath(projectId, environment, "/"); + const rootFolder = await folderDAL.findBySecretPath(projectId, env.slug, "/"); if (!rootFolder) throw new NotFoundError({ message: `Root folder not found` }); // get all folders under environment root folder const folderPaths = await folderDAL.findByEnvsDeep({ parentIds: [rootFolder.id] }); @@ -492,7 +518,13 @@ export const secretFolderServiceFactory = ({ folderMap.get(normalizeKey(folder.parentId))?.push(folder); } - // Recursively collect all folders under the given parentId + // Find the target folder in the folderPaths to get its full details + const targetFolderWithPath = folderPaths.find((f) => f.id === targetFolder!.id); + if (!targetFolderWithPath) { + throw new NotFoundError({ message: `Target folder path not found` }); + } + + // Recursively collect all folders under the target folder (descendants only) const collectDescendants = ( id: string ): (TSecretFolders & { path: string; depth: number; environment: string })[] => { @@ -500,23 +532,31 @@ export const secretFolderServiceFactory = ({ return [...children, ...children.flatMap((child) => collectDescendants(child.id))]; }; - const foldersUnderParent = collectDescendants(parentId); + const targetFolderDescendants = collectDescendants(targetFolder.id); - const folderPolicyPaths = foldersUnderParent.map((folder) => ({ + // Include the target folder itself plus all its descendants + const foldersToCheck = [targetFolderWithPath, ...targetFolderDescendants]; + + const folderPolicyPaths = foldersToCheck.map((folder) => ({ path: folder.path, id: folder.id })); // get secrets under the given folders - const secrets = await secretV2BridgeDAL.findByFolderIds({ folderIds: folderPolicyPaths.map((p) => p.id) }); + const secrets = await secretV2BridgeDAL.findByFolderIds({ + folderIds: folderPolicyPaths.map((p) => p.id) + }); + for await (const folderPolicyPath of folderPolicyPaths) { // eslint-disable-next-line no-continue if (!secrets.some((s) => s.folderId === folderPolicyPath.id)) continue; + const policy = await secretApprovalPolicyService.getSecretApprovalPolicy( projectId, - environment, + env.slug, folderPolicyPath.path ); + // if there is a policy and there are secrets under the given folder, throw error if (policy) { throw new BadRequestError({ @@ -560,20 +600,42 @@ export const secretFolderServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - await $checkFolderPolicy({ projectId, environment, parentId: parentFolder.id }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + + let folderToDelete = await folderDAL + .findOne({ + envId: env.id, + name: idOrName, + parentId: parentFolder.id, + isReserved: false + }) + .catch(() => null); + + if (!folderToDelete && uuidValidate(idOrName)) { + folderToDelete = await folderDAL + .findOne({ + envId: env.id, + id: idOrName, + parentId: parentFolder.id, + isReserved: false + }) + .catch(() => null); + } + + if (!folderToDelete) { + throw new NotFoundError({ message: `Folder with ID '${idOrName}' not found` }); + } const [doc] = await folderDAL.delete( { envId: env.id, - [uuidValidate(idOrName) ? "id" : "name"]: idOrName, + id: folderToDelete.id, parentId: parentFolder.id, isReserved: false }, tx ); - if (!doc) throw new NotFoundError({ message: `Failed to delete folder with ID '${idOrName}', not found` }); - const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx); await folderCommitService.createCommit( diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index b70b37caf..62730c3da 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -20,7 +20,8 @@ export enum SecretSync { Render = "render", Flyio = "flyio", GitLab = "gitlab", - CloudflarePages = "cloudflare-pages" + CloudflarePages = "cloudflare-pages", + Zabbix = "zabbix" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 9d0513a2c..d058d355e 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -45,6 +45,7 @@ import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; +import { ZABBIX_SYNC_LIST_OPTION, ZabbixSyncFns } from "./zabbix"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -68,7 +69,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Render]: RENDER_SYNC_LIST_OPTION, [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION, [SecretSync.GitLab]: GITLAB_SYNC_LIST_OPTION, - [SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION + [SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION, + [SecretSync.Zabbix]: ZABBIX_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -236,6 +238,8 @@ export const SecretSyncFns = { return GitLabSyncFns.syncSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); case SecretSync.CloudflarePages: return CloudflarePagesSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Zabbix: + return ZabbixSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -328,6 +332,9 @@ export const SecretSyncFns = { case SecretSync.CloudflarePages: secretMap = await CloudflarePagesSyncFns.getSecrets(secretSync); break; + case SecretSync.Zabbix: + secretMap = await ZabbixSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -405,6 +412,8 @@ export const SecretSyncFns = { return GitLabSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); case SecretSync.CloudflarePages: return CloudflarePagesSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Zabbix: + return ZabbixSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 1dc0ea6c0..25df5d0b4 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -23,7 +23,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Render]: "Render", [SecretSync.Flyio]: "Fly.io", [SecretSync.GitLab]: "GitLab", - [SecretSync.CloudflarePages]: "Cloudflare Pages" + [SecretSync.CloudflarePages]: "Cloudflare Pages", + [SecretSync.Zabbix]: "Zabbix" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -48,7 +49,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, [SecretSync.GitLab]: AppConnection.GitLab, - [SecretSync.CloudflarePages]: AppConnection.Cloudflare + [SecretSync.CloudflarePages]: AppConnection.Cloudflare, + [SecretSync.Zabbix]: AppConnection.Zabbix }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -73,5 +75,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Render]: SecretSyncPlanType.Regular, [SecretSync.Flyio]: SecretSyncPlanType.Regular, [SecretSync.GitLab]: SecretSyncPlanType.Regular, - [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular + [SecretSync.CloudflarePages]: SecretSyncPlanType.Regular, + [SecretSync.Zabbix]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index a31183280..b076ea9c4 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -113,6 +113,7 @@ import { TTerraformCloudSyncWithCredentials } from "./terraform-cloud"; import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; +import { TZabbixSync, TZabbixSyncInput, TZabbixSyncListItem, TZabbixSyncWithCredentials } from "./zabbix"; export type TSecretSync = | TAwsParameterStoreSync @@ -136,7 +137,8 @@ export type TSecretSync = | TRenderSync | TFlyioSync | TGitLabSync - | TCloudflarePagesSync; + | TCloudflarePagesSync + | TZabbixSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -160,7 +162,8 @@ export type TSecretSyncWithCredentials = | TRenderSyncWithCredentials | TFlyioSyncWithCredentials | TGitLabSyncWithCredentials - | TCloudflarePagesSyncWithCredentials; + | TCloudflarePagesSyncWithCredentials + | TZabbixSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -184,7 +187,8 @@ export type TSecretSyncInput = | TRenderSyncInput | TFlyioSyncInput | TGitLabSyncInput - | TCloudflarePagesSyncInput; + | TCloudflarePagesSyncInput + | TZabbixSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -208,7 +212,8 @@ export type TSecretSyncListItem = | TRenderSyncListItem | TFlyioSyncListItem | TGitLabSyncListItem - | TCloudflarePagesSyncListItem; + | TCloudflarePagesSyncListItem + | TZabbixSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/zabbix/index.ts b/backend/src/services/secret-sync/zabbix/index.ts new file mode 100644 index 000000000..a49d8e14c --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/index.ts @@ -0,0 +1,5 @@ +export * from "./zabbix-sync-constants"; +export * from "./zabbix-sync-enums"; +export * from "./zabbix-sync-fns"; +export * from "./zabbix-sync-schemas"; +export * from "./zabbix-sync-types"; diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-constants.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-constants.ts new file mode 100644 index 000000000..51c1ca793 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const ZABBIX_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Zabbix", + destination: SecretSync.Zabbix, + connection: AppConnection.Zabbix, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-enums.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-enums.ts new file mode 100644 index 000000000..8f4c8c5d6 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-enums.ts @@ -0,0 +1,4 @@ +export enum ZabbixSyncScope { + Global = "global", + Host = "host" +} diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-fns.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-fns.ts new file mode 100644 index 000000000..18f9061ec --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-fns.ts @@ -0,0 +1,285 @@ +import RE2 from "re2"; + +import { request } from "@app/lib/config/request"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { + TZabbixSecret, + TZabbixSyncWithCredentials, + ZabbixApiResponse, + ZabbixMacroCreateResponse, + ZabbixMacroDeleteResponse +} from "@app/services/secret-sync/zabbix/zabbix-sync-types"; + +import { ZabbixSyncScope } from "./zabbix-sync-enums"; + +const TRAILING_SLASH_REGEX = new RE2("/+$"); +const MACRO_START_REGEX = new RE2("^\\{\\$"); +const MACRO_END_REGEX = new RE2("\\}$"); + +const extractMacroKey = (macro: string): string => { + return macro.replace(MACRO_START_REGEX, "").replace(MACRO_END_REGEX, ""); +}; + +// Helper function to handle Zabbix API responses and errors +const handleZabbixResponse = (response: ZabbixApiResponse): T => { + if (response.data.error) { + const errorMessage = response.data.error.data + ? `${response.data.error.message}: ${response.data.error.data}` + : response.data.error.message; + throw new SecretSyncError({ + error: new Error(`Zabbix API Error (${response.data.error.code}): ${errorMessage}`) + }); + } + + if (response.data.result === undefined) { + throw new SecretSyncError({ + error: new Error("Zabbix API returned no result") + }); + } + + return response.data.result; +}; + +const listZabbixSecrets = async (apiToken: string, instanceUrl: string, hostId?: string): Promise => { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + // - jsonrpc: Specifies the JSON-RPC protocol version. + // - method: The API method to call, in this case "usermacro.get" for retrieving user macros. + // - id: A unique identifier for the request. Required by JSON-RPC but not used by the API for logic. Typically set to any integer. + const payload = { + jsonrpc: "2.0" as const, + method: "usermacro.get", + params: hostId ? { output: "extend", hostids: hostId } : { output: "extend", globalmacro: true }, + id: 1 + }; + + try { + const response: ZabbixApiResponse = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + return handleZabbixResponse(response) || []; + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to list Zabbix secrets") + }); + } +}; + +const putZabbixSecrets = async ( + apiToken: string, + instanceUrl: string, + secretMap: TSecretMap, + destinationConfig: TZabbixSyncWithCredentials["destinationConfig"], + existingSecrets: TZabbixSecret[] +): Promise => { + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + + const existingMacroMap = new Map(existingSecrets.map((secret) => [secret.macro, secret])); + + for (const [key, secret] of Object.entries(secretMap)) { + const macroKey = `{$${key.toUpperCase()}}`; + const existingMacro = existingMacroMap.get(macroKey); + + try { + if (existingMacro) { + // Update existing macro + const updatePayload = { + jsonrpc: "2.0" as const, + method: hostId ? "usermacro.update" : "usermacro.updateglobal", + params: { + [hostId ? "hostmacroid" : "globalmacroid"]: existingMacro[hostId ? "hostmacroid" : "globalmacroid"], + value: secret.value, + type: destinationConfig.macroType, + description: secret.comment + }, + id: 1 + }; + + // eslint-disable-next-line no-await-in-loop + const response: ZabbixApiResponse = await request.post(apiUrl, updatePayload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + handleZabbixResponse(response); + } else { + // Create new macro + const createPayload = { + jsonrpc: "2.0" as const, + method: hostId ? "usermacro.create" : "usermacro.createglobal", + params: hostId + ? { + hostid: hostId, + macro: macroKey, + value: secret.value, + type: destinationConfig.macroType, + description: secret.comment + } + : { + macro: macroKey, + value: secret.value, + type: destinationConfig.macroType, + description: secret.comment + }, + id: 1 + }; + + // eslint-disable-next-line no-await-in-loop + const response: ZabbixApiResponse = await request.post(apiUrl, createPayload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + handleZabbixResponse(response); + } + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error(`Failed to sync secret ${key}`) + }); + } + } +}; + +const deleteZabbixSecrets = async ( + apiToken: string, + instanceUrl: string, + keys: string[], + hostId?: string +): Promise => { + if (keys.length === 0) return; + + const apiUrl = `${instanceUrl.replace(TRAILING_SLASH_REGEX, "")}/api_jsonrpc.php`; + + try { + // Get existing macros to find their IDs + const existingSecrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + const macroIds = existingSecrets + .filter((secret) => keys.includes(secret.macro)) + .map((secret) => secret[hostId ? "hostmacroid" : "globalmacroid"]) + .filter(Boolean); + + if (macroIds.length === 0) return; + + const payload = { + jsonrpc: "2.0" as const, + method: hostId ? "usermacro.delete" : "usermacro.deleteglobal", + params: macroIds, + id: 1 + }; + + const response: ZabbixApiResponse = await request.post(apiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}` + } + }); + + handleZabbixResponse(response); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to delete Zabbix secrets") + }); + } +}; + +export const ZabbixSyncFns = { + syncSecrets: async (secretSync: TZabbixSyncWithCredentials, secretMap: TSecretMap) => { + const { connection, environment, destinationConfig } = secretSync; + const { apiToken, instanceUrl } = connection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + let secrets: TZabbixSecret[] = []; + try { + secrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to list Zabbix secrets") + }); + } + + try { + await putZabbixSecrets(apiToken, instanceUrl, secretMap, destinationConfig, secrets); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to sync secrets") + }); + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + try { + const shapedSecretMapKeys = Object.keys(secretMap).map((key) => key.toUpperCase()); + + const keys = secrets + .filter( + (secret) => + matchesSchema(secret.macro, environment?.slug || "", secretSync.syncOptions.keySchema) && + !shapedSecretMapKeys.includes(extractMacroKey(secret.macro)) + ) + .map((secret) => secret.macro); + + await deleteZabbixSecrets(apiToken, instanceUrl, keys, hostId); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to delete orphaned secrets") + }); + } + }, + + removeSecrets: async (secretSync: TZabbixSyncWithCredentials, secretMap: TSecretMap) => { + const { connection, destinationConfig } = secretSync; + const { apiToken, instanceUrl } = connection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + + try { + const secrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + + const shapedSecretMapKeys = Object.keys(secretMap).map((key) => key.toUpperCase()); + const keys = secrets + .filter((secret) => shapedSecretMapKeys.includes(extractMacroKey(secret.macro))) + .map((secret) => secret.macro); + + await deleteZabbixSecrets(apiToken, instanceUrl, keys, hostId); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to remove secrets") + }); + } + }, + + getSecrets: async (secretSync: TZabbixSyncWithCredentials) => { + const { connection, destinationConfig } = secretSync; + const { apiToken, instanceUrl } = connection.credentials; + await blockLocalAndPrivateIpAddresses(instanceUrl); + const hostId = destinationConfig.scope === ZabbixSyncScope.Host ? destinationConfig.hostId : undefined; + + try { + const secrets = await listZabbixSecrets(apiToken, instanceUrl, hostId); + return Object.fromEntries( + secrets.map((secret) => [ + extractMacroKey(secret.macro), + { value: secret.value ?? "", comment: secret.description } + ]) + ); + } catch (error) { + throw new SecretSyncError({ + error: error instanceof Error ? error : new Error("Failed to get secrets") + }); + } + } +}; diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts new file mode 100644 index 000000000..94a729cb6 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { ZabbixSyncScope } from "./zabbix-sync-enums"; + +const ZabbixSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(ZabbixSyncScope.Host).describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.scope), + hostId: z.string().trim().min(1, "Host required").max(255).describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.hostId), + hostName: z + .string() + .trim() + .min(1, "Host name required") + .max(255) + .describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.hostName), + macroType: z + .number() + .min(0, "Macro type required") + .max(1, "Macro type required") + .describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.macroType) + }), + z.object({ + scope: z.literal(ZabbixSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.scope), + macroType: z + .number() + .min(0, "Macro type required") + .max(1, "Macro type required") + .describe(SecretSyncs.DESTINATION_CONFIG.ZABBIX.macroType) + }) +]); + +const ZabbixSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const ZabbixSyncSchema = BaseSecretSyncSchema(SecretSync.Zabbix, ZabbixSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Zabbix), + destinationConfig: ZabbixSyncDestinationConfigSchema +}); + +export const CreateZabbixSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Zabbix, + ZabbixSyncOptionsConfig +).extend({ + destinationConfig: ZabbixSyncDestinationConfigSchema +}); + +export const UpdateZabbixSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Zabbix, + ZabbixSyncOptionsConfig +).extend({ + destinationConfig: ZabbixSyncDestinationConfigSchema.optional() +}); + +export const ZabbixSyncListItemSchema = z.object({ + name: z.literal("Zabbix"), + connection: z.literal(AppConnection.Zabbix), + destination: z.literal(SecretSync.Zabbix), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-types.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-types.ts new file mode 100644 index 000000000..9640394d9 --- /dev/null +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-types.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { TZabbixConnection } from "@app/services/app-connection/zabbix"; + +import { CreateZabbixSyncSchema, ZabbixSyncListItemSchema, ZabbixSyncSchema } from "./zabbix-sync-schemas"; + +export type TZabbixSync = z.infer; +export type TZabbixSyncInput = z.infer; +export type TZabbixSyncListItem = z.infer; + +export type TZabbixSyncWithCredentials = TZabbixSync & { + connection: TZabbixConnection; +}; + +export type TZabbixSecret = { + macro: string; + value: string; + description?: string; + globalmacroid?: string; + hostmacroid?: string; + hostid?: string; + type: number; + automatic?: string; +}; + +export interface ZabbixApiResponse { + data: { + jsonrpc: "2.0"; + result?: T; + error?: { + code: number; + message: string; + data?: string; + }; + id: number; + }; +} + +export interface ZabbixMacroCreateResponse { + hostmacroids?: string[]; + globalmacroids?: string[]; +} + +export interface ZabbixMacroUpdateResponse { + hostmacroids?: string[]; + globalmacroids?: string[]; +} + +export interface ZabbixMacroDeleteResponse { + hostmacroids?: string[]; + globalmacroids?: string[]; +} + +export enum ZabbixMacroType { + TEXT = 0, + SECRET = 1 +} + +export interface ZabbixMacroInput { + hostid?: string; + macro: string; + value: string; + description?: string; + type?: ZabbixMacroType; + automatic?: "0" | "1"; +} + +export interface ZabbixMacroUpdate { + hostmacroid?: string; + globalmacroid?: string; + value?: string; + description?: string; + type?: ZabbixMacroType; + automatic?: "0" | "1"; +} diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 8a0d4dd14..2ca7a0c33 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -5,7 +5,13 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; -import { getConfig } from "@app/lib/config/env"; +import { + getConfig, + getOriginalConfig, + overrideEnvConfig, + overwriteSchema, + validateOverrides +} from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -33,6 +39,7 @@ import { TInvalidateCacheQueueFactory } from "./invalidate-cache-queue"; import { TSuperAdminDALFactory } from "./super-admin-dal"; import { CacheType, + EnvOverrides, LoginMethod, TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, @@ -234,6 +241,45 @@ export const superAdminServiceFactory = ({ adminIntegrationsConfig = config; }; + const getEnvOverrides = async () => { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + + if (!serverCfg || !serverCfg.encryptedEnvOverrides) { + return {}; + } + + const decrypt = kmsService.decryptWithRootKey(); + + const overrides = JSON.parse(decrypt(serverCfg.encryptedEnvOverrides).toString()) as Record; + + return overrides; + }; + + const getEnvOverridesOrganized = async (): Promise => { + const overrides = await getEnvOverrides(); + const ogConfig = getOriginalConfig(); + + return Object.fromEntries( + Object.entries(overwriteSchema).map(([groupKey, groupDef]) => [ + groupKey, + { + name: groupDef.name, + fields: groupDef.fields.map(({ key, description }) => ({ + key, + description, + value: overrides[key] || "", + hasEnvEntry: !!(ogConfig as unknown as Record)[key] + })) + } + ]) + ); + }; + + const $syncEnvConfig = async () => { + const config = await getEnvOverrides(); + overrideEnvConfig(config); + }; + const updateServerCfg = async ( data: TSuperAdminUpdate & { slackClientId?: string; @@ -246,6 +292,7 @@ export const superAdminServiceFactory = ({ gitHubAppConnectionSlug?: string; gitHubAppConnectionId?: string; gitHubAppConnectionPrivateKey?: string; + envOverrides?: Record; }, userId: string ) => { @@ -374,6 +421,17 @@ export const superAdminServiceFactory = ({ gitHubAppConnectionSettingsUpdated = true; } + let envOverridesUpdated = false; + if (data.envOverrides !== undefined) { + // Verify input format + validateOverrides(data.envOverrides); + + const encryptedEnvOverrides = encryptWithRoot(Buffer.from(JSON.stringify(data.envOverrides))); + updatedData.encryptedEnvOverrides = encryptedEnvOverrides; + updatedData.envOverrides = undefined; + envOverridesUpdated = true; + } + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); @@ -382,6 +440,10 @@ export const superAdminServiceFactory = ({ await $syncAdminIntegrationConfig(); } + if (envOverridesUpdated) { + await $syncEnvConfig(); + } + if ( updatedServerCfg.encryptedMicrosoftTeamsAppId && updatedServerCfg.encryptedMicrosoftTeamsClientSecret && @@ -814,6 +876,18 @@ export const superAdminServiceFactory = ({ return job; }; + const initializeEnvConfigSync = async () => { + logger.info("Setting up background sync process for environment overrides"); + + await $syncEnvConfig(); + + // sync every 5 minutes + const job = new CronJob("*/5 * * * *", $syncEnvConfig); + job.start(); + + return job; + }; + return { initServerCfg, updateServerCfg, @@ -833,6 +907,9 @@ export const superAdminServiceFactory = ({ getOrganizations, deleteOrganization, deleteOrganizationMembership, - initializeAdminIntegrationConfigSync + initializeAdminIntegrationConfigSync, + initializeEnvConfigSync, + getEnvOverrides, + getEnvOverridesOrganized }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 205c59f2c..b57a015a4 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -1,3 +1,5 @@ +import { TEnvConfig } from "@app/lib/config/env"; + export type TAdminSignUpDTO = { email: string; password: string; @@ -74,3 +76,10 @@ export type TAdminIntegrationConfig = { privateKey: string; }; }; + +export interface EnvOverrides { + [key: string]: { + name: string; + fields: { key: keyof TEnvConfig; value: string; hasEnvEntry: boolean; description?: string }[]; + }; +} diff --git a/docs/api-reference/endpoints/app-connections/zabbix/available.mdx b/docs/api-reference/endpoints/app-connections/zabbix/available.mdx new file mode 100644 index 000000000..49488471e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/zabbix/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/create.mdx b/docs/api-reference/endpoints/app-connections/zabbix/create.mdx new file mode 100644 index 000000000..a11b01309 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/zabbix" +--- + + + Check out the configuration docs for [Zabbix Connections](/integrations/app-connections/zabbix) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/zabbix/delete.mdx b/docs/api-reference/endpoints/app-connections/zabbix/delete.mdx new file mode 100644 index 000000000..95b34e814 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/zabbix/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/zabbix/get-by-id.mdx new file mode 100644 index 000000000..46306b035 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/zabbix/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/zabbix/get-by-name.mdx new file mode 100644 index 000000000..692c69fc7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/zabbix/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/list.mdx b/docs/api-reference/endpoints/app-connections/zabbix/list.mdx new file mode 100644 index 000000000..bc1c7df2b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/zabbix" +--- diff --git a/docs/api-reference/endpoints/app-connections/zabbix/update.mdx b/docs/api-reference/endpoints/app-connections/zabbix/update.mdx new file mode 100644 index 000000000..aa408c9ee --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/zabbix/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/zabbix/{connectionId}" +--- + + + Check out the configuration docs for [Zabbix Connections](/integrations/app-connections/zabbix) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/create.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/create.mdx new file mode 100644 index 000000000..1d15bd7d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/zabbix" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/delete.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/delete.mdx new file mode 100644 index 000000000..dc7345298 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/zabbix/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-id.mdx new file mode 100644 index 000000000..79e787d04 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/zabbix/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-name.mdx new file mode 100644 index 000000000..2b364c699 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/zabbix/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/import-secrets.mdx new file mode 100644 index 000000000..716f3c8d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/zabbix/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/list.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/list.mdx new file mode 100644 index 000000000..d6d4bdef5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/zabbix" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/remove-secrets.mdx new file mode 100644 index 000000000..8fb422544 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/zabbix/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/sync-secrets.mdx new file mode 100644 index 000000000..9751ad721 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/zabbix/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/zabbix/update.mdx b/docs/api-reference/endpoints/secret-syncs/zabbix/update.mdx new file mode 100644 index 000000000..ea7582143 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/zabbix/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/zabbix/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index ac9d62a7c..015ecb3c4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -490,7 +490,8 @@ "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", "integrations/app-connections/vercel", - "integrations/app-connections/windmill" + "integrations/app-connections/windmill", + "integrations/app-connections/zabbix" ] } ] @@ -523,7 +524,8 @@ "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", - "integrations/secret-syncs/windmill" + "integrations/secret-syncs/windmill", + "integrations/secret-syncs/zabbix" ] } ] @@ -1521,6 +1523,18 @@ "api-reference/endpoints/app-connections/windmill/update", "api-reference/endpoints/app-connections/windmill/delete" ] + }, + { + "group": "Zabbix", + "pages": [ + "api-reference/endpoints/app-connections/zabbix/list", + "api-reference/endpoints/app-connections/zabbix/available", + "api-reference/endpoints/app-connections/zabbix/get-by-id", + "api-reference/endpoints/app-connections/zabbix/get-by-name", + "api-reference/endpoints/app-connections/zabbix/create", + "api-reference/endpoints/app-connections/zabbix/update", + "api-reference/endpoints/app-connections/zabbix/delete" + ] } ] }, @@ -1827,6 +1841,20 @@ "api-reference/endpoints/secret-syncs/windmill/import-secrets", "api-reference/endpoints/secret-syncs/windmill/remove-secrets" ] + }, + { + "group": "Zabbix", + "pages": [ + "api-reference/endpoints/secret-syncs/zabbix/list", + "api-reference/endpoints/secret-syncs/zabbix/get-by-id", + "api-reference/endpoints/secret-syncs/zabbix/get-by-name", + "api-reference/endpoints/secret-syncs/zabbix/create", + "api-reference/endpoints/secret-syncs/zabbix/update", + "api-reference/endpoints/secret-syncs/zabbix/delete", + "api-reference/endpoints/secret-syncs/zabbix/sync-secrets", + "api-reference/endpoints/secret-syncs/zabbix/import-secrets", + "api-reference/endpoints/secret-syncs/zabbix/remove-secrets" + ] } ] }, diff --git a/docs/images/app-connections/zabbix/zabbix-api-token-form.png b/docs/images/app-connections/zabbix/zabbix-api-token-form.png new file mode 100644 index 000000000..471ecc4a4 Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-api-token-form.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-api-token-generated.png b/docs/images/app-connections/zabbix/zabbix-api-token-generated.png new file mode 100644 index 000000000..f05dd279e Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-api-token-generated.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-api-token-list.png b/docs/images/app-connections/zabbix/zabbix-api-token-list.png new file mode 100644 index 000000000..d549cc576 Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-api-token-list.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-app-connection-form.png b/docs/images/app-connections/zabbix/zabbix-app-connection-form.png new file mode 100644 index 000000000..90ac10f5f Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-app-connection-form.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-app-connection-generated.png b/docs/images/app-connections/zabbix/zabbix-app-connection-generated.png new file mode 100644 index 000000000..87cf99a20 Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-app-connection-generated.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-app-connection-option.png b/docs/images/app-connections/zabbix/zabbix-app-connection-option.png new file mode 100644 index 000000000..4cd01571c Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-app-connection-option.png differ diff --git a/docs/images/app-connections/zabbix/zabbix-dashboard.png b/docs/images/app-connections/zabbix/zabbix-dashboard.png new file mode 100644 index 000000000..1f80f2e1b Binary files /dev/null and b/docs/images/app-connections/zabbix/zabbix-dashboard.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-destination.png b/docs/images/secret-syncs/zabbix/configure-destination.png new file mode 100644 index 000000000..deb9ad993 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-destination.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-details.png b/docs/images/secret-syncs/zabbix/configure-details.png new file mode 100644 index 000000000..c454de858 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-details.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-source.png b/docs/images/secret-syncs/zabbix/configure-source.png new file mode 100644 index 000000000..82b2630b4 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-source.png differ diff --git a/docs/images/secret-syncs/zabbix/configure-sync-options.png b/docs/images/secret-syncs/zabbix/configure-sync-options.png new file mode 100644 index 000000000..ad54ce8ad Binary files /dev/null and b/docs/images/secret-syncs/zabbix/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/zabbix/review-configuration.png b/docs/images/secret-syncs/zabbix/review-configuration.png new file mode 100644 index 000000000..25b0fbf8f Binary files /dev/null and b/docs/images/secret-syncs/zabbix/review-configuration.png differ diff --git a/docs/images/secret-syncs/zabbix/select-option.png b/docs/images/secret-syncs/zabbix/select-option.png new file mode 100644 index 000000000..9ebf248a0 Binary files /dev/null and b/docs/images/secret-syncs/zabbix/select-option.png differ diff --git a/docs/images/secret-syncs/zabbix/sync-created.png b/docs/images/secret-syncs/zabbix/sync-created.png new file mode 100644 index 000000000..e9924c82f Binary files /dev/null and b/docs/images/secret-syncs/zabbix/sync-created.png differ diff --git a/docs/images/self-hosting/configuration/overrides/page.png b/docs/images/self-hosting/configuration/overrides/page.png new file mode 100644 index 000000000..660273d4f Binary files /dev/null and b/docs/images/self-hosting/configuration/overrides/page.png differ diff --git a/docs/integrations/app-connections/zabbix.mdx b/docs/integrations/app-connections/zabbix.mdx new file mode 100644 index 000000000..c4d47b22e --- /dev/null +++ b/docs/integrations/app-connections/zabbix.mdx @@ -0,0 +1,101 @@ +--- +title: "Zabbix Connection" +description: "Learn how to configure a Zabbix Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://www.zabbix.com/documentation/current/en/manual/web_interface/frontend_sections/users/api_tokens) to connect with Zabbix. + +## Create Zabbix API Token + + + + ![Dashboard Page](/images/app-connections/zabbix/zabbix-dashboard.png) + + + ![Click Create Token](/images/app-connections/zabbix/zabbix-api-token-list.png) + + + Ensure that you give this token access to the correct app, then click 'Create Token'. + + ![Create Token Page](/images/app-connections/zabbix/zabbix-api-token-form.png) + + + After clicking 'Create Token', a modal containing your access token will appear. Save this token for later steps. + ![Copy Token Modal](/images/app-connections/zabbix/zabbix-api-token-generated.png) + + + +## Create Zabbix Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Zabbix Connection** option from the available integrations. + + ![Select Zabbix Connection](/images/app-connections/zabbix/zabbix-app-connection-option.png) + + + Complete the Zabbix Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The Zabbix URL for your instance + - The API Token from earlier steps + + ![Zabbix Connection Modal](/images/app-connections/zabbix/zabbix-app-connection-form.png) + + + After clicking Create, your **Zabbix Connection** is established and ready to use with your Infisical projects. + + ![Zabbix Connection Created](/images/app-connections/zabbix/zabbix-app-connection-generated.png) + + + + + To create a Zabbix Connection, make an API request to the [Create Zabbix Connection](/api-reference/endpoints/app-connections/zabbix/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/zabbix \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-zabbix-connection", + "method": "api-token", + "credentials": { + "apiToken": "[API TOKEN]", + "instanceUrl": "https://zabbix.example.com" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-zabbix-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "zabbix", + "method": "api-token", + "credentials": { + "instanceUrl": "https://zabbix.example.com" + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/zabbix.mdx b/docs/integrations/secret-syncs/zabbix.mdx new file mode 100644 index 000000000..bb3292069 --- /dev/null +++ b/docs/integrations/secret-syncs/zabbix.mdx @@ -0,0 +1,173 @@ +--- +title: "Zabbix Sync" +description: "Learn how to configure a Zabbix Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Zabbix Connection](/integrations/app-connections/zabbix) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Zabbix](/images/secret-syncs/zabbix/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/zabbix/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/zabbix/configure-destination.png) + + - **Zabbix Connection**: The Zabbix Connection to authenticate with. + - **Scope**: The Zabbix scope to sync secrets to. + - **Global**: Secrets will be synced globally. + - **Host**: Secrets will be synced to the specified host. + - **Macro Type**: The type of macro to use when syncing secrets to Zabbix. Currently only **Text** and **Secret** macros are supported. + The remaining fields are determined by the selected **Scope**: + + + - **Host**: The host to sync secrets to. + + + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/zabbix/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Zabbix when keys conflict. + - **Import Secrets (Prioritize Zabbix)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Zabbix over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Zabbix Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/zabbix/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Zabbix Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/zabbix/review-configuration.png) + + + If enabled, your Zabbix Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/zabbix/sync-created.png) + + + + + To create a **Zabbix Sync**, make an API request to the [Create Zabbix Sync](/api-reference/endpoints/secret-syncs/zabbix/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/zabbix \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-zabbix-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "scope": "host", + "hostId": "my-zabbix-host", + "hostName": "my-zabbix-host", + "macroType": 0 + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-zabbix-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "zabbix", + "name": "my-zabbix-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "zabbix", + "destinationConfig": { + "scope": "host", + "hostId": "my-zabbix-host", + "hostName": "my-zabbix-host", + "macroType": 0 + } + } + } + ``` + + diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 29f5d8f15..42d289804 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -794,3 +794,9 @@ If export type is set to `otlp`, you will have to configure a value for `OTEL_EX The TLS header used to propagate the client certificate from the load balancer to the server. + +## Environment Variable Overrides + +If you can't directly access and modify environment variables, you can update them using the [Server Admin Console](/documentation/platform/admin-panel/server-admin). + +![Environment Variables Overrides Page](../../images/self-hosting/configuration/overrides/page.png) diff --git a/frontend/public/images/integrations/Zabbix.png b/frontend/public/images/integrations/Zabbix.png new file mode 100644 index 000000000..3ac67d2b4 Binary files /dev/null and b/frontend/public/images/integrations/Zabbix.png differ diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 0b3a8d038..22f47fa17 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -65,6 +65,7 @@ export const createNotification = ( toast(, { position: "bottom-right", ...toastProps, + autoClose: toastProps.autoClose || 15000, theme: "dark", type: myProps?.type || "info" }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx index dd54dbf63..15169c73a 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/1PasswordSyncFields.tsx @@ -60,7 +60,7 @@ export const OnePassSyncFields = () => { menuPlacement="top" isLoading={isVaultsLoading && Boolean(connectionId)} isDisabled={!connectionId} - value={vaults?.find((v) => v.id === value) ?? null} + value={vaults?.find((v) => v.id === value) || null} onChange={(option) => onChange((option as SingleValue)?.id ?? null)} options={vaults} placeholder="Select a vault..." diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b6074d270..da8686cc1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -25,6 +25,7 @@ import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; import { WindmillSyncFields } from "./WindmillSyncFields"; +import { ZabbixSyncFields } from "./ZabbixSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -76,6 +77,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.CloudflarePages: return ; + case SecretSync.Zabbix: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ZabbixSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ZabbixSyncFields.tsx new file mode 100644 index 000000000..e00100284 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ZabbixSyncFields.tsx @@ -0,0 +1,147 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; +import { + TZabbixHost, + useZabbixConnectionListHosts, + ZABBIX_SYNC_SCOPES, + ZabbixMacroType, + ZabbixSyncScope +} from "@app/hooks/api/appConnections/zabbix"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const ZabbixSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Zabbix } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentScope = watch("destinationConfig.scope"); + + const { data: hosts = [], isPending: isHostsPending } = useZabbixConnectionListHosts( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.scope", ZabbixSyncScope.Global); + setValue("destinationConfig.hostId", ""); + setValue("destinationConfig.hostName", ""); + }} + /> + ( + +

+ Specify how Infisical should manage secrets from Zabbix. The following options are + available: +

+
    + {Object.values(ZABBIX_SYNC_SCOPES).map(({ name, description }) => { + return ( +
  • +

    + {name}: {description} +

    +
  • + ); + })} +
+ + } + > + +
+ )} + /> + {currentScope === ZabbixSyncScope.Host && ( + ( + + host.hostId === value) ?? null} + onChange={(option) => { + const selectedOption = option as SingleValue; + onChange(selectedOption?.hostId ?? null); + + if (selectedOption) { + setValue("destinationConfig.hostName", selectedOption.host); + } else { + setValue("destinationConfig.hostName", ""); + } + }} + options={hosts} + placeholder="Select a host..." + getOptionLabel={(option) => option.host} + getOptionValue={(option) => option.hostId} + /> + + )} + /> + )} + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index a61c2a6b8..d91fc3771 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -23,6 +23,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { const { control, watch } = useFormContext(); const destination = watch("destination"); + const currentSyncOption = watch("syncOptions"); const destinationName = SECRET_SYNC_MAP[destination].name; @@ -57,6 +58,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Flyio: case SecretSync.GitLab: case SecretSync.CloudflarePages: + case SecretSync.Zabbix: AdditionalSyncOptionsFieldsComponent = null; break; default: @@ -127,8 +129,9 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { {!syncOption?.canImportSecrets && (

- {destinationName} only supports overwriting destination secrets. Secrets not present - in Infisical will be removed from the destination. + {destinationName} only supports overwriting destination secrets.{" "} + {!currentSyncOption.disableSecretDeletion && + "Secrets not present in Infisical will be removed from the destination."}

)} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index fb639e91b..e2ffb9fa6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -35,6 +35,7 @@ import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; +import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -124,6 +125,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.CloudflarePages: DestinationFieldsComponent = ; break; + case SecretSync.Zabbix: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ZabbixSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ZabbixSyncReviewFields.tsx new file mode 100644 index 000000000..c58e35382 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ZabbixSyncReviewFields.tsx @@ -0,0 +1,31 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { ZabbixSyncScope } from "@app/hooks/api/appConnections/zabbix"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +const isTextMacro = (macroType: number) => macroType === 0; + +export const ZabbixSyncReviewFields = () => { + const { watch } = useFormContext(); + const scope = watch("destinationConfig.scope"); + const hostId = watch("destinationConfig.hostId"); + const hostName = watch("destinationConfig.hostName"); + const macroType = watch("destinationConfig.macroType"); + + return ( + <> + {scope} + {scope === ZabbixSyncScope.Host && ( + <> + {hostId} + {hostName} + + )} + + {isTextMacro(macroType) ? "Text" : "Secret"} + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 5d15492eb..331768e7f 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -22,6 +22,7 @@ import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schem import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; +import { ZabbixSyncDestinationSchema } from "./zabbix-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -45,7 +46,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ RenderSyncDestinationSchema, FlyioSyncDestinationSchema, GitlabSyncDestinationSchema, - CloudflarePagesSyncDestinationSchema + CloudflarePagesSyncDestinationSchema, + ZabbixSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/zabbix-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/zabbix-sync-destination-schema.ts new file mode 100644 index 000000000..694c6c7af --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/zabbix-sync-destination-schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { ZabbixMacroType, ZabbixSyncScope } from "@app/hooks/api/appConnections/zabbix"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const ZabbixSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Zabbix), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(ZabbixSyncScope.Host), + hostId: z.string().trim().min(1, "Host ID required"), + hostName: z.string().trim().min(1, "Host name required"), + macroType: z.nativeEnum(ZabbixMacroType, { + errorMap: () => ({ message: "Macro type must be either 'text' or 'secret'" }) + }) + }), + z.object({ + scope: z.literal(ZabbixSyncScope.Global), + macroType: z.nativeEnum(ZabbixMacroType, { + errorMap: () => ({ message: "Macro type must be either 'text' or 'secret'" }) + }) + }) + ]) + }) +); diff --git a/frontend/src/components/v2/HeaderResizer/HeaderResizer.tsx b/frontend/src/components/v2/HeaderResizer/HeaderResizer.tsx new file mode 100644 index 000000000..77ba59c33 --- /dev/null +++ b/frontend/src/components/v2/HeaderResizer/HeaderResizer.tsx @@ -0,0 +1,36 @@ +import { MouseEventHandler } from "react"; + +export const HeaderResizer = ({ + onMouseDown, + isActive, + scrollOffset, + heightOffset +}: { + onMouseDown: MouseEventHandler; + isActive: boolean; + scrollOffset: number; + heightOffset: number; +}) => { + return ( + <> +
+
+
+
+ + ); +}; diff --git a/frontend/src/components/v2/HeaderResizer/index.tsx b/frontend/src/components/v2/HeaderResizer/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/components/v2/HighlightText/HighlightText.tsx b/frontend/src/components/v2/HighlightText/HighlightText.tsx new file mode 100644 index 000000000..3ce7c8f19 --- /dev/null +++ b/frontend/src/components/v2/HighlightText/HighlightText.tsx @@ -0,0 +1,42 @@ +export const HighlightText = ({ + text, + highlight, + highlightClassName +}: { + text: string | undefined | null; + highlight: string; + highlightClassName?: string; +}) => { + if (!text) return null; + const searchTerm = highlight.toLowerCase().trim(); + + if (!searchTerm) return {text}; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearchTerm, "gi"); + + text.replace(regex, (match: string, offset: number) => { + if (offset > lastIndex) { + parts.push({text.substring(lastIndex, offset)}); + } + + parts.push( + + {match} + + ); + + lastIndex = offset + match.length; + + return match; + }); + + if (lastIndex < text.length) { + parts.push({text.substring(lastIndex)}); + } + + return parts; +}; diff --git a/frontend/src/components/v2/HighlightText/index.tsx b/frontend/src/components/v2/HighlightText/index.tsx new file mode 100644 index 000000000..a2ff1b504 --- /dev/null +++ b/frontend/src/components/v2/HighlightText/index.tsx @@ -0,0 +1 @@ +export { HighlightText } from "./HighlightText"; diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index 1d5a9c394..cc180ffb6 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -45,10 +45,14 @@ export const Table = ({ children, className }: TableProps): JSX.Element => ( export type THeadProps = { children: ReactNode; className?: string; + style?: React.CSSProperties; }; -export const THead = ({ children, className }: THeadProps): JSX.Element => ( - +export const THead = ({ children, className, style }: THeadProps): JSX.Element => ( + {children} ); @@ -96,14 +100,16 @@ export const Tr = ({ export type ThProps = { children?: ReactNode; className?: string; + style?: React.CSSProperties; }; -export const Th = ({ children, className }: ThProps): JSX.Element => ( +export const Th = ({ children, className, style }: ThProps): JSX.Element => ( {children} diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 23552fdf5..fa6f11bbb 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -37,7 +37,8 @@ import { TeamCityConnectionMethod, TerraformCloudConnectionMethod, VercelConnectionMethod, - WindmillConnectionMethod + WindmillConnectionMethod, + ZabbixConnectionMethod } from "@app/hooks/api/appConnections/types"; import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/bitbucket-connection"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; @@ -90,7 +91,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, [AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }, [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }, - [AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" } + [AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" }, + [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -123,6 +125,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case OnePassConnectionMethod.ApiToken: case CloudflareConnectionMethod.ApiToken: case BitbucketConnectionMethod.ApiToken: + case ZabbixConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index d6395397d..e42898036 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -81,6 +81,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, [SecretSync.GitLab]: AppConnection.Gitlab, - [SecretSync.CloudflarePages]: AppConnection.Cloudflare + [SecretSync.CloudflarePages]: AppConnection.Cloudflare, + [SecretSync.Zabbix]: AppConnection.Zabbix }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index c628df955..871c7288c 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -10,6 +10,7 @@ import { AdminGetUsersFilters, AdminIntegrationsConfig, OrganizationWithProjects, + TGetEnvOverrides, TGetInvalidatingCacheStatus, TGetServerRootKmsEncryptionDetails, TServerConfig @@ -31,7 +32,8 @@ export const adminQueryKeys = { getAdminSlackConfig: () => ["admin-slack-config"] as const, getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const, getInvalidateCache: () => ["admin-invalidate-cache"] as const, - getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const + getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const, + getEnvOverrides: () => ["env-overrides"] as const }; export const fetchServerConfig = async () => { @@ -163,3 +165,13 @@ export const useGetInvalidatingCacheStatus = (enabled = true) => { refetchInterval: (data) => (data ? 3000 : false) }); }; + +export const useGetEnvOverrides = () => { + return useQuery({ + queryKey: adminQueryKeys.getEnvOverrides(), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/admin/env-overrides"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c5d92b9da..4580c6581 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -48,6 +48,7 @@ export type TServerConfig = { authConsentContent?: string; pageFrameContent?: string; invalidatingCache: boolean; + envOverrides?: Record; }; export type TUpdateServerConfigDTO = { @@ -61,6 +62,7 @@ export type TUpdateServerConfigDTO = { gitHubAppConnectionSlug?: string; gitHubAppConnectionId?: string; gitHubAppConnectionPrivateKey?: string; + envOverrides?: Record; } & Partial; export type TCreateAdminUserDTO = { @@ -138,3 +140,10 @@ export type TInvalidateCacheDTO = { export type TGetInvalidatingCacheStatus = { invalidating: boolean; }; + +export interface TGetEnvOverrides { + [key: string]: { + name: string; + fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[]; + }; +} diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index d03045cb1..31443c67d 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -28,5 +28,6 @@ export enum AppConnection { Flyio = "flyio", Gitlab = "gitlab", Cloudflare = "cloudflare", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + Zabbix = "zabbix" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index b9f3990d3..1fa1086c9 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -134,6 +134,10 @@ export type TCloudflareConnectionOption = TAppConnectionOptionBase & { export type TBitbucketConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Bitbucket; +} + +export type TZabbixConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Zabbix; }; export type TAppConnectionOption = @@ -165,6 +169,7 @@ export type TAppConnectionOption = | TGitlabConnectionOption | TCloudflareConnectionOption | TBitbucketConnectionOption; + | TZabbixConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -197,4 +202,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Gitlab]: TGitlabConnectionOption; [AppConnection.Cloudflare]: TCloudflareConnectionOption; [AppConnection.Bitbucket]: TBitbucketConnectionOption; + [AppConnection.Zabbix]: TZabbixConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index da87d03b2..4aabde9ad 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -30,6 +30,7 @@ import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; +import { TZabbixConnection } from "./zabbix-connection"; export * from "./1password-connection"; export * from "./auth0-connection"; @@ -61,6 +62,7 @@ export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; +export * from "./zabbix-connection"; export type TAppConnection = | TAwsConnection @@ -92,7 +94,8 @@ export type TAppConnection = | TFlyioConnection | TGitLabConnection | TCloudflareConnection - | TBitbucketConnection; + | TBitbucketConnection + | TZabbixConnection; export type TAvailableAppConnection = Pick; @@ -150,4 +153,5 @@ export type TAppConnectionMap = { [AppConnection.Gitlab]: TGitLabConnection; [AppConnection.Cloudflare]: TCloudflareConnection; [AppConnection.Bitbucket]: TBitbucketConnection; + [AppConnection.Zabbix]: TZabbixConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/zabbix-connection.ts b/frontend/src/hooks/api/appConnections/types/zabbix-connection.ts new file mode 100644 index 000000000..b8eaa9636 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/zabbix-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum ZabbixConnectionMethod { + ApiToken = "api-token" +} + +export type TZabbixConnection = TRootAppConnection & { app: AppConnection.Zabbix } & { + method: ZabbixConnectionMethod.ApiToken; + credentials: { + apiToken: string; + instanceUrl: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/zabbix/index.ts b/frontend/src/hooks/api/appConnections/zabbix/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/zabbix/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/zabbix/queries.tsx b/frontend/src/hooks/api/appConnections/zabbix/queries.tsx new file mode 100644 index 000000000..5c7d1cc19 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/zabbix/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TZabbixHost } from "./types"; + +const zabbixConnectionKeys = { + all: [...appConnectionKeys.all, "zabbix"] as const, + listHosts: (connectionId: string) => [...zabbixConnectionKeys.all, "hosts", connectionId] as const +}; + +export const useZabbixConnectionListHosts = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TZabbixHost[], + unknown, + TZabbixHost[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: zabbixConnectionKeys.listHosts(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/zabbix/${connectionId}/hosts` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/zabbix/types.ts b/frontend/src/hooks/api/appConnections/zabbix/types.ts new file mode 100644 index 000000000..be33ffca2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/zabbix/types.ts @@ -0,0 +1,25 @@ +export type TZabbixHost = { + host: string; + hostId: string; +}; + +export enum ZabbixSyncScope { + Host = "host", + Global = "global" +} + +export enum ZabbixMacroType { + Text = 0, + Secret = 1 +} + +export const ZABBIX_SYNC_SCOPES = { + [ZabbixSyncScope.Host]: { + name: "Host", + description: "Sync secrets to a specific host in Zabbix." + }, + [ZabbixSyncScope.Global]: { + name: "Global", + description: "Sync secrets to a global scope in Zabbix." + } +}; diff --git a/frontend/src/hooks/api/auditLogs/queries.tsx b/frontend/src/hooks/api/auditLogs/queries.tsx index 3b851f1cb..52a35cb85 100644 --- a/frontend/src/hooks/api/auditLogs/queries.tsx +++ b/frontend/src/hooks/api/auditLogs/queries.tsx @@ -30,8 +30,8 @@ export const useGetAuditLogs = ( params: { ...filters, offset: pageParam, - startDate: filters?.startDate?.toISOString(), - endDate: filters?.endDate?.toISOString(), + startDate: filters.startDate.toISOString(), + endDate: filters.endDate.toISOString(), ...(filters.eventMetadata && Object.keys(filters.eventMetadata).length ? { eventMetadata: Object.entries(filters.eventMetadata) diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 56dc11aeb..0b9e201fe 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -14,8 +14,8 @@ export type TGetAuditLogsFilter = { actor?: string; // user ID format secretPath?: string; secretKey?: string; - startDate?: Date; - endDate?: Date; + startDate: Date; + endDate: Date; limit: number; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 66834ced5..ab79f73bb 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -20,7 +20,8 @@ export enum SecretSync { Render = "render", Flyio = "flyio", GitLab = "gitlab", - CloudflarePages = "cloudflare-pages" + CloudflarePages = "cloudflare-pages", + Zabbix = "zabbix" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 3e254e9aa..33119b8a3 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -23,6 +23,7 @@ import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; import { TWindmillSync } from "./windmill-sync"; +import { TZabbixSync } from "./zabbix-sync"; export type TSecretSyncOption = { name: string; @@ -53,7 +54,8 @@ export type TSecretSync = | TRenderSync | TFlyioSync | TGitLabSync - | TCloudflarePagesSync; + | TCloudflarePagesSync + | TZabbixSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/zabbix-sync.ts b/frontend/src/hooks/api/secretSyncs/types/zabbix-sync.ts new file mode 100644 index 000000000..cb8a4bac6 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/zabbix-sync.ts @@ -0,0 +1,25 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +import { ZabbixSyncScope } from "../../appConnections/zabbix"; + +export type TZabbixSync = TRootSecretSync & { + destination: SecretSync.Zabbix; + destinationConfig: + | { + scope: ZabbixSyncScope.Host; + hostId: string; + hostName: string; + macroType: number; + } + | { + scope: ZabbixSyncScope.Global; + macroType: number; + }; + connection: { + app: AppConnection.Zabbix; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index caa4a76f8..b224c1b6d 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -5,6 +5,7 @@ export { usePagination } from "./usePagination"; export { usePersistentState } from "./usePersistentState"; export { usePopUp } from "./usePopUp"; export { useResetPageHelper } from "./useResetPageHelper"; +export * from "./useResizableHeaderHeight"; export { useSyntaxHighlight } from "./useSyntaxHighlight"; export { useTimedReset } from "./useTimedReset"; export { useToggle } from "./useToggle"; diff --git a/frontend/src/hooks/useResizableHeaderHeight.tsx b/frontend/src/hooks/useResizableHeaderHeight.tsx new file mode 100644 index 000000000..9624e9052 --- /dev/null +++ b/frontend/src/hooks/useResizableHeaderHeight.tsx @@ -0,0 +1,71 @@ +import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; + +type Params = { + minHeight: number; + maxHeight: number; + initialHeight: number; +}; + +export const useResizableHeaderHeight = ({ minHeight, maxHeight, initialHeight }: Params) => { + const [headerHeight, setHeaderHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); + const startY = useRef(0); + const startHeight = useRef(0); + + const handleMouseDown = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + startY.current = e.clientY; + startHeight.current = headerHeight; + }, + [headerHeight] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + const deltaY = e.clientY - startY.current; + const newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight.current + deltaY)); + + setHeaderHeight(newHeight); + }, + [isResizing] + ); + + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (isResizing) { + document.addEventListener( + "mousemove", + // @ts-expect-error native discrepancy + handleMouseMove + ); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; + } + + return () => { + document.removeEventListener( + "mousemove", + // @ts-expect-error native discrepancy + handleMouseMove + ); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + return { + headerHeight, + handleMouseDown, + isResizing + }; +}; diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx index d5a8d2a4b..807bb307f 100644 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ b/frontend/src/layouts/AdminLayout/Sidebar.tsx @@ -29,6 +29,11 @@ const generalTabs = [ label: "Caching", icon: "note", link: "/admin/caching" + }, + { + label: "Environment Variables", + icon: "unlock", + link: "/admin/environment" } ]; diff --git a/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx b/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx new file mode 100644 index 000000000..000dd1f0d --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx @@ -0,0 +1,27 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { PageHeader } from "@app/components/v2"; + +import { EnvironmentPageForm } from "./components"; + +export const EnvironmentPage = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Admin" })} + +
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx new file mode 100644 index 000000000..0d2adba9d --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Control, Controller, useForm, useWatch } from "react-hook-form"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faChevronRight, + faExclamationTriangle, + faMagnifyingGlass +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, SecretInput, Tooltip } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; +import { useGetEnvOverrides, useUpdateServerConfig } from "@app/hooks/api"; + +type TForm = Record; + +export const GroupContainer = ({ + group, + control, + search +}: { + group: { + fields: { + key: string; + value: string; + hasEnvEntry: boolean; + description?: string; + }[]; + name: string; + }; + control: Control; + search: string; +}) => { + const [open, setOpen] = useState(false); + + return ( +
+
setOpen((o) => !o)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setOpen((o) => !o); + } + }} + > + + +
{group.name}
+
+ + {(open || search) && ( +
+ {group.fields.map((field) => ( +
+
+ + + + + + +
+ +
+ {field.hasEnvEntry && ( + + + + )} + + ( + + + + )} + /> +
+
+ ))} +
+ )} +
+ ); +}; + +export const EnvironmentPageForm = () => { + const { data: envOverrides } = useGetEnvOverrides(); + const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); + const [search, setSearch] = useState(""); + + const allFields = useMemo(() => { + if (!envOverrides) return []; + return Object.values(envOverrides).flatMap((group) => group.fields); + }, [envOverrides]); + + const formSchema = useMemo(() => { + return z.object(Object.fromEntries(allFields.map((field) => [field.key, z.string()]))); + }, [allFields]); + + const defaultValues = useMemo(() => { + const values: Record = {}; + allFields.forEach((field) => { + values[field.key] = field.value ?? ""; + }); + return values; + }, [allFields]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues + }); + + const formValues = useWatch({ control }); + + const filteredData = useMemo(() => { + if (!envOverrides) return []; + + const searchTerm = search.toLowerCase().trim(); + if (!searchTerm) { + return Object.values(envOverrides); + } + + return Object.values(envOverrides) + .map((group) => { + const filteredFields = group.fields.filter( + (field) => + field.key.toLowerCase().includes(searchTerm) || + (field.description ?? "").toLowerCase().includes(searchTerm) + ); + + if (filteredFields.length > 0) { + return { ...group, fields: filteredFields }; + } + return null; + }) + .filter(Boolean); + }, [search, formValues, envOverrides]); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const onSubmit = useCallback( + async (formData: TForm) => { + try { + const filteredFormData = Object.fromEntries( + Object.entries(formData).filter(([, value]) => value !== "") + ); + await updateServerConfig({ + envOverrides: filteredFormData + }); + + createNotification({ + type: "success", + text: "Environment overrides updated successfully. It can take up to 5 minutes to take effect." + }); + + reset(formData); + } catch (error) { + const errorMessage = + (error as any)?.response?.data?.message || + (error as any)?.message || + "An unknown error occurred"; + createNotification({ + type: "error", + title: "Failed to update environment overrides", + text: errorMessage + }); + } + }, + [reset, updateServerConfig] + ); + + return ( +
+
+
+
+

Overrides

+ +
+ + Docs + +
+
+
+

+ Override specific environment variables. After saving, it may take up to 5 minutes for + variables to propagate throughout every container. +

+
+ +
+ +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search for keys, descriptions, and values..." + className="flex-1" + /> +
+ {filteredData.map((group) => ( + + ))} +
+ + ); +}; diff --git a/frontend/src/pages/admin/EnvironmentPage/components/index.ts b/frontend/src/pages/admin/EnvironmentPage/components/index.ts new file mode 100644 index 000000000..44b82c206 --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/components/index.ts @@ -0,0 +1 @@ +export { EnvironmentPageForm } from "./EnvironmentPageForm"; diff --git a/frontend/src/pages/admin/EnvironmentPage/route.tsx b/frontend/src/pages/admin/EnvironmentPage/route.tsx new file mode 100644 index 000000000..8f9ac4c7e --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/route.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, linkOptions } from "@tanstack/react-router"; + +import { EnvironmentPage } from "./EnvironmentPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/admin/_admin-layout/environment" +)({ + component: EnvironmentPage, + beforeLoad: () => { + return { + breadcrumbs: [ + { + label: "Admin", + link: linkOptions({ to: "/admin" }) + }, + { + label: "Environment", + link: linkOptions({ + to: "/admin/environment" + }) + } + ] + }; + } +}); diff --git a/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx b/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx index 92c485ed3..b163134eb 100644 --- a/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx +++ b/frontend/src/pages/auth/EmailNotVerifiedPage/EmailNotVerifiedPage.tsx @@ -1,19 +1,33 @@ import { Helmet } from "react-helmet"; +import { Link } from "@tanstack/react-router"; export const EmailNotVerifiedPage = () => { return ( -
+
Request a New Invite -
-

Oops.

-

Your email was not verified.

-

Please try again.

-

- Note: If it still doesn't work, please reach out to us at support@infisical.com + +

+ Infisical Logo +
+ +
+

+ Your email was not verified +

+

+ Please try again.
Note: If it still doesn't work, please reach out to us at + support@infisical.com

+
+ + + Back to Login + + +
); diff --git a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx index 8b6be6b9a..17c96e8a2 100644 --- a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { z } from "zod"; import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; @@ -36,7 +36,12 @@ export const PasswordResetPage = () => { const navigate = useNavigate(); return ( -
+
+ +
+ Infisical Logo +
+ {step === Steps.ConfirmEmail && ( { diff --git a/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx index 1f1a79490..bf7cfcdec 100644 --- a/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx @@ -19,17 +19,21 @@ export const ConfirmEmailStep = ({ onComplete }: Props) => { isPending: isVerifyPasswordResetLoading } = useVerifyPasswordResetCode(); return ( -
-

+

+

Confirm your email +

+

+ Reset password for {email}.

- verify email -
+
diff --git a/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx index de7bcd3f3..4f021ec34 100644 --- a/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx @@ -1,7 +1,7 @@ import crypto from "crypto"; import { Controller, useForm } from "react-hook-form"; -import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useSearch } from "@tanstack/react-router"; @@ -169,17 +169,15 @@ export const EnterPasswordStep = ({ return (
-

+

Enter new password +

+

+ Make sure you save it somewhere safe.

-
-

- Make sure you save it somewhere safe. -

-
-
+
+
+ +
{passwordErrorTooShort || passwordErrorTooLong || passwordErrorNoLetterChar || @@ -210,33 +222,33 @@ export const EnterPasswordStep = ({ passwordErrorEscapeChar || passwordErrorLowEntropy || passwordErrorBreached ? ( -
-
Password should contain:
-
+
+
Password should contain:
+
{passwordErrorTooShort ? ( - + ) : ( - + )}
at least 14 characters
-
+
{passwordErrorTooLong ? ( - + ) : ( - + )}
at most 100 characters
-
+
{passwordErrorNoLetterChar ? ( - + ) : ( - + )}
-
+
{passwordErrorNoNumOrSpecialChar ? ( - + ) : ( - + )}
-
+
{passwordErrorRepeatedChar ? ( - + ) : ( - + )}
-
+
{passwordErrorEscapeChar ? ( - + ) : ( - + )}
-
+
{passwordErrorLowEntropy ? ( - + ) : ( - + )}
-
+
{passwordErrorBreached ? ( - + ) : ( - + )}
Password was found in a data breach.
- ) : ( -
- )} -
-
- -
-
+ ) : null} ); }; diff --git a/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx index 54a5d5e9d..8f84af3bd 100644 --- a/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx @@ -43,18 +43,15 @@ export const InputBackupKeyStep = ({ verificationToken, onComplete }: Props) => return (
-

+

Enter your backup key +

+

+ You can find it in your emergency kit you downloaded during signup.

-
-

- You can find it in your emergency kit. You had to download the emergency kit during - signup. -

-
-
+
)} />
-
-
- -
+
+
); diff --git a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx index 9777f564a..0f9da2991 100644 --- a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx +++ b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx @@ -2,8 +2,7 @@ import { FormEvent, useState } from "react"; import { Helmet } from "react-helmet"; import { Link } from "@tanstack/react-router"; -import InputField from "@app/components/basic/InputField"; -import { Button, EmailServiceSetupModal } from "@app/components/v2"; +import { Button, EmailServiceSetupModal, Input } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; import { useSendPasswordResetEmail } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -44,9 +43,9 @@ export const VerifyEmailPage = () => { }; return ( -
+
- Login + Reset Password @@ -56,66 +55,80 @@ export const VerifyEmailPage = () => { /> -
+
long logo
{step === 1 && (
-

+

Forgot your password? +

+

+ Enter your email to start the password reset process.
You will receive an email + with instructions.

-
-

- Enter your email to start the password reset process. You will receive an email with - instructions. -

-
-
- + setEmail(e.target.value)} + type="email" + placeholder="Enter your email..." isRequired autoComplete="username" + className="h-10" />
-
-
- -
+
+ +
+
+ + + Back to Login + +
)} {step === 2 && ( -
-

- Look for an email in your inbox. +

+

+ Look for an email in your inbox +

+

+ If the email is in our system, you will receive an email at{" "} + {email} with instructions on how to reset your password.

-
-

- If the email is in our system, you will receive an email at{" "} - {email} with instructions on how to reset your - password. -

+
+ + + Back to Login + +
)} - handlePopUpToggle("setUpEmail", isOpen)} diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index df9012809..0e080d6dd 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -39,6 +39,7 @@ import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; +import { ZabbixConnectionForm } from "./ZabbixConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -137,6 +138,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Bitbucket: return ; + case AppConnection.Zabbix: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -233,6 +236,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Bitbucket: return ; + case AppConnection.Zabbix: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ZabbixConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ZabbixConnectionForm.tsx new file mode 100644 index 000000000..72bbd03f7 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ZabbixConnectionForm.tsx @@ -0,0 +1,137 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TZabbixConnection, ZabbixConnectionMethod } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TZabbixConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Zabbix) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(ZabbixConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required"), + instanceUrl: z.string().trim().url("Invalid instance URL") + }) + }) +]); + +type FormData = z.infer; + +export const ZabbixConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Zabbix, + method: ZabbixConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsDateFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsDateFilter.tsx new file mode 100644 index 000000000..87957ad22 --- /dev/null +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsDateFilter.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faArrowRight, faCalendar, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; + +import { + Button, + DatePicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; + +import { + auditLogDateFilterFormSchema, + AuditLogDateFilterType, + TAuditLogDateFilterFormData +} from "./types"; + +type Props = { + setFilter: (data: TAuditLogDateFilterFormData) => void; + filter: TAuditLogDateFilterFormData; +}; +const RELATIVE_VALUES = ["5m", "30m", "1h", "3h", "12h"]; +export const LogsDateFilter = ({ setFilter, filter }: Props) => { + const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); + const [isPopupOpen, setIsPopOpen] = useState(false); + + const { control, watch, handleSubmit, formState } = useForm({ + resolver: zodResolver(auditLogDateFilterFormSchema), + values: filter + }); + const selectType = watch("type"); + const isCustomRelative = + filter.type === AuditLogDateFilterType.Relative && + !RELATIVE_VALUES.includes(filter.relativeModeValue || ""); + + const onSubmit = (data: TAuditLogDateFilterFormData) => { + const endDate = data.type === AuditLogDateFilterType.Relative ? new Date() : data.endDate; + const startDate = + data.type === AuditLogDateFilterType.Relative && data.relativeModeValue + ? new Date(Number(new Date()) - ms(data.relativeModeValue)) + : data.startDate; + setFilter({ + ...data, + startDate, + endDate + }); + setIsPopOpen(false); + }; + + return ( + setIsPopOpen(el)}> +
+ {filter.type === AuditLogDateFilterType.Relative ? ( + <> + {RELATIVE_VALUES.map((el) => ( + + ))} + + ) : ( + <> +
+ {format(filter.startDate, "yyyy-MM-dd HH:mm")} +
+
+ +
+
+ {format(filter.endDate, "yyyy-MM-dd HH:mm")} +
+ + )} + + + +
+ +
+ ( + + + + )} + /> + {selectType === AuditLogDateFilterType.Relative && ( + ( + + + + )} + /> + )} + {selectType === AuditLogDateFilterType.Absolute && ( +
+ { + return ( + + + + ); + }} + /> +
+
+ +
+ { + return ( + + + + ); + }} + /> +
+ )} +
+ +
+ + + + ); +}; diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index 367cfefc0..7d406c865 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -1,28 +1,15 @@ /* eslint-disable no-nested-ternary */ -import { useMemo, useState } from "react"; -import { - Control, - Controller, - UseFormGetFieldState, - UseFormReset, - UseFormResetField, - UseFormSetValue, - UseFormWatch -} from "react-hook-form"; -import { - faArrowRight, - faCaretDown, - faCheckCircle, - faFilterCircleXmark -} from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; import { AnimatePresence, motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; import { Badge, Button, - DatePicker, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -44,7 +31,7 @@ import { EventType } from "@app/hooks/api/auditLogs/enums"; import { UserAgentType } from "@app/hooks/api/auth/types"; import { LogFilterItem } from "./LogFilterItem"; -import { AuditLogFilterFormData, Presets } from "./types"; +import { auditLogFilterFormSchema, Presets, TAuditLogFilterFormData } from "./types"; const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value })); const userAgentTypes = Object.entries(userAgentTypeToNameMap).map(([value, label]) => ({ @@ -54,78 +41,54 @@ const userAgentTypes = Object.entries(userAgentTypeToNameMap).map(([value, label type Props = { presets?: Presets; - control: Control; - reset: UseFormReset; - resetField: UseFormResetField; - watch: UseFormWatch; - getFieldState: UseFormGetFieldState; - setValue: UseFormSetValue; + setFilter: (data: TAuditLogFilterFormData) => void; + filter: TAuditLogFilterFormData; }; -const getActiveFilterCount = ( - getFieldState: UseFormGetFieldState, - watch: UseFormWatch -) => { +const getActiveFilterCount = (filter: TAuditLogFilterFormData) => { const fields = [ "actor", "project", "eventType", - "startDate", - "endDate", "environment", "secretPath", "userAgentType", "secretKey" - ] as Partial[]; + ] as Partial[]; let filterCount = 0; // either start or end date should only be counted as one filter - let dateProcessed = false; - fields.forEach((field) => { - const fieldState = getFieldState(field); - - if ( - field === "userAgentType" || - field === "environment" || - field === "secretKey" || - field === "secretPath" - ) { - const value = watch(field); - - if (value !== undefined && value !== "") { - filterCount += 1; - } - } else if (fieldState.isDirty && !dateProcessed) { + const value = filter?.[field]; + if (Array.isArray(value) ? value.length : value) { filterCount += 1; - - if (field === "startDate" || field === "endDate") { - dateProcessed = true; - } } }); return filterCount; }; -export const LogsFilter = ({ - presets, - control, - reset, - resetField, - watch, - getFieldState, - setValue -}: Props) => { - const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); - const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); - +export const LogsFilter = ({ presets, setFilter, filter }: Props) => { const { data: workspaces = [] } = useGetUserWorkspaces(); const { currentOrg } = useOrganization(); const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id); + const { control, watch, resetField, setValue, handleSubmit, formState } = + useForm({ + resolver: zodResolver(auditLogFilterFormSchema), + defaultValues: { + project: null, + environment: undefined, + secretKey: "", + secretPath: "", + actor: presets?.actorId, + eventType: filter?.eventType || [], + userAgentType: undefined + }, + values: filter + }); const selectedEventTypes = watch("eventType") as EventType[] | undefined; const selectedProject = watch("project"); @@ -140,408 +103,345 @@ export const LogsFilter = ({ return workspacesInOrg.find((ws) => ws.id === selectedProject.id)?.environments ?? []; }, [selectedProject, workspacesInOrg]); - const activeFilterCount = getActiveFilterCount(getFieldState, watch); + const activeFilterCount = getActiveFilterCount(filter); return ( - -
-
-
-
- Filters - - {activeFilterCount} - +
+
+
+
+
+ Filters + + {activeFilterCount} + +
+
-
+ +
+ { + resetField("eventType"); }} - variant="link" - className="text-mineshaft-400" - size="xs" > - Clear filters + ( + + + +
+ {selectedEventTypes?.length === 1 + ? eventTypes.find( + (eventType) => eventType.value === selectedEventTypes[0] + )?.label + : selectedEventTypes?.length === 0 + ? "All events" + : `${selectedEventTypes?.length} events selected`} + +
+
+ +
+ {eventTypes && eventTypes.length > 0 ? ( + eventTypes.map((eventType) => { + const isSelected = selectedEventTypes?.includes( + eventType.value as EventType + ); + + return ( + + eventTypes.length > 1 && event.preventDefault() + } + onClick={() => { + if ( + selectedEventTypes?.includes(eventType.value as EventType) + ) { + field.onChange( + selectedEventTypes?.filter( + (e: string) => e !== eventType.value + ) + ); + } else { + field.onChange([ + ...(selectedEventTypes || []), + eventType.value + ]); + } + }} + key={`event-type-${eventType.value}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {eventType.label} + + ); + }) + ) : ( +
+ )} +
+ + + + )} + /> + + { + resetField("userAgentType"); + }} + > + ( + + + + )} + /> + + { + resetField("project"); + resetField("environment"); + setValue("secretPath", ""); + setValue("secretKey", ""); + }} + > + ( + + { + if (e === null) { + setValue("secretPath", ""); + setValue("secretKey", ""); + } + resetField("environment"); + onChange(e); + }} + placeholder="All projects" + options={workspacesInOrg.map(({ name, id, defaultProduct }) => ({ + name, + id, + type: defaultProduct + }))} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + + + {showSecretsSection && ( + +
+

Secrets

+
+
+ { + resetField("environment"); + }} + > + ( + + onChange(e)} + placeholder="All environments" + options={availableEnvironments.map(({ name, slug }) => ({ + name, + slug + }))} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + + { + setValue("secretPath", ""); + }} + > + ( + + onChange(e.target.value)} + /> + + )} + /> + + + { + setValue("secretKey", ""); + }} + > + ( + + + setValue("secretKey", e.target.value, { shouldDirty: true }) + } + /> + + )} + /> + + + )} + +
+
+
- -
- { - resetField("eventType"); - }} - > - ( - - - -
- {selectedEventTypes?.length === 1 - ? eventTypes.find( - (eventType) => eventType.value === selectedEventTypes[0] - )?.label - : selectedEventTypes?.length === 0 - ? "All events" - : `${selectedEventTypes?.length} events selected`} - -
-
- -
- {eventTypes && eventTypes.length > 0 ? ( - eventTypes.map((eventType) => { - const isSelected = selectedEventTypes?.includes( - eventType.value as EventType - ); - - return ( - - eventTypes.length > 1 && event.preventDefault() - } - onClick={() => { - if ( - selectedEventTypes?.includes(eventType.value as EventType) - ) { - field.onChange( - selectedEventTypes?.filter( - (e: string) => e !== eventType.value - ) - ); - } else { - field.onChange([ - ...(selectedEventTypes || []), - eventType.value - ]); - } - }} - key={`event-type-${eventType.value}`} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {eventType.label} - - ); - }) - ) : ( -
- )} -
- - - - )} - /> - - { - resetField("userAgentType"); - }} - > - ( - - - - )} - /> - - - { - resetField("startDate"); - resetField("endDate"); - }} - > -
- { - return ( - - - - ); - }} - /> - -
-
- -
- - { - return ( - - - - ); - }} - /> -
- - - {showSecretsSection && ( - -
-

Secrets

-
-
- - { - resetField("project"); - resetField("environment"); - setValue("secretPath", ""); - setValue("secretKey", ""); - }} - > - ( - - { - if (e === null) { - setValue("secretPath", ""); - setValue("secretKey", ""); - } - resetField("environment"); - onChange(e); - }} - placeholder="All projects" - options={workspacesInOrg.map(({ name, id, defaultProduct }) => ({ - name, - id, - type: defaultProduct - }))} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - /> - - )} - /> - - - { - resetField("environment"); - }} - > - ( - - onChange(e)} - placeholder="All environments" - options={availableEnvironments.map(({ name, slug }) => ({ - name, - slug - }))} - getOptionValue={(option) => option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - /> - - { - setValue("secretPath", ""); - }} - > - ( - - onChange(e.target.value)} - /> - - )} - /> - - - { - setValue("secretKey", ""); - }} - > - ( - - - setValue("secretKey", e.target.value, { shouldDirty: true }) - } - /> - - )} - /> - - - )} - -
-
+ ); diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx index 1878a49e3..7f676121f 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx @@ -1,17 +1,20 @@ -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import ms from "ms"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { withPermission } from "@app/hoc"; -import { useDebounce } from "@app/hooks"; -import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { usePopUp } from "@app/hooks/usePopUp"; +import { LogsDateFilter } from "./LogsDateFilter"; import { LogsFilter } from "./LogsFilter"; import { LogsTable } from "./LogsTable"; -import { AuditLogFilterFormData, auditLogFilterFormSchema, Presets } from "./types"; +import { + AuditLogDateFilterType, + Presets, + TAuditLogDateFilterFormData, + TAuditLogFilterFormData +} from "./types"; type Props = { presets?: Presets; @@ -24,74 +27,45 @@ export const LogsSection = withPermission( const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); - - const { control, reset, watch, getFieldState, resetField, setValue } = - useForm({ - resolver: zodResolver(auditLogFilterFormSchema), - defaultValues: { - project: null, - environment: undefined, - secretKey: "", - secretPath: "", - actor: presets?.actorId, - eventType: presets?.eventType || [], - userAgentType: undefined, - startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), - endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) - } - }); + const [logFilter, setLogFilter] = useState({ + eventType: presets?.eventType || [], + actor: presets?.actorId + }); + const [dateFilter, setDateFilter] = useState({ + startDate: new Date(Number(new Date()) - ms("1h")), + endDate: new Date(), + type: AuditLogDateFilterType.Relative, + relativeModeValue: "1h" + }); useEffect(() => { if (subscription && !subscription.auditLogs) { handlePopUpOpen("upgradePlan"); } }, [subscription]); - - const eventType = watch("eventType") as EventType[] | undefined; - const userAgentType = watch("userAgentType") as UserAgentType | undefined; - const actor = watch("actor"); - const projectId = watch("project")?.id; - const environment = watch("environment")?.slug; - const secretPath = watch("secretPath"); - const secretKey = watch("secretKey"); - - const startDate = watch("startDate"); - const endDate = watch("endDate"); - - const [debouncedSecretPath] = useDebounce(secretPath!, 500); - const [debouncedSecretKey] = useDebounce(secretKey!, 500); - return (
+ {showFilters && } {showFilters && ( - + )}
- { // Determine the project ID for filtering diff --git a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx index 0e0d8cc8a..8e0ec9ba9 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/types.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/types.tsx @@ -3,23 +3,33 @@ import { z } from "zod"; import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { ProjectType } from "@app/hooks/api/workspace/types"; -export const auditLogFilterFormSchema = z +export enum AuditLogDateFilterType { + Relative = "relative", + Absolute = "absolute" +} + +export const auditLogFilterFormSchema = z.object({ + eventMetadata: z.object({}).optional(), + project: z + .object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) }) + .optional() + .nullable(), + environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(), + eventType: z.nativeEnum(EventType).array(), + actor: z.string().optional(), + userAgentType: z.nativeEnum(UserAgentType).optional(), + secretPath: z.string().optional(), + secretKey: z.string().optional(), + page: z.coerce.number().optional(), + perPage: z.coerce.number().optional() +}); + +export const auditLogDateFilterFormSchema = z .object({ - eventMetadata: z.object({}).optional(), - project: z - .object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) }) - .optional() - .nullable(), - environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(), - eventType: z.nativeEnum(EventType).array(), - actor: z.string().optional(), - userAgentType: z.nativeEnum(UserAgentType), - secretPath: z.string().optional(), - secretKey: z.string().optional(), - startDate: z.date().optional(), - endDate: z.date().optional(), - page: z.coerce.number().optional(), - perPage: z.coerce.number().optional() + type: z.nativeEnum(AuditLogDateFilterType), + relativeModeValue: z.string().optional(), + startDate: z.date(), + endDate: z.date() }) .superRefine((el, ctx) => { if (el.endDate && el.startDate && el.endDate < el.startDate) { @@ -31,10 +41,11 @@ export const auditLogFilterFormSchema = z } }); -export type AuditLogFilterFormData = z.infer; +export type TAuditLogFilterFormData = z.infer; +export type TAuditLogDateFilterFormData = z.infer; export type SetValueType = ( - name: keyof AuditLogFilterFormData, + name: keyof TAuditLogFilterFormData, value: any, options?: { shouldValidate?: boolean; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 01064ef1d..c980df5bd 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -22,6 +22,7 @@ import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; +import { ZabbixSyncDestinationCol } from "./ZabbixSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -73,6 +74,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.CloudflarePages: return ; + case SecretSync.Zabbix: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ZabbixSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ZabbixSyncDestinationCol.tsx new file mode 100644 index 000000000..849e64d55 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ZabbixSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TZabbixSync } from "@app/hooks/api/secretSyncs/types/zabbix-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TZabbixSync; +}; + +export const ZabbixSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 26c9144d7..1787162e3 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -1,4 +1,5 @@ import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; +import { ZabbixSyncScope } from "@app/hooks/api/appConnections/zabbix"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { @@ -144,6 +145,17 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.projectName; secondaryText = destinationConfig.environment; break; + case SecretSync.Zabbix: + if (destinationConfig.scope === ZabbixSyncScope.Host) { + primaryText = destinationConfig.hostName; + secondaryText = destinationConfig.hostId; + } else if (destinationConfig.scope === ZabbixSyncScope.Global) { + primaryText = "Global"; + secondaryText = ""; + } else { + throw new Error(`Unhandled Zabbix Scope Destination Col Values ${destination}`); + } + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 78582db2d..255196f86 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; @@ -58,6 +58,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { HeaderResizer } from "@app/components/v2/HeaderResizer/HeaderResizer"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, @@ -73,7 +74,14 @@ import { PreferenceKey, setUserTablePreference } from "@app/helpers/userTablePreferences"; -import { useDebounce, usePagination, usePopUp, useResetPageHelper, useToggle } from "@app/hooks"; +import { + useDebounce, + usePagination, + usePopUp, + useResetPageHelper, + useResizableHeaderHeight, + useToggle +} from "@app/hooks"; import { useCreateFolder, useCreateSecretV3, @@ -97,6 +105,7 @@ import { useSecretRotationOverview } from "@app/hooks/utils"; import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow"; +import { getHeaderStyle } from "@app/pages/secret-manager/OverviewPage/components/utils"; import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm"; import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm"; @@ -142,6 +151,8 @@ const DEFAULT_FILTER_STATE = { [RowType.SecretRotation]: true }; +const DEFAULT_COLLAPSED_HEADER_HEIGHT = 120; + export const OverviewPage = () => { const { t } = useTranslation(); @@ -159,7 +170,7 @@ export const OverviewPage = () => { const [scrollOffset, setScrollOffset] = useState(0); const [debouncedScrollOffset] = useDebounce(scrollOffset); const { permission } = useProjectPermission(); - + const tableRef = useRef(null); const { currentWorkspace } = useWorkspace(); const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; const workspaceId = currentWorkspace?.id as string; @@ -861,6 +872,22 @@ export const OverviewPage = () => { ); }, [importedByEnvs, selectedEntries, selectedKeysCount]); + const storedHeight = Number.parseInt( + localStorage.getItem("overview-header-height") ?? DEFAULT_COLLAPSED_HEADER_HEIGHT.toString(), + 10 + ); + const { headerHeight, handleMouseDown, isResizing } = useResizableHeaderHeight({ + initialHeight: Number.isNaN(storedHeight) ? DEFAULT_COLLAPSED_HEADER_HEIGHT : storedHeight, + minHeight: DEFAULT_COLLAPSED_HEADER_HEIGHT, + maxHeight: 288 + }); + + const debouncedHeaderHeight = useDebounce(headerHeight); + + useEffect(() => { + localStorage.setItem("overview-header-height", debouncedHeaderHeight.toString()); + }, [debouncedHeaderHeight]); + if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { return (
@@ -892,7 +919,7 @@ export const OverviewPage = () => { -
+
{ - + {/*