Merge branch 'heads/main' into daniel/scim-deprovisioning-ui

This commit is contained in:
Daniel Hougaard
2025-08-25 16:56:09 +02:00
156 changed files with 6558 additions and 1109 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({
@@ -192,4 +209,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: {
@@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type";
import { EventType, filterableSecretEvents } from "./audit-log-types";
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
pruneAuditLog: (tx?: knex.Knex) => Promise<void>;
pruneAuditLog: () => Promise<void>;
find: (
arg: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string | undefined;
@@ -41,6 +41,10 @@ type TFindQuery = {
offset?: number;
};
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
export const auditLogDALFactory = (db: TDbClient) => {
const auditLogOrm = ormify(db, TableName.AuditLog);
@@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => {
};
// delete all audit log that have expired
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => {
const runPrune = async (dbClient: knex.Knex) => {
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => {
const today = new Date();
let deletedAuditLogIds: { id: string }[] = [];
let numberOfRetryOnFailure = 0;
let isRetrying = false;
const today = new Date();
let deletedAuditLogIds: { id: string }[] = [];
let numberOfRetryOnFailure = 0;
let isRetrying = false;
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
do {
try {
// eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
do {
try {
const findExpiredLogSubQuery = dbClient(TableName.AuditLog)
const findExpiredLogSubQuery = trx(TableName.AuditLog)
.where("expiresAt", "<", today)
.where("createdAt", "<", today) // to use audit log partition
.orderBy(`${TableName.AuditLog}.createdAt`, "desc")
@@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => {
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
// eslint-disable-next-line no-await-in-loop
deletedAuditLogIds = await dbClient(TableName.AuditLog)
.whereIn("id", findExpiredLogSubQuery)
.del()
.returning("id");
numberOfRetryOnFailure = 0; // reset
} catch (error) {
numberOfRetryOnFailure += 1;
logger.error(error, "Failed to delete audit log on pruning");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
};
const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id");
if (tx) {
await runPrune(tx);
} else {
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
await runPrune(trx);
});
}
return results;
});
numberOfRetryOnFailure = 0; // reset
} catch (error) {
numberOfRetryOnFailure += 1;
deletedAuditLogIds = [];
logger.error(error, "Failed to delete audit log on pruning");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
};
const create: TAuditLogDALFactory["create"] = async (tx) => {
@@ -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");
@@ -126,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
await redis.set(key, "1", "EX", 60);
stream.push("1");
send({ type: "ping" });
};
const close = () => {
+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("/"),
@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
auditLogStreams: false,
auditLogStreamLimit: 3,
samlSSO: false,
enforceGoogleSSO: false,
hsm: false,
oidcSSO: false,
scim: false,
@@ -47,6 +47,7 @@ export type TFeatureSet = {
auditLogStreamLimit: 3;
githubOrgSync: false;
samlSSO: false;
enforceGoogleSSO: false;
hsm: false;
oidcSSO: false;
secretAccessInsights: false;
@@ -13,6 +13,7 @@ import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretEventActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretScanningConfigActions,
ProjectPermissionSecretScanningDataSourceActions,
@@ -161,8 +162,7 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Delete,
ProjectPermissionSecretActions.Subscribe
ProjectPermissionSecretActions.Delete
],
ProjectPermissionSub.Secrets
);
@@ -253,6 +253,16 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretScanningConfigs
);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules;
};
@@ -266,8 +276,7 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Delete,
ProjectPermissionSecretActions.Subscribe
ProjectPermissionSecretActions.Delete
],
ProjectPermissionSub.Secrets
);
@@ -457,6 +466,16 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules;
};
@@ -507,6 +526,16 @@ const buildViewerPermissionRules = () => {
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
can(
[
ProjectPermissionSecretEventActions.SubscribeCreated,
ProjectPermissionSecretEventActions.SubscribeDeleted,
ProjectPermissionSecretEventActions.SubscribeUpdated,
ProjectPermissionSecretEventActions.SubscribeImportMutations
],
ProjectPermissionSub.SecretEvents
);
return rules;
};
@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
projectFavorites?: string[] | null | undefined;
customRoleSlug?: string | null | undefined;
orgAuthEnforced?: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
} & {
groups: {
id: string;
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
}[];
orgId: string;
orgAuthEnforced: boolean | null | undefined;
orgGoogleSsoAuthEnforced: boolean;
orgRole: OrgMembershipRole;
userId: string;
projectId: string;
@@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.OrgRoles),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"),
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
OrgMembershipsSchema.extend({
permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(),
orgGoogleSsoAuthEnforced: z.boolean(),
bypassOrgAuthEnabled: z.boolean(),
customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean()
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project),
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
orgId,
username,
orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole,
membershipId,
groupMembershipId,
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
}) => ({
orgId,
orgAuthEnforced,
orgGoogleSsoAuthEnforced,
orgRole: orgRole as OrgMembershipRole,
userId,
projectId,
@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
function validateOrgSSO(
actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"],
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
orgRole: OrgMembershipRole
) {
@@ -128,10 +129,16 @@ function validateOrgSSO(
throw new UnauthorizedError({ name: "No auth method defined" });
}
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
return;
}
// case: google sso is enforced, but the actor is not using google sso
if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) {
throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" });
}
// case: SAML SSO is enforced, but the actor is not using SAML SSO
if (
isOrgSsoEnforced &&
actorAuthMethod !== null &&
@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
validateOrgSSO(
authMethod,
membership.orgAuthEnforced,
membership.orgGoogleSsoAuthEnforced,
membership.bypassOrgAuthEnabled,
membership.role as OrgMembershipRole
);
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
validateOrgSSO(
authMethod,
userProjectPermission.orgAuthEnforced,
userProjectPermission.orgGoogleSsoAuthEnforced,
userProjectPermission.bypassOrgAuthEnabled,
userProjectPermission.orgRole
);
@@ -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
]);
@@ -953,13 +953,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) {