mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 03:31:27 +00:00
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
This commit is contained in:
@@ -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<TProjectDALFactory, "findById">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
eventBusService: TEventBusService;
|
||||
};
|
||||
|
||||
export type TAuditLogQueueServiceFactory = {
|
||||
@@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({
|
||||
queueService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
auditLogStreamDAL,
|
||||
eventBusService
|
||||
auditLogStreamDAL
|
||||
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
|
||||
const pushToLog = async (data: TCreateAuditLogDTO) => {
|
||||
await queueService.queue<QueueName.AuditLog>(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 {
|
||||
|
||||
@@ -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 <T extends z.input<typeof EventSchema>>(topic: TopicName, event: T) => {
|
||||
const publish = async <T extends z.input<typeof BusEventSchema>>(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 = <T extends z.infer<typeof EventSchema>>(fn: (data: T) => Promise<void> | void) => {
|
||||
const subscribe = <T extends z.infer<typeof BusEventSchema>>(fn: (data: T) => Promise<void> | void) => {
|
||||
// Not using async await cause redis client's `on` method does not expect async listeners.
|
||||
const listener = (channel: string, message: string) => {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
send: (data: EventMessage | EventData) => void;
|
||||
send: (data: EventMessage | BusEvent) => void;
|
||||
ping: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
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");
|
||||
|
||||
@@ -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<Event, { metadata: { secrets: Array<unknown> } }> => {
|
||||
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<Event, { type: PublisableEventTypes }>;
|
||||
|
||||
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<typeof EventSecretPayload>;
|
||||
|
||||
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<typeof EventSchema>;
|
||||
export type BusEvent = z.infer<typeof BusEventSchema>;
|
||||
|
||||
type PublishableEventPayload = z.input<typeof BusEventSchema>["data"];
|
||||
type PublishableSecretEvent = Extract<
|
||||
PublishableEventPayload,
|
||||
{ event: Exclude<BusEventName, BusEventName.ImportMutation> }
|
||||
>["payload"];
|
||||
|
||||
export type PublishableEvent = {
|
||||
created?: PublishableSecretEvent;
|
||||
updated?: PublishableSecretEvent;
|
||||
deleted?: PublishableSecretEvent;
|
||||
importMutation?: Extract<PublishableEventPayload, { event: BusEventName.ImportMutation }>["payload"];
|
||||
};
|
||||
|
||||
export const EventRegisterSchema = z.object({
|
||||
event: EventName,
|
||||
event: z.nativeEnum(BusEventName),
|
||||
conditions: z
|
||||
.object({
|
||||
secretPath: z.string().optional().default("/"),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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<ProjectPermissionSub.SecretEvents> & 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
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user