Merge branch 'main' of github.com:Infisical/infisical into sid/k8s-operator

This commit is contained in:
sidwebworks
2025-08-14 15:48:20 +05:30
76 changed files with 4300 additions and 541 deletions
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
body: z.object({
permissions: z.any().array(),
isTemporary: z.boolean(),
temporaryRange: z.string().optional(),
temporaryRange: z
.string()
.optional()
.transform((val, ctx) => {
if (!val || val === "permanent") return undefined;
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
note: z.string().max(255).optional()
}),
querystring: z.object({
@@ -190,4 +207,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
return { review };
}
});
server.route({
url: "/:requestId",
method: "PATCH",
schema: {
params: z.object({
requestId: z.string().trim()
}),
body: z.object({
temporaryRange: z.string().transform((val, ctx) => {
const parsedMs = ms(val);
if (typeof parsedMs !== "number" || parsedMs <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
});
return z.NEVER;
}
return val;
}),
editNote: z.string().max(255)
}),
response: {
200: z.object({
approval: AccessApprovalRequestsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
temporaryRange: req.body.temporaryRange,
editNote: req.body.editNote,
requestId: req.params.requestId
});
return { approval: request };
}
});
};
@@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
accessApprovalRequestReviewerDAL: Pick<
TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction"
"create" | "find" | "findOne" | "transaction" | "delete"
>;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
@@ -301,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
return { request: approval };
};
const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({
temporaryRange,
actorId,
actor,
actorOrgId,
actorAuthMethod,
editNote,
requestId
}) => {
const cfg = getConfig();
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
if (!accessApprovalRequest) {
throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` });
}
const { policy, requestedByUser } = accessApprovalRequest;
if (policy.deletedAt) {
throw new BadRequestError({
message: "The policy associated with this access request has been deleted."
});
}
const { membership, hasRole } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: accessApprovalRequest.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
if (!membership) {
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
}
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) {
throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" });
}
const project = await projectDAL.findById(accessApprovalRequest.projectId);
if (!project) {
throw new NotFoundError({
message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]`
});
}
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
throw new BadRequestError({ message: "The request has been closed" });
}
const editedByUser = await userDAL.findById(actorId);
if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" });
if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) {
if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) {
throw new BadRequestError({ message: "Updated access duration must be less than current access duration" });
}
}
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({
permissions: accessApprovalRequest.permissions
});
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
const approvalRequest = await accessApprovalRequestDAL.updateById(
requestId,
{
temporaryRange,
isTemporary: true,
editNote,
editedByUserId: actorId
},
tx
);
// reset review progress
await accessApprovalRequestReviewerDAL.delete(
{
requestId
},
tx
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`;
await triggerWorkflowIntegrationNotification({
input: {
notification: {
type: TriggerFeature.ACCESS_REQUEST_UPDATED,
payload: {
projectName: project.name,
requesterFullName,
isTemporary: true,
requesterEmail: requestedByUser.email as string,
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
}
},
projectId: project.id
},
dependencies: {
projectDAL,
projectSlackConfigDAL,
kmsService,
microsoftTeamsService,
projectMicrosoftTeamsConfigDAL
}
});
await smtpService.sendMail({
recipients: policy.approvers
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
.map((approver) => approver.email!),
subjectLine: "Access Approval Request Updated",
substitutions: {
projectName: project.name,
requesterFullName,
requesterEmail: requestedByUser.email,
isTemporary: true,
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorFullName,
editorEmail: editedByUser.email
},
template: SmtpTemplates.AccessApprovalRequestUpdated
});
return approvalRequest;
});
return { request: approval };
};
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug,
authorUserId,
@@ -650,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
return {
createAccessApprovalRequest,
updateAccessApprovalRequest,
listApprovalRequests,
reviewAccessRequest,
getCount
@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
note?: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalRequestDTO = {
requestId: string;
temporaryRange: string;
editNote: string;
} & Omit<TProjectPermission, "projectId">;
export type TListApprovalRequestsDTO = {
projectSlug: string;
authorUserId?: string;
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
privilegeDeletedAt?: Date | null | undefined;
};
}>;
updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{
request: {
status: string;
id: string;
createdAt: Date;
updatedAt: Date;
policyId: string;
isTemporary: boolean;
requestedByUserId: string;
privilegeId?: string | null | undefined;
requestedBy?: string | null | undefined;
temporaryRange?: string | null | undefined;
permissions?: unknown;
note?: string | null | undefined;
privilegeDeletedAt?: Date | null | undefined;
};
}>;
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
requests: {
policy: {
@@ -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 {
@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import axios from "axios";
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
type TCreateCouchbaseUser = {
name: string;
password: string;
access: {
privileges: string[];
resources: {
buckets: {
name: string;
scopes?: {
name: string;
collections?: string[];
}[];
}[];
};
}[];
};
type CouchbaseUserResponse = {
id: string;
uuid?: string;
};
const sanitizeCouchbaseUsername = (username: string): string => {
// Couchbase username restrictions:
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
// - Cannot begin with @ character
const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
let sanitized = forbiddenCharsPattern.replace(username, "-");
const leadingAtPattern = new RE2("^@+");
sanitized = leadingAtPattern.replace(sanitized, "");
if (!sanitized || sanitized.length === 0) {
return alphaNumericNanoId(12);
}
return sanitized;
};
/**
* Normalizes bucket configuration to handle wildcard (*) access consistently.
*
* Key behaviors:
* - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections
*
* @param buckets - Either a string or array of bucket configurations
* @returns Normalized bucket resources for Couchbase API
*/
const normalizeBucketConfiguration = (
buckets:
| string
| Array<{
name: string;
scopes?: Array<{
name: string;
collections?: string[];
}>;
}>
) => {
if (typeof buckets === "string") {
// Simple string format - either "*" or comma-separated bucket names
const bucketNames = buckets
.split(",")
.map((bucket) => bucket.trim())
.filter((bucket) => bucket.length > 0);
// If "*" is present anywhere, grant access to all buckets, scopes, and collections
if (bucketNames.includes("*") || buckets === "*") {
return [{ name: "*" }];
}
return bucketNames.map((bucketName) => ({ name: bucketName }));
}
// Array of bucket objects with scopes and collections
// Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections
const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*");
if (hasWildcardBucket) {
return [{ name: "*" }];
}
return buckets.map((bucket) => ({
name: bucket.name,
scopes: bucket.scopes?.map((scope) => ({
name: scope.name,
collections: scope.collections || []
}))
}));
};
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
const compiledUsername = compileUsernameTemplate({
usernameTemplate,
randomUsername,
identity
});
return sanitizeCouchbaseUsername(compiledUsername);
};
const generatePassword = (requirements?: PasswordRequirements): string => {
const {
length = 12,
required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 },
allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`"
} = requirements || {};
const lowercase = "abcdefghijklmnopqrstuvwxyz";
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const digits = "0123456789";
const symbols = allowedSymbols;
let password = "";
let remaining = length;
// Add required characters
for (let i = 0; i < required.lowercase; i += 1) {
password += lowercase[crypto.randomInt(lowercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.uppercase; i += 1) {
password += uppercase[crypto.randomInt(uppercase.length)];
remaining -= 1;
}
for (let i = 0; i < required.digits; i += 1) {
password += digits[crypto.randomInt(digits.length)];
remaining -= 1;
}
for (let i = 0; i < required.symbols; i += 1) {
password += symbols[crypto.randomInt(symbols.length)];
remaining -= 1;
}
// Fill remaining with random characters from all sets
const allChars = lowercase + uppercase + digits + symbols;
for (let i = 0; i < remaining; i += 1) {
password += allChars[crypto.randomInt(allChars.length)];
}
// Shuffle the password
return password
.split("")
.sort(() => crypto.randomInt(3) - 1)
.join("");
};
const couchbaseApiRequest = async (
method: string,
url: string,
apiKey: string,
data?: unknown
): Promise<CouchbaseUserResponse> => {
await blockLocalAndPrivateIpAddresses(url);
try {
const response = await axios({
method: method.toLowerCase() as "get" | "post" | "put" | "delete",
url,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
data: data || undefined,
timeout: 30000
});
return response.data as CouchbaseUserResponse;
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [apiKey]
});
throw new BadRequestError({
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
});
}
};
export const CouchbaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: object) => {
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
await blockLocalAndPrivateIpAddresses(providerInputs.url);
return providerInputs;
};
const validateConnection = async (inputs: unknown): Promise<boolean> => {
try {
const providerInputs = await validateProviderInputs(inputs as object);
// Test connection by trying to get organization info
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
return true;
} catch (error) {
throw new BadRequestError({
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const create = async ({
inputs,
usernameTemplate,
identity
}: {
inputs: unknown;
usernameTemplate?: string | null;
identity?: { name: string };
}) => {
const providerInputs = await validateProviderInputs(inputs as object);
const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements);
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
const userData: TCreateCouchbaseUser = {
name: username,
password,
access: [
{
privileges: providerInputs.roles,
resources: {
buckets: bucketResources
}
}
]
};
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
const userUuid = response?.id || response?.uuid || username;
return {
entityId: userUuid,
data: {
username,
password
}
};
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs as object);
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
return { entityId };
};
const renew = async (_inputs: unknown, entityId: string) => {
// Couchbase Cloud API doesn't support renewing user credentials
// The user remains valid until explicitly deleted
return { entityId };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};
@@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra";
import { CouchbaseProvider } from "./couchbase";
import { ElasticSearchProvider } from "./elastic-search";
import { GcpIamProvider } from "./gcp-iam";
import { GithubProvider } from "./github";
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
[DynamicSecretProviders.GcpIam]: GcpIamProvider(),
[DynamicSecretProviders.Github]: GithubProvider()
[DynamicSecretProviders.Github]: GithubProvider(),
[DynamicSecretProviders.Couchbase]: CouchbaseProvider()
});
@@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({
.describe("The private key generated for your GitHub App.")
});
export const DynamicSecretCouchbaseSchema = z.object({
url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"),
orgId: z.string().trim().min(1).describe("Organization ID"),
projectId: z.string().trim().min(1).describe("Project ID"),
clusterId: z.string().trim().min(1).describe("Cluster ID"),
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z
.union([
z
.string()
.trim()
.min(1)
.default("*")
.refine((val) => {
if (val.includes(",")) {
const buckets = val
.split(",")
.map((b) => b.trim())
.filter((b) => b.length > 0);
if (buckets.includes("*") && buckets.length > 1) {
return false;
}
}
return true;
}, "Cannot combine '*' with other bucket names"),
z
.array(
z.object({
name: z.string().trim().min(1).describe("Bucket name"),
scopes: z
.array(
z.object({
name: z.string().trim().min(1).describe("Scope name"),
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names")
})
)
.optional()
.describe("Scopes within the bucket")
})
)
.refine((buckets) => {
const hasWildcard = buckets.some((bucket) => bucket.name === "*");
if (hasWildcard && buckets.length > 1) {
return false;
}
return true;
}, "Cannot combine '*' bucket with other buckets")
])
.default("*")
.describe(
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
),
passwordRequirements: z
.object({
length: z.number().min(8, "Password must be at least 8 characters").max(128),
required: z
.object({
lowercase: z.number().min(1, "At least 1 lowercase character required"),
uppercase: z.number().min(1, "At least 1 uppercase character required"),
digits: z.number().min(1, "At least 1 digit required"),
symbols: z.number().min(1, "At least 1 special character required")
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 128;
}, "Sum of required characters cannot exceed 128"),
allowedSymbols: z
.string()
.refine((symbols) => {
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
return !forbiddenChars.some((char) => symbols?.includes(char));
}, "Cannot contain: < > ; . * & | £")
.optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.optional()
.describe("Password generation requirements for Couchbase"),
auth: z.object({
apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key")
})
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
Kubernetes = "kubernetes",
Vertica = "vertica",
GcpIam = "gcp-iam",
Github = "github"
Github = "github",
Couchbase = "couchbase"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema })
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema })
]);
export type TDynamicProviderFns = {
@@ -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");
+45 -71
View File
@@ -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) {