From 273a7b9657e8f2c40d58459486ed8537ce1ae7ce Mon Sep 17 00:00:00 2001 From: Sid <58144379+sidwebworks@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:47:59 +0530 Subject: [PATCH] fix: update event permissions system (#4355) * fix: update project permission types * fix: permissions * fix: PR changes * chore: update docs * fix: greptile changes * fix: secret imports router changes * fix: type change * fix: lint * fix: type change * fix: check plan type in publish * fix: permissions * fix: import change * fix: lint fix --- .../ee/services/audit-log/audit-log-queue.ts | 18 +-- .../ee/services/event/event-bus-service.ts | 6 +- .../ee/services/event/event-sse-service.ts | 6 +- .../src/ee/services/event/event-sse-stream.ts | 21 ++-- backend/src/ee/services/event/types.ts | 116 +++++++----------- .../ee/services/permission/default-roles.ts | 6 +- .../services/permission/project-permission.ts | 38 +++++- .../secret-approval-request-service.ts | 28 ++++- backend/src/server/routes/index.ts | 7 +- backend/src/server/routes/v1/event-router.ts | 18 ++- .../secret-import/secret-import-service.ts | 16 ++- .../secret-v2-bridge-service.ts | 76 ++++++++++-- backend/src/services/secret/secret-queue.ts | 84 ++++++++++++- .../permissions/project-permissions.mdx | 72 ++++++----- .../context/ProjectPermissionContext/types.ts | 30 ++++- .../ProjectRoleModifySection.utils.tsx | 77 ++++++++++-- .../components/RolePermissionsSection.tsx | 5 + .../SecretEventPermissionConditions.tsx | 22 ++++ .../components/SecretPermissionConditions.tsx | 3 +- 19 files changed, 461 insertions(+), 188 deletions(-) create mode 100644 frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 9e0f5f998..0914b8f6b 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,8 +1,6 @@ import { AxiosError, RawAxiosRequestHeaders } from "axios"; -import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; -import { TEventBusService } from "@app/ee/services/event/event-bus-service"; -import { TopicName, toPublishableEvent } from "@app/ee/services/event/types"; +import { SecretKeyEncoding } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto/cryptography"; import { logger } from "@app/lib/logger"; @@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = { queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; - eventBusService: TEventBusService; }; export type TAuditLogQueueServiceFactory = { @@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL, - eventBusService + auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep): Promise => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({ ) ); } - - const publishable = toPublishableEvent(event); - - if (publishable) { - await eventBusService.publish(TopicName.CoreServers, { - type: ProjectType.SecretManager, - source: "infiscal", - data: publishable.data - }); - } }); return { diff --git a/backend/src/ee/services/event/event-bus-service.ts b/backend/src/ee/services/event/event-bus-service.ts index 63f3b9fae..bb102c721 100644 --- a/backend/src/ee/services/event/event-bus-service.ts +++ b/backend/src/ee/services/event/event-bus-service.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { logger } from "@app/lib/logger"; -import { EventSchema, TopicName } from "./types"; +import { BusEventSchema, TopicName } from "./types"; export const eventBusFactory = (redis: Redis) => { const publisher = redis.duplicate(); @@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => { * @param topic - The topic to publish the event to. * @param event - The event data to publish. */ - const publish = async >(topic: TopicName, event: T) => { + const publish = async >(topic: TopicName, event: T) => { const json = JSON.stringify(event); return publisher.publish(topic, json, (err) => { @@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => { * @template T - The type of the event data, which should match the schema defined in EventSchema. * @returns A function that can be called to unsubscribe from the event bus. */ - const subscribe = >(fn: (data: T) => Promise | void) => { + const subscribe = >(fn: (data: T) => Promise | void) => { // Not using async await cause redis client's `on` method does not expect async listeners. const listener = (channel: string, message: string) => { try { diff --git a/backend/src/ee/services/event/event-sse-service.ts b/backend/src/ee/services/event/event-sse-service.ts index bb85f667c..dc52cc14c 100644 --- a/backend/src/ee/services/event/event-sse-service.ts +++ b/backend/src/ee/services/event/event-sse-service.ts @@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger"; import { TEventBusService } from "./event-bus-service"; import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; -import { EventData, RegisteredEvent, toBusEventName } from "./types"; +import { BusEvent, RegisteredEvent } from "./types"; const AUTH_REFRESH_INTERVAL = 60 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000; @@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { } }; - function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) { - const eventType = toBusEventName(event.data.eventType); + function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) { + const eventType = event.data.event; const match = registered.find((r) => r.event === eventType); if (!match) return; diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts index 258783c2e..dc7d15c79 100644 --- a/backend/src/ee/services/event/event-sse-stream.ts +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore"; import { conditionsMatcher } from "@app/lib/casl"; import { logger } from "@app/lib/logger"; -import { EventData, RegisteredEvent } from "./types"; +import { BusEvent, RegisteredEvent } from "./types"; export const getServerSentEventsHeaders = () => ({ @@ -55,7 +55,7 @@ export type EventStreamClient = { id: string; stream: Readable; open: () => Promise; - send: (data: EventMessage | EventData) => void; + send: (data: EventMessage | BusEvent) => void; ping: () => Promise; refresh: () => Promise; close: () => void; @@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien return { subject: options.type, action: "subscribe", - conditions: { - eventType: r.event, - ...(hasConditions - ? { - environment: r.conditions?.environmentSlug ?? "", - secretPath: { $glob: secretPath } - } - : {}) - } + conditions: hasConditions + ? { + environment: r.conditions?.environmentSlug ?? "", + secretPath: { $glob: secretPath } + } + : undefined }; }); @@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien // We will manually push data to the stream stream._read = () => {}; - const send = (data: EventMessage | EventData) => { + const send = (data: EventMessage | BusEvent) => { const chunk = serializeSseEvent(data); if (!stream.push(chunk)) { logger.debug("Backpressure detected: dropped manual event"); diff --git a/backend/src/ee/services/event/types.ts b/backend/src/ee/services/event/types.ts index 721e6ea13..5cbaf4c14 100644 --- a/backend/src/ee/services/event/types.ts +++ b/backend/src/ee/services/event/types.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { ProjectType } from "@app/db/schemas"; -import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types"; + +import { ProjectPermissionSecretEventActions } from "../permission/project-permission"; export enum TopicName { CoreServers = "infisical::core-servers" @@ -10,84 +11,44 @@ export enum TopicName { export enum BusEventName { CreateSecret = "secret:create", UpdateSecret = "secret:update", - DeleteSecret = "secret:delete" + DeleteSecret = "secret:delete", + ImportMutation = "secret:import-mutation" } -type PublisableEventTypes = - | EventType.CREATE_SECRET - | EventType.CREATE_SECRETS - | EventType.DELETE_SECRET - | EventType.DELETE_SECRETS - | EventType.UPDATE_SECRETS - | EventType.UPDATE_SECRET; - -export function toBusEventName(input: EventType) { - switch (input) { - case EventType.CREATE_SECRET: - case EventType.CREATE_SECRETS: - return BusEventName.CreateSecret; - case EventType.UPDATE_SECRET: - case EventType.UPDATE_SECRETS: - return BusEventName.UpdateSecret; - case EventType.DELETE_SECRET: - case EventType.DELETE_SECRETS: - return BusEventName.DeleteSecret; - default: - return null; - } -} - -const isBulkEvent = (event: Event): event is Extract } }> => { - return event.type.endsWith("-secrets"); // Feels so wrong -}; - -export const toPublishableEvent = (event: Event) => { - const name = toBusEventName(event.type); - - if (!name) return null; - - const e = event as Extract; - - if (isBulkEvent(e)) { - return { - name, - isBulk: true, - data: { - eventType: e.type, - payload: e.metadata.secrets.map((s) => ({ - environment: e.metadata.environment, - secretPath: e.metadata.secretPath, - ...s - })) - } - } as const; - } - - return { - name, - isBulk: false, - data: { - eventType: e.type, - payload: { - ...e.metadata, - environment: e.metadata.environment - } +export const Mappings = { + BusEventToAction(input: BusEventName) { + switch (input) { + case BusEventName.CreateSecret: + return ProjectPermissionSecretEventActions.SubscribeCreated; + case BusEventName.DeleteSecret: + return ProjectPermissionSecretEventActions.SubscribeDeleted; + case BusEventName.ImportMutation: + return ProjectPermissionSecretEventActions.SubscribeImportMutations; + case BusEventName.UpdateSecret: + return ProjectPermissionSecretEventActions.SubscribeUpdated; + default: + throw new Error("Unknown bus event name"); } - } as const; + } }; export const EventName = z.nativeEnum(BusEventName); const EventSecretPayload = z.object({ - secretPath: z.string().optional(), secretId: z.string(), + secretPath: z.string().optional(), secretKey: z.string(), environment: z.string() }); +const EventImportMutationPayload = z.object({ + secretPath: z.string(), + environment: z.string() +}); + export type EventSecret = z.infer; -export const EventSchema = z.object({ +export const BusEventSchema = z.object({ datacontenttype: z.literal("application/json").optional().default("application/json"), type: z.nativeEnum(ProjectType), source: z.string(), @@ -95,25 +56,38 @@ export const EventSchema = z.object({ .string() .optional() .default(() => new Date().toISOString()), - data: z.discriminatedUnion("eventType", [ + data: z.discriminatedUnion("event", [ z.object({ specversion: z.number().optional().default(1), - eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]), - payload: EventSecretPayload + event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]), + payload: z.union([EventSecretPayload, EventSecretPayload.array()]) }), z.object({ specversion: z.number().optional().default(1), - eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]), - payload: EventSecretPayload.array() + event: z.enum([BusEventName.ImportMutation]), + payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()]) }) // Add more event types as needed ]) }); -export type EventData = z.infer; +export type BusEvent = z.infer; + +type PublishableEventPayload = z.input["data"]; +type PublishableSecretEvent = Extract< + PublishableEventPayload, + { event: Exclude } +>["payload"]; + +export type PublishableEvent = { + created?: PublishableSecretEvent; + updated?: PublishableSecretEvent; + deleted?: PublishableSecretEvent; + importMutation?: Extract["payload"]; +}; export const EventRegisterSchema = z.object({ - event: EventName, + event: z.nativeEnum(BusEventName), conditions: z .object({ secretPath: z.string().optional().default("/"), diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 11d8e0518..cca4efaf2 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -161,8 +161,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -266,8 +265,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 899d364eb..ab8fea5df 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions { ReadValue = "readValue", Create = "create", Edit = "edit", - Delete = "delete", - Subscribe = "subscribe" + Delete = "delete" } export enum ProjectPermissionCmekActions { @@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions { Update = "update-configs" } +export enum ProjectPermissionSecretEventActions { + SubscribeCreated = "subscribe-on-created", + SubscribeUpdated = "subscribe-on-updated", + SubscribeDeleted = "subscribe-on-deleted", + SubscribeImportMutations = "subscribe-on-import-mutations" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -197,7 +203,8 @@ export enum ProjectPermissionSub { Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", - SecretScanningConfigs = "secret-scanning-configs" + SecretScanningConfigs = "secret-scanning-configs", + SecretEvents = "secret-events" } export type SecretSubjectFields = { @@ -205,7 +212,13 @@ export type SecretSubjectFields = { secretPath: string; secretName?: string; secretTags?: string[]; - eventType?: string; +}; + +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName?: string; + secretTags?: string[]; }; export type SecretFolderSubjectFields = { @@ -344,7 +357,11 @@ export type ProjectPermissionSet = | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] - | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs] + | [ + ProjectPermissionSecretEventActions, + ProjectPermissionSub.SecretEvents | (ForcedSubject & SecretEventSubjectFields) + ]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), - + z.object({ + subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretSyncConditionV2Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 1d1ca0a00..90a7b0e1f 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -952,13 +952,39 @@ export const secretApprovalRequestServiceFactory = ({ if (!folder) { throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` }); } + + const { secrets } = mergeStatus; + await secretQueueService.syncSecrets({ projectId, orgId: actorOrgId, secretPath: folder.path, environmentSlug: folder.environmentSlug, actorId, - actor + actor, + event: { + created: secrets.created.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + updated: secrets.updated.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + deleted: secrets.deleted.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })) + } }); if (isSoftEnforcement) { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8be9f3034..6dd7d190d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -560,8 +560,7 @@ export const registerRoutes = async ( queueService, projectDAL, licenseService, - auditLogStreamDAL, - eventBusService + auditLogStreamDAL }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); @@ -1121,7 +1120,9 @@ export const registerRoutes = async ( resourceMetadataDAL, folderCommitService, secretSyncQueue, - reminderService + reminderService, + eventBusService, + licenseService }); const projectService = projectServiceFactory({ diff --git a/backend/src/server/routes/v1/event-router.ts b/backend/src/server/routes/v1/event-router.ts index 19dc296bf..156819df9 100644 --- a/backend/src/server/routes/v1/event-router.ts +++ b/backend/src/server/routes/v1/event-router.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; -import { EventRegisterSchema } from "@app/ee/services/event/types"; -import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types"; +import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs"; import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => { req.body.register.forEach((r) => { const fields = { environment: r.conditions?.environmentSlug ?? "", - secretPath: r.conditions?.secretPath ?? "/", - eventType: r.event + secretPath: r.conditions?.secretPath ?? "/" }; - const allowed = info.permission.can( - ProjectPermissionSecretActions.Subscribe, - subject(ProjectPermissionSub.Secrets, fields) - ); + const action = Mappings.BusEventToAction(r.event); + + const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields)); if (!allowed) { throw new ForbiddenRequestError({ name: "PermissionDenied", - message: `You are not allowed to subscribe on secrets`, + message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`, details: { - event: fields.eventType, + action, environmentSlug: fields.environment, secretPath: fields.secretPath } diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 403484fc2..30cd93291 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({ projectId, environmentSlug: environment, actorId, - actor + actor, + event: { + importMutation: { + secretPath, + environment + } + } }); } @@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({ projectId, environmentSlug: environment, actor, - actorId + actorId, + event: { + importMutation: { + secretPath, + environment + } + } }); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 43daaf0df..101fd9b17 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({ actorId, actor, projectId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + created: { + secretId: secret.id, + environment: folder.environment.slug, + secretKey: secret.key, + secretPath + } + } }); } @@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({ actor, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + updated: { + secretId: secret.id, + environment: folder.environment.slug, + secretKey: secret.key, + secretPath + } + } }); } @@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({ actor, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + deleted: { + secretId: secretToDelete.id, + environment: folder.environment.slug, + secretKey: secretToDelete.key, + secretPath + } + } }); } @@ -1708,7 +1732,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + created: newSecrets.map((el) => ({ + secretId: el.id, + secretKey: el.key, + secretPath, + environment: folder.environment.slug + })) + } }); return newSecrets.map((el) => { @@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath: el.path, projectId, orgId: actorOrgId, - environmentSlug: environment + environmentSlug: environment, + event: { + updated: updatedSecrets.map((sec) => ({ + secretId: sec.id, + secretKey: sec.key, + secretPath: sec.secretPath, + environment + })) + } }) : undefined ) @@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + deleted: secretsDeleted.map((el) => ({ + secretId: el.id, + secretKey: el.key, + secretPath, + environment: folder.environment.slug + })) + } }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({ secretPath: destinationFolder.path, environmentSlug: destinationFolder.environment.slug, actorId, - actor + actor, + event: { + importMutation: { + secretPath: sourceFolder.path, + environment: sourceFolder.environment.slug + } + } }); } @@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({ secretPath: sourceFolder.path, environmentSlug: sourceFolder.environment.slug, actorId, - actor + actor, + event: { + importMutation: { + secretPath: sourceFolder.path, + environment: sourceFolder.environment.slug + } + } }); } diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 689e19673..dc5713901 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -5,6 +5,7 @@ import { Knex } from "knex"; import { ProjectMembershipRole, + ProjectType, ProjectUpgradeStatus, ProjectVersion, SecretType, @@ -12,6 +13,9 @@ import { TSecretVersionsV2 } from "@app/db/schemas"; import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TEventBusService } from "@app/ee/services/event/event-bus-service"; +import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; @@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = { folderCommitService: Pick; secretSyncQueue: Pick; reminderService: Pick; + eventBusService: TEventBusService; + licenseService: Pick; }; export type TGetSecrets = { @@ -172,7 +178,9 @@ export const secretQueueFactory = ({ resourceMetadataDAL, secretSyncQueue, folderCommitService, - reminderService + reminderService, + eventBusService, + licenseService }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -534,17 +542,70 @@ export const secretQueueFactory = ({ }); }; + const publishEvents = async (event: PublishableEvent) => { + if (event.created) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.CreateSecret, + payload: event.created + } + }); + } + + if (event.updated) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.UpdateSecret, + payload: event.updated + } + }); + } + + if (event.deleted) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.DeleteSecret, + payload: event.deleted + } + }); + } + + if (event.importMutation) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.ImportMutation, + payload: event.importMutation + } + }); + } + }; + const syncSecrets = async ({ // seperate de-dupe queue for integration sync and replication sync _deDupeQueue: deDupeQueue = {}, _depth: depth = 0, _deDupeReplicationQueue: deDupeReplicationQueue = {}, + event, ...dto - }: TSyncSecretsDTO) => { + }: TSyncSecretsDTO & { event?: PublishableEvent }) => { logger.info( `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` ); + const plan = await licenseService.getPlan(dto.orgId); + + if (event && plan.eventSubscriptions) { + await publishEvents(event); + } + const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); if ( !dto.excludeReplication @@ -565,7 +626,7 @@ export const secretQueueFactory = ({ _deDupeQueue: deDupeQueue, _deDupeReplicationQueue: deDupeReplicationQueue, _depth: depth - } as TSyncSecretsDTO, + } as unknown as TSyncSecretsDTO, { removeOnFail: true, removeOnComplete: true, @@ -689,6 +750,7 @@ export const secretQueueFactory = ({ isManual, projectId, secretPath, + depth = 1, deDupeQueue = {} } = job.data as TIntegrationSyncPayload; @@ -738,7 +800,13 @@ export const secretQueueFactory = ({ environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, _deDupeQueue: deDupeQueue, _depth: depth + 1, - excludeReplication: true + excludeReplication: true, + event: { + importMutation: { + secretPath: foldersGroupedById[folderId][0]?.path as string, + environment: foldersGroupedById[folderId][0]?.environmentSlug as string + } + } }) ) ); @@ -791,7 +859,13 @@ export const secretQueueFactory = ({ environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, _deDupeQueue: deDupeQueue, _depth: depth + 1, - excludeReplication: true + excludeReplication: true, + event: { + importMutation: { + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string + } + } }) ) ); diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index c60db5afc..c823cf4a7 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their Supports conditions and permission inversion | Action | Description | Notes | | -------- | ------------------------------- | ----- | -| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | +| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | | `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. | | `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. | -| `create` | Add new secrets to the project | | -| `edit` | Modify existing secret values | | -| `delete` | Remove secrets from the project | | +| `create` | Add new secrets to the project | | +| `edit` | Modify existing secret values | | +| `delete` | Remove secrets from the project | | #### Subject: `secret-folders` @@ -169,6 +169,15 @@ Supports conditions and permission inversion | `edit` | Modify secret imports | | `delete` | Remove secret imports | +#### Subject: `secret-events` + +| Action | Description | +| ------------------------------- | ------------------------------------------------------------- | +| `subscribe-on-created` | Subscribe to events when secrets are created | +| `subscribe-on-updated` | Subscribe to events when secrets are updated | +| `subscribe-on-deleted` | Subscribe to events when secrets are deleted | +| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports | + #### Subject: `secret-rollback` | Action | Description | @@ -178,10 +187,10 @@ Supports conditions and permission inversion #### Subject: `commits` -| Action | Description | -| -------- | ---------------------------------- | -| `read` | View commits and changes across folders | -| `perform-rollback` | Roll back commits changes and restore folders to previous state| +| Action | Description | +| ------------------ | --------------------------------------------------------------- | +| `read` | View commits and changes across folders | +| `perform-rollback` | Roll back commits changes and restore folders to previous state | #### Subject: `secret-approval` @@ -197,14 +206,14 @@ Supports conditions and permission inversion #### Subject: `secret-rotation` Supports conditions and permission inversion -| Action | Description | +| Action | Description | | ------------------------------ | ---------------------------------------------- | -| `read` | View secret rotation configurations | -| `read-generated-credentials` | View the generated credentials of a rotation | -| `create` | Set up secret rotation configurations | -| `edit` | Modify secret rotation configurations | -| `rotate-secrets` | Rotate the generated credentials of a rotation | -| `delete` | Remove secret rotation configurations | +| `read` | View secret rotation configurations | +| `read-generated-credentials` | View the generated credentials of a rotation | +| `create` | Set up secret rotation configurations | +| `edit` | Modify secret rotation configurations | +| `rotate-secrets` | Rotate the generated credentials of a rotation | +| `delete` | Remove secret rotation configurations | #### Subject: `secret-syncs` @@ -263,12 +272,12 @@ Supports conditions and permission inversion #### Subject: `certificates` -| Action | Description | -| -------------------- | ----------------------------- | -| `read` | View certificates | -| `read-private-key` | Read certificate private key | -| `create` | Issue new certificates | -| `delete` | Revoke or remove certificates | +| Action | Description | +| ------------------ | ----------------------------- | +| `read` | View certificates | +| `read-private-key` | Read certificate private key | +| `create` | Issue new certificates | +| `delete` | Revoke or remove certificates | #### Subject: `certificate-templates` @@ -330,8 +339,8 @@ Supports conditions and permission inversion #### Subject: `secret-scanning-data-sources` -| Action | Description | -| -------- | ---------------------------------------------------- | +| Action | Description | +| ---------------------------- | -------------------------------- | | `read-data-sources` | View Data Sources | | `create-data-sources` | Create new Data Sources | | `edit-data-sources` | Modify Data Sources | @@ -342,15 +351,14 @@ Supports conditions and permission inversion #### Subject: `secret-scanning-findings` -| Action | Description | -| -------- | --------------------------------- | -| `read-findings` | View Secret Scanning Findings | -| `update-findings` | Update Secret Scanning Findings | - +| Action | Description | +| ----------------- | ------------------------------- | +| `read-findings` | View Secret Scanning Findings | +| `update-findings` | Update Secret Scanning Findings | #### Subject: `secret-scanning-configs` -| Action | Description | -| ---------------- | ------------------------------------------------ | -| `read-configs` | View Secret Scanning Project Configuration | -| `update-configs` | Update Secret Scanning Project Configuration | +| Action | Description | +| ---------------- | -------------------------------------------- | +| `read-configs` | View Secret Scanning Project Configuration | +| `update-configs` | Update Secret Scanning Project Configuration | diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 3c0f9df32..dad72cada 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -143,6 +143,13 @@ export enum ProjectPermissionSecretScanningConfigActions { Update = "update-configs" } +export enum ProjectPermissionSecretEventActions { + SubscribeCreated = "subscribe-on-created", + SubscribeUpdated = "subscribe-on-updated", + SubscribeDeleted = "subscribe-on-deleted", + SubscribeImportMutations = "subscribe-on-import-mutations" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports - | ProjectPermissionSub.SecretRotation; + | ProjectPermissionSub.SecretRotation + | ProjectPermissionSub.SecretEvents; export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { [PermissionConditionOperators.$EQ]: "equal to", @@ -250,7 +258,8 @@ export enum ProjectPermissionSub { Commits = "commits", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", - SecretScanningConfigs = "secret-scanning-configs" + SecretScanningConfigs = "secret-scanning-configs", + SecretEvents = "secret-events" } export type SecretSubjectFields = { @@ -260,6 +269,14 @@ export type SecretSubjectFields = { secretTags: string[]; }; +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName: string; + secretTags: string[]; + action: string; +}; + export type SecretFolderSubjectFields = { environment: string; secretPath: string; @@ -403,6 +420,13 @@ export type ProjectPermissionSet = ProjectPermissionSub.SecretScanningDataSources ] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] - | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs] + | [ + ProjectPermissionSecretEventActions, + ( + | ProjectPermissionSub.SecretEvents + | (ForcedSubject & SecretEventSubjectFields) + ) + ]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 602ce457e..9ebc903a5 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -21,6 +21,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({ [ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional() }); +const SecretEventsPolicyActionSchema = z.object({ + [ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeUpdated]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeDeleted]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeImportMutations]: z.boolean().optional() +}); + const SecretRollbackPolicyActionSchema = z.object({ read: z.boolean().optional(), create: z.boolean().optional() @@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.SecretScanningFindings]: SecretScanningFindingPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretScanningConfigs]: - SecretScanningConfigPolicyActionSchema.array().default([]) + SecretScanningConfigPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({ + conditions: ConditionSchema + }) + .array() + .default([]) }) .partial() .optional() @@ -374,7 +387,8 @@ type TConditionalFields = | ProjectPermissionSub.SshHosts | ProjectPermissionSub.SecretRotation | ProjectPermissionSub.Identity - | ProjectPermissionSub.SecretSyncs; + | ProjectPermissionSub.SecretSyncs + | ProjectPermissionSub.SecretEvents; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -388,7 +402,8 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.PkiSubscribers || subject === ProjectPermissionSub.CertificateTemplates || - subject === ProjectPermissionSub.SecretSyncs; + subject === ProjectPermissionSub.SecretSyncs || + subject === ProjectPermissionSub.SecretEvents; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshHostGroups, - ProjectPermissionSub.SecretSyncs + ProjectPermissionSub.SecretSyncs, + ProjectPermissionSub.SecretEvents ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.SecretEvents) { + const canSubscribeCreate = action.includes( + ProjectPermissionSecretEventActions.SubscribeCreated + ); + const canSubscribeUpdate = action.includes( + ProjectPermissionSecretEventActions.SubscribeUpdated + ); + const canSubscribeDelete = action.includes( + ProjectPermissionSecretEventActions.SubscribeDeleted + ); + const canSubscribeImportMutations = action.includes( + ProjectPermissionSecretEventActions.SubscribeImportMutations + ); + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + "subscribe-on-created": canSubscribeCreate, + "subscribe-on-deleted": canSubscribeDelete, + "subscribe-on-updated": canSubscribeUpdate, + "subscribe-on-import-mutations": canSubscribeImportMutations, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [] + }); + + return; + } + // for other subjects const canRead = action.includes(ProjectPermissionActions.Read); const canEdit = action.includes(ProjectPermissionActions.Edit); @@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Read Value", value: ProjectPermissionSecretActions.ReadValue }, { label: "Modify", value: ProjectPermissionSecretActions.Edit }, { label: "Remove", value: ProjectPermissionSecretActions.Delete }, - { label: "Create", value: ProjectPermissionSecretActions.Create }, - { label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe } + { label: "Create", value: ProjectPermissionSecretActions.Create } ] }, [ProjectPermissionSub.SecretFolders]: { @@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { value: ProjectPermissionSecretScanningConfigActions.Update } ] + }, + [ProjectPermissionSub.SecretEvents]: { + title: "Secret Events", + actions: [ + { + label: "Subscribe on Created", + value: ProjectPermissionSecretEventActions.SubscribeCreated + }, + { + label: "Subscribe on Deleted", + value: ProjectPermissionSecretEventActions.SubscribeDeleted + }, + { + label: "Subscribe on Updated", + value: ProjectPermissionSecretEventActions.SubscribeUpdated + }, + { + label: "Subscribe on Import Mutations", + value: ProjectPermissionSecretEventActions.SubscribeImportMutations + } + ] } }; @@ -1564,7 +1626,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({ [ProjectPermissionSub.SecretRollback]: enabled, [ProjectPermissionSub.SecretRotation]: enabled, [ProjectPermissionSub.ServiceTokens]: enabled, - [ProjectPermissionSub.Commits]: enabled + [ProjectPermissionSub.Commits]: enabled, + [ProjectPermissionSub.SecretEvents]: enabled }); const KmsPermissionSubjects = (enabled = false) => ({ diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 4dbf84d92..f50446a8e 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -32,6 +32,7 @@ import { rolePermission2Form, TFormSchema } from "./ProjectRoleModifySection.utils"; +import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions"; import { SecretPermissionConditions } from "./SecretPermissionConditions"; import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions"; import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; @@ -72,6 +73,10 @@ export const renderConditionalComponents = ( return ; } + if (subject === ProjectPermissionSub.SecretEvents) { + return ; + } + return ; } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx new file mode 100644 index 000000000..801375f0c --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx @@ -0,0 +1,22 @@ +import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; + +import { ConditionsFields } from "./ConditionsFields"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const SecretEventPermissionConditions = ({ position = 0, isDisabled }: Props) => { + return ( + + ); +}; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx index b7f846bbd..9978b3e45 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx @@ -17,8 +17,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) { value: "environment", label: "Environment Slug" }, { value: "secretPath", label: "Secret Path" }, { value: "secretName", label: "Secret Name" }, - { value: "secretTags", label: "Secret Tags" }, - { value: "eventType", label: "Event Type" } + { value: "secretTags", label: "Secret Tags" } ]} /> );