diff --git a/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts b/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts new file mode 100644 index 000000000..687ee5570 --- /dev/null +++ b/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote"); + const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId"); + + if (!hasEditNoteCol || !hasEditedByUserId) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + if (!hasEditedByUserId) { + t.uuid("editedByUserId").nullable(); + t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + } + + if (!hasEditNoteCol) { + t.string("editNote").nullable(); + } + }); + } +} + +export async function down(knex: Knex): Promise { + const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote"); + const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId"); + + if (hasEditNoteCol || hasEditedByUserId) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + if (hasEditedByUserId) { + t.dropColumn("editedByUserId"); + } + + if (hasEditNoteCol) { + t.dropColumn("editNote"); + } + }); + } +} diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index 6a6f09148..14997a974 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({ requestedByUserId: z.string().uuid(), note: z.string().nullable().optional(), privilegeDeletedAt: z.date().nullable().optional(), - status: z.string().default("pending") + status: z.string().default("pending"), + editedByUserId: z.string().uuid().nullable().optional(), + editNote: z.string().nullable().optional() }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index c9e7b0d5c..83378c16e 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -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 }; + } + }); }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 58f5c57db..0f05bd5af 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = { accessApprovalPolicyDAL: Pick; accessApprovalRequestReviewerDAL: Pick< TAccessApprovalRequestReviewerDALFactory, - "create" | "find" | "findOne" | "transaction" + "create" | "find" | "findOne" | "transaction" | "delete" >; groupDAL: Pick; projectMembershipDAL: Pick; @@ -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 diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 88a46192b..ed027835a 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = { note?: string; } & Omit; +export type TUpdateAccessApprovalRequestDTO = { + requestId: string; + temporaryRange: string; + editNote: string; +} & Omit; + 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: { diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 9e0f5f998..0914b8f6b 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,8 +1,6 @@ import { AxiosError, RawAxiosRequestHeaders } from "axios"; -import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; -import { TEventBusService } from "@app/ee/services/event/event-bus-service"; -import { TopicName, toPublishableEvent } from "@app/ee/services/event/types"; +import { SecretKeyEncoding } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto/cryptography"; import { logger } from "@app/lib/logger"; @@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = { queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; - eventBusService: TEventBusService; }; export type TAuditLogQueueServiceFactory = { @@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL, - eventBusService + auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep): Promise => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({ ) ); } - - const publishable = toPublishableEvent(event); - - if (publishable) { - await eventBusService.publish(TopicName.CoreServers, { - type: ProjectType.SecretManager, - source: "infiscal", - data: publishable.data - }); - } }); return { diff --git a/backend/src/ee/services/dynamic-secret/providers/couchbase.ts b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts new file mode 100644 index 000000000..c59f1c2b3 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts @@ -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 => { + 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 => { + 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 + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 7fd65f98d..184b9fc89 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -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() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 528ea414a..ae1bcfc25 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -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 = { diff --git a/backend/src/ee/services/event/event-bus-service.ts b/backend/src/ee/services/event/event-bus-service.ts index 63f3b9fae..bb102c721 100644 --- a/backend/src/ee/services/event/event-bus-service.ts +++ b/backend/src/ee/services/event/event-bus-service.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { logger } from "@app/lib/logger"; -import { EventSchema, TopicName } from "./types"; +import { BusEventSchema, TopicName } from "./types"; export const eventBusFactory = (redis: Redis) => { const publisher = redis.duplicate(); @@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => { * @param topic - The topic to publish the event to. * @param event - The event data to publish. */ - const publish = async >(topic: TopicName, event: T) => { + const publish = async >(topic: TopicName, event: T) => { const json = JSON.stringify(event); return publisher.publish(topic, json, (err) => { @@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => { * @template T - The type of the event data, which should match the schema defined in EventSchema. * @returns A function that can be called to unsubscribe from the event bus. */ - const subscribe = >(fn: (data: T) => Promise | void) => { + const subscribe = >(fn: (data: T) => Promise | void) => { // Not using async await cause redis client's `on` method does not expect async listeners. const listener = (channel: string, message: string) => { try { diff --git a/backend/src/ee/services/event/event-sse-service.ts b/backend/src/ee/services/event/event-sse-service.ts index bb85f667c..dc52cc14c 100644 --- a/backend/src/ee/services/event/event-sse-service.ts +++ b/backend/src/ee/services/event/event-sse-service.ts @@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger"; import { TEventBusService } from "./event-bus-service"; import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; -import { EventData, RegisteredEvent, toBusEventName } from "./types"; +import { BusEvent, RegisteredEvent } from "./types"; const AUTH_REFRESH_INTERVAL = 60 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000; @@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { } }; - function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) { - const eventType = toBusEventName(event.data.eventType); + function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) { + const eventType = event.data.event; const match = registered.find((r) => r.event === eventType); if (!match) return; diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts index 139263f10..13e18374f 100644 --- a/backend/src/ee/services/event/event-sse-stream.ts +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore"; import { conditionsMatcher } from "@app/lib/casl"; import { logger } from "@app/lib/logger"; -import { EventData, RegisteredEvent } from "./types"; +import { BusEvent, RegisteredEvent } from "./types"; export const getServerSentEventsHeaders = () => ({ @@ -55,7 +55,7 @@ export type EventStreamClient = { id: string; stream: Readable; open: () => Promise; - send: (data: EventMessage | EventData) => void; + send: (data: EventMessage | BusEvent) => void; ping: () => Promise; refresh: () => Promise; close: () => void; @@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien return { subject: options.type, action: "subscribe", - conditions: { - eventType: r.event, - ...(hasConditions - ? { - environment: r.conditions?.environmentSlug ?? "", - secretPath: { $glob: secretPath } - } - : {}) - } + conditions: hasConditions + ? { + environment: r.conditions?.environmentSlug ?? "", + secretPath: { $glob: secretPath } + } + : undefined }; }); @@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien // We will manually push data to the stream stream._read = () => {}; - const send = (data: EventMessage | EventData) => { + const send = (data: EventMessage | BusEvent) => { const chunk = serializeSseEvent(data); if (!stream.push(chunk)) { logger.debug("Backpressure detected: dropped manual event"); diff --git a/backend/src/ee/services/event/types.ts b/backend/src/ee/services/event/types.ts index 721e6ea13..5cbaf4c14 100644 --- a/backend/src/ee/services/event/types.ts +++ b/backend/src/ee/services/event/types.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { ProjectType } from "@app/db/schemas"; -import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types"; + +import { ProjectPermissionSecretEventActions } from "../permission/project-permission"; export enum TopicName { CoreServers = "infisical::core-servers" @@ -10,84 +11,44 @@ export enum TopicName { export enum BusEventName { CreateSecret = "secret:create", UpdateSecret = "secret:update", - DeleteSecret = "secret:delete" + DeleteSecret = "secret:delete", + ImportMutation = "secret:import-mutation" } -type PublisableEventTypes = - | EventType.CREATE_SECRET - | EventType.CREATE_SECRETS - | EventType.DELETE_SECRET - | EventType.DELETE_SECRETS - | EventType.UPDATE_SECRETS - | EventType.UPDATE_SECRET; - -export function toBusEventName(input: EventType) { - switch (input) { - case EventType.CREATE_SECRET: - case EventType.CREATE_SECRETS: - return BusEventName.CreateSecret; - case EventType.UPDATE_SECRET: - case EventType.UPDATE_SECRETS: - return BusEventName.UpdateSecret; - case EventType.DELETE_SECRET: - case EventType.DELETE_SECRETS: - return BusEventName.DeleteSecret; - default: - return null; - } -} - -const isBulkEvent = (event: Event): event is Extract } }> => { - return event.type.endsWith("-secrets"); // Feels so wrong -}; - -export const toPublishableEvent = (event: Event) => { - const name = toBusEventName(event.type); - - if (!name) return null; - - const e = event as Extract; - - if (isBulkEvent(e)) { - return { - name, - isBulk: true, - data: { - eventType: e.type, - payload: e.metadata.secrets.map((s) => ({ - environment: e.metadata.environment, - secretPath: e.metadata.secretPath, - ...s - })) - } - } as const; - } - - return { - name, - isBulk: false, - data: { - eventType: e.type, - payload: { - ...e.metadata, - environment: e.metadata.environment - } +export const Mappings = { + BusEventToAction(input: BusEventName) { + switch (input) { + case BusEventName.CreateSecret: + return ProjectPermissionSecretEventActions.SubscribeCreated; + case BusEventName.DeleteSecret: + return ProjectPermissionSecretEventActions.SubscribeDeleted; + case BusEventName.ImportMutation: + return ProjectPermissionSecretEventActions.SubscribeImportMutations; + case BusEventName.UpdateSecret: + return ProjectPermissionSecretEventActions.SubscribeUpdated; + default: + throw new Error("Unknown bus event name"); } - } as const; + } }; export const EventName = z.nativeEnum(BusEventName); const EventSecretPayload = z.object({ - secretPath: z.string().optional(), secretId: z.string(), + secretPath: z.string().optional(), secretKey: z.string(), environment: z.string() }); +const EventImportMutationPayload = z.object({ + secretPath: z.string(), + environment: z.string() +}); + export type EventSecret = z.infer; -export const EventSchema = z.object({ +export const BusEventSchema = z.object({ datacontenttype: z.literal("application/json").optional().default("application/json"), type: z.nativeEnum(ProjectType), source: z.string(), @@ -95,25 +56,38 @@ export const EventSchema = z.object({ .string() .optional() .default(() => new Date().toISOString()), - data: z.discriminatedUnion("eventType", [ + data: z.discriminatedUnion("event", [ z.object({ specversion: z.number().optional().default(1), - eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]), - payload: EventSecretPayload + event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]), + payload: z.union([EventSecretPayload, EventSecretPayload.array()]) }), z.object({ specversion: z.number().optional().default(1), - eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]), - payload: EventSecretPayload.array() + event: z.enum([BusEventName.ImportMutation]), + payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()]) }) // Add more event types as needed ]) }); -export type EventData = z.infer; +export type BusEvent = z.infer; + +type PublishableEventPayload = z.input["data"]; +type PublishableSecretEvent = Extract< + PublishableEventPayload, + { event: Exclude } +>["payload"]; + +export type PublishableEvent = { + created?: PublishableSecretEvent; + updated?: PublishableSecretEvent; + deleted?: PublishableSecretEvent; + importMutation?: Extract["payload"]; +}; export const EventRegisterSchema = z.object({ - event: EventName, + event: z.nativeEnum(BusEventName), conditions: z .object({ secretPath: z.string().optional().default("/"), diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 11d8e0518..cca4efaf2 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -161,8 +161,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -266,8 +265,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 899d364eb..ab8fea5df 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions { ReadValue = "readValue", Create = "create", Edit = "edit", - Delete = "delete", - Subscribe = "subscribe" + Delete = "delete" } export enum ProjectPermissionCmekActions { @@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions { Update = "update-configs" } +export enum ProjectPermissionSecretEventActions { + SubscribeCreated = "subscribe-on-created", + SubscribeUpdated = "subscribe-on-updated", + SubscribeDeleted = "subscribe-on-deleted", + SubscribeImportMutations = "subscribe-on-import-mutations" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -197,7 +203,8 @@ export enum ProjectPermissionSub { Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", - SecretScanningConfigs = "secret-scanning-configs" + SecretScanningConfigs = "secret-scanning-configs", + SecretEvents = "secret-events" } export type SecretSubjectFields = { @@ -205,7 +212,13 @@ export type SecretSubjectFields = { secretPath: string; secretName?: string; secretTags?: string[]; - eventType?: string; +}; + +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName?: string; + secretTags?: string[]; }; export type SecretFolderSubjectFields = { @@ -344,7 +357,11 @@ export type ProjectPermissionSet = | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] - | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs] + | [ + ProjectPermissionSecretEventActions, + ProjectPermissionSub.SecretEvents | (ForcedSubject & SecretEventSubjectFields) + ]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), - + z.object({ + subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretSyncConditionV2Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 1d1ca0a00..90a7b0e1f 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -952,13 +952,39 @@ export const secretApprovalRequestServiceFactory = ({ if (!folder) { throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` }); } + + const { secrets } = mergeStatus; + await secretQueueService.syncSecrets({ projectId, orgId: actorOrgId, secretPath: folder.path, environmentSlug: folder.environmentSlug, actorId, - actor + actor, + event: { + created: secrets.created.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + updated: secrets.updated.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + deleted: secrets.deleted.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })) + } }); if (isSoftEnforcement) { diff --git a/backend/src/lib/workflow-integrations/trigger-notification.ts b/backend/src/lib/workflow-integrations/trigger-notification.ts index 58411bdb0..355761f73 100644 --- a/backend/src/lib/workflow-integrations/trigger-notification.ts +++ b/backend/src/lib/workflow-integrations/trigger-notification.ts @@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); if (slackConfig) { - if (notification.type === TriggerFeature.ACCESS_REQUEST) { + if ( + notification.type === TriggerFeature.ACCESS_REQUEST || + notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED + ) { const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { await sendSlackNotification({ @@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl } if (microsoftTeamsConfig) { - if (notification.type === TriggerFeature.ACCESS_REQUEST) { + if ( + notification.type === TriggerFeature.ACCESS_REQUEST || + notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED + ) { if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( microsoftTeamsConfig.accessRequestChannels diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts index c18ecb496..f8f55eadd 100644 --- a/backend/src/lib/workflow-integrations/types.ts +++ b/backend/src/lib/workflow-integrations/types.ts @@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack export enum TriggerFeature { SECRET_APPROVAL = "secret-approval", - ACCESS_REQUEST = "access-request" + ACCESS_REQUEST = "access-request", + ACCESS_REQUEST_UPDATED = "access-request-updated" } export type TNotification = @@ -34,6 +35,22 @@ export type TNotification = approvalUrl: string; note?: string; }; + } + | { + type: TriggerFeature.ACCESS_REQUEST_UPDATED; + payload: { + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + projectName: string; + permissions: string[]; + approvalUrl: string; + editNote?: string; + editorFullName?: string; + editorEmail?: string; + }; }; export type TTriggerWorkflowNotificationDTO = { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8be9f3034..6dd7d190d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -560,8 +560,7 @@ export const registerRoutes = async ( queueService, projectDAL, licenseService, - auditLogStreamDAL, - eventBusService + auditLogStreamDAL }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); @@ -1121,7 +1120,9 @@ export const registerRoutes = async ( resourceMetadataDAL, folderCommitService, secretSyncQueue, - reminderService + reminderService, + eventBusService, + licenseService }); const projectService = projectServiceFactory({ diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 3f3b58b5e..ea3726c22 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { email: z.string().email().trim(), password: z.string().trim(), firstName: z.string().trim(), - lastName: z.string().trim().optional(), - protectedKey: z.string().trim(), - protectedKeyIV: z.string().trim(), - protectedKeyTag: z.string().trim(), - publicKey: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - encryptedPrivateKeyIV: z.string().trim(), - encryptedPrivateKeyTag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim() + lastName: z.string().trim().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/event-router.ts b/backend/src/server/routes/v1/event-router.ts index 19dc296bf..156819df9 100644 --- a/backend/src/server/routes/v1/event-router.ts +++ b/backend/src/server/routes/v1/event-router.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; -import { EventRegisterSchema } from "@app/ee/services/event/types"; -import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types"; +import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs"; import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => { req.body.register.forEach((r) => { const fields = { environment: r.conditions?.environmentSlug ?? "", - secretPath: r.conditions?.secretPath ?? "/", - eventType: r.event + secretPath: r.conditions?.secretPath ?? "/" }; - const allowed = info.permission.can( - ProjectPermissionSecretActions.Subscribe, - subject(ProjectPermissionSub.Secrets, fields) - ); + const action = Mappings.BusEventToAction(r.event); + + const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields)); if (!allowed) { throw new ForbiddenRequestError({ name: "PermissionDenied", - message: `You are not allowed to subscribe on secrets`, + message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`, details: { - event: fields.eventType, + action, environmentSlug: fields.environment, secretPath: fields.secretPath } diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index b71a744fd..8b9091364 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -283,6 +283,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Get project details by slug", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") }), diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index e4bd2e10d..a71036d82 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => return token; }; +const parseGitHubLinkHeader = (linkHeader: string | undefined): Record => { + if (!linkHeader) return {}; + + const links: Record = {}; + const segments = linkHeader.split(","); + const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/); + + for (const segment of segments) { + const match = re.exec(segment.trim()); + if (match) { + const url = match[1]; + const rel = match[2]; + links[rel] = url; + } + } + return links; +}; + function extractNextPageUrl(linkHeader: string | undefined): string | null { - if (!linkHeader) return null; - - const links = linkHeader.split(","); - const nextLink = links.find((link) => link.includes('rel="next"')); - - if (!nextLink) return null; - - const match = new RE2(/<([^>]+)>/).exec(nextLink); - return match ? match[1] : null; + const links = parseGitHubLinkHeader(linkHeader); + return links.next || null; } export const makePaginatedGitHubRequest = async ( @@ -164,27 +175,83 @@ export const makePaginatedGitHubRequest = async ( const token = method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); - let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + + const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + const initialUrlObj = new URL(baseUrl); + initialUrlObj.searchParams.set("per_page", "100"); + let results: T[] = []; - let i = 0; + const maxIterations = 1000; - while (url && i < 1000) { - // eslint-disable-next-line no-await-in-loop - const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { - url, - method: "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28" - } - }); + // Make initial request to get link header + const firstResponse: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { + url: initialUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); - const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); - results = results.concat(items); + const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]); + results = results.concat(firstPageItems); - url = extractNextPageUrl(response.headers.link as string | undefined); - i += 1; + const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined); + const lastPageUrl = linkHeader.last; + + // If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last + if (lastPageUrl) { + const lastPageParam = new URL(lastPageUrl).searchParams.get("page"); + const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1; + + const pageRequests: Promise>[] = []; + + for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) { + const pageUrlObj = new URL(initialUrlObj.toString()); + pageUrlObj.searchParams.set("page", pageNum.toString()); + + pageRequests.push( + requestWithGitHubGateway(appConnection, gatewayService, { + url: pageUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }) + ); + } + const responses = await Promise.all(pageRequests); + + for (const response of responses) { + const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); + results = results.concat(items); + } + } else { + // Fallback in case last link isn't present + let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined); + let i = 1; + + while (url && i < maxIterations) { + // eslint-disable-next-line no-await-in-loop + const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { + url, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); + results = results.concat(items); + + url = extractNextPageUrl(response.headers.link as string | undefined); + i += 1; + } } return results; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts index 38fc99819..e940fda54 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts @@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => { }; } + case TriggerFeature.ACCESS_REQUEST_UPDATED: { + const { payload } = notification; + + const adaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Updated access approval request pending for review", + weight: "Bolder", + size: "Large" + }, + { + type: "TextBlock", + text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ + payload.isTemporary ? "temporary" : "permanent" + } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`, + wrap: true + }, + { + type: "TextBlock", + text: `The following permissions are requested: ${payload.permissions.join(", ")}`, + wrap: true + }, + payload.editNote + ? { + type: "TextBlock", + text: `**Editor Note**: ${payload.editNote}`, + wrap: true + } + : null + ].filter(Boolean), + actions: [ + { + type: "Action.OpenUrl", + title: "View request in Infisical", + url: payload.approvalUrl + } + ] + }; + + return { + adaptiveCard + }; + } + default: { throw new BadRequestError({ message: "Teams notification type not supported." diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 403484fc2..30cd93291 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({ projectId, environmentSlug: environment, actorId, - actor + actor, + event: { + importMutation: { + secretPath, + environment + } + } }); } @@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({ projectId, environmentSlug: environment, actor, - actorId + actorId, + event: { + importMutation: { + secretPath, + environment + } + } }); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 43daaf0df..101fd9b17 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({ actorId, actor, projectId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + created: { + secretId: secret.id, + environment: folder.environment.slug, + secretKey: secret.key, + secretPath + } + } }); } @@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({ actor, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + updated: { + secretId: secret.id, + environment: folder.environment.slug, + secretKey: secret.key, + secretPath + } + } }); } @@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({ actor, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + deleted: { + secretId: secretToDelete.id, + environment: folder.environment.slug, + secretKey: secretToDelete.key, + secretPath + } + } }); } @@ -1708,7 +1732,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + created: newSecrets.map((el) => ({ + secretId: el.id, + secretKey: el.key, + secretPath, + environment: folder.environment.slug + })) + } }); return newSecrets.map((el) => { @@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath: el.path, projectId, orgId: actorOrgId, - environmentSlug: environment + environmentSlug: environment, + event: { + updated: updatedSecrets.map((sec) => ({ + secretId: sec.id, + secretKey: sec.key, + secretPath: sec.secretPath, + environment + })) + } }) : undefined ) @@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + deleted: secretsDeleted.map((el) => ({ + secretId: el.id, + secretKey: el.key, + secretPath, + environment: folder.environment.slug + })) + } }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({ secretPath: destinationFolder.path, environmentSlug: destinationFolder.environment.slug, actorId, - actor + actor, + event: { + importMutation: { + secretPath: sourceFolder.path, + environment: sourceFolder.environment.slug + } + } }); } @@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({ secretPath: sourceFolder.path, environmentSlug: sourceFolder.environment.slug, actorId, - actor + actor, + event: { + importMutation: { + secretPath: sourceFolder.path, + environment: sourceFolder.environment.slug + } + } }); } diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 689e19673..dc5713901 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -5,6 +5,7 @@ import { Knex } from "knex"; import { ProjectMembershipRole, + ProjectType, ProjectUpgradeStatus, ProjectVersion, SecretType, @@ -12,6 +13,9 @@ import { TSecretVersionsV2 } from "@app/db/schemas"; import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TEventBusService } from "@app/ee/services/event/event-bus-service"; +import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; @@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = { folderCommitService: Pick; secretSyncQueue: Pick; reminderService: Pick; + eventBusService: TEventBusService; + licenseService: Pick; }; export type TGetSecrets = { @@ -172,7 +178,9 @@ export const secretQueueFactory = ({ resourceMetadataDAL, secretSyncQueue, folderCommitService, - reminderService + reminderService, + eventBusService, + licenseService }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -534,17 +542,70 @@ export const secretQueueFactory = ({ }); }; + const publishEvents = async (event: PublishableEvent) => { + if (event.created) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.CreateSecret, + payload: event.created + } + }); + } + + if (event.updated) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.UpdateSecret, + payload: event.updated + } + }); + } + + if (event.deleted) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.DeleteSecret, + payload: event.deleted + } + }); + } + + if (event.importMutation) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.ImportMutation, + payload: event.importMutation + } + }); + } + }; + const syncSecrets = async ({ // seperate de-dupe queue for integration sync and replication sync _deDupeQueue: deDupeQueue = {}, _depth: depth = 0, _deDupeReplicationQueue: deDupeReplicationQueue = {}, + event, ...dto - }: TSyncSecretsDTO) => { + }: TSyncSecretsDTO & { event?: PublishableEvent }) => { logger.info( `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` ); + const plan = await licenseService.getPlan(dto.orgId); + + if (event && plan.eventSubscriptions) { + await publishEvents(event); + } + const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); if ( !dto.excludeReplication @@ -565,7 +626,7 @@ export const secretQueueFactory = ({ _deDupeQueue: deDupeQueue, _deDupeReplicationQueue: deDupeReplicationQueue, _depth: depth - } as TSyncSecretsDTO, + } as unknown as TSyncSecretsDTO, { removeOnFail: true, removeOnComplete: true, @@ -689,6 +750,7 @@ export const secretQueueFactory = ({ isManual, projectId, secretPath, + depth = 1, deDupeQueue = {} } = job.data as TIntegrationSyncPayload; @@ -738,7 +800,13 @@ export const secretQueueFactory = ({ environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, _deDupeQueue: deDupeQueue, _depth: depth + 1, - excludeReplication: true + excludeReplication: true, + event: { + importMutation: { + secretPath: foldersGroupedById[folderId][0]?.path as string, + environment: foldersGroupedById[folderId][0]?.environmentSlug as string + } + } }) ) ); @@ -791,7 +859,13 @@ export const secretQueueFactory = ({ environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, _deDupeQueue: deDupeQueue, _depth: depth + 1, - excludeReplication: true + excludeReplication: true, + event: { + importMutation: { + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string + } + } }) ) ); diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index a111d5372..88db1a84d 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -115,6 +115,44 @@ User Note: ${payload.note}` payloadBlocks }; } + case TriggerFeature.ACCESS_REQUEST_UPDATED: { + const { payload } = notification; + const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ + payload.isTemporary ? "temporary" : "permanent" + } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. + +The following permissions are requested: ${payload.permissions.join(", ")} + +View the request and approve or deny it <${payload.approvalUrl}|here>.${ + payload.editNote + ? ` +Editor Note: ${payload.editNote}` + : "" + }`; + + const payloadBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: "Updated access approval request pending for review", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: messageBody + } + } + ]; + + return { + payloadMessage: messageBody, + payloadBlocks + }; + } default: { throw new BadRequestError({ message: "Slack notification type not supported." diff --git a/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx b/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx new file mode 100644 index 000000000..2ade57dac --- /dev/null +++ b/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx @@ -0,0 +1,95 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface AccessApprovalRequestUpdatedTemplateProps + extends Omit { + projectName: string; + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + expiresIn: string; + permissions: string[]; + editNote: string; + editorFullName: string; + editorEmail: string; + approvalUrl: string; +} + +export const AccessApprovalRequestUpdatedTemplate = ({ + projectName, + siteUrl, + requesterFullName, + requesterEmail, + isTemporary, + secretPath, + environment, + expiresIn, + permissions, + editNote, + editorEmail, + editorFullName, + approvalUrl +}: AccessApprovalRequestUpdatedTemplateProps) => { + return ( + + + An access approval request was updated and is pending your review for the project {projectName} + +
+ + {editorFullName} ({editorEmail}) has + updated the access request submitted by {requesterFullName} ( + {requesterEmail}) for {secretPath} in + the {environment} environment. + + + {isTemporary && ( + + This access will expire {expiresIn} after approval. + + )} + + The following permissions are requested: + + {permissions.map((permission) => ( + + - {permission} + + ))} + + Editor Note: "{editNote}" + +
+
+ Review Request +
+
+ ); +}; + +export default AccessApprovalRequestUpdatedTemplate; + +AccessApprovalRequestUpdatedTemplate.PreviewProps = { + requesterFullName: "Abigail Williams", + requesterEmail: "abigail@infisical.com", + isTemporary: true, + secretPath: "/api/secrets", + environment: "Production", + siteUrl: "https://infisical.com", + projectName: "Example Project", + expiresIn: "1 day", + permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"], + editNote: "Too permissive, they only need 3 days", + editorEmail: "john@infisical.com", + editorFullName: "John Smith" +} as AccessApprovalRequestUpdatedTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 840a98cad..209cd672b 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -1,4 +1,5 @@ export * from "./AccessApprovalRequestTemplate"; +export * from "./AccessApprovalRequestUpdatedTemplate"; export * from "./EmailMfaTemplate"; export * from "./EmailVerificationTemplate"; export * from "./ExternalImportFailedTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index ac56f0ee4..500d0d89c 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger"; import { AccessApprovalRequestTemplate, + AccessApprovalRequestUpdatedTemplate, EmailMfaTemplate, EmailVerificationTemplate, ExternalImportFailedTemplate, @@ -54,6 +55,7 @@ export enum SmtpTemplates { EmailMfa = "emailMfa", UnlockAccount = "unlockAccount", AccessApprovalRequest = "accessApprovalRequest", + AccessApprovalRequestUpdated = "accessApprovalRequestUpdated", AccessSecretRequestBypassed = "accessSecretRequestBypassed", SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? @@ -96,6 +98,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate, + [SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate, [SmtpTemplates.EmailVerification]: EmailVerificationTemplate, [SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate, [SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 93ac5cadb..03091a255 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -11,7 +11,6 @@ import { validateOverrides } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; @@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({ return updatedServerCfg; }; - const adminSignUp = async ({ - lastName, - firstName, - email, - salt, - password, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - ip, - userAgent - }: TAdminSignUpDTO) => { + const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => { const appCfg = getConfig(); const sanitizedEmail = email.trim().toLowerCase(); const existingUser = await userDAL.findOne({ username: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); - const privateKey = await getUserPrivateKey(password, { - encryptionVersion: 2, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - }); - const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); - const { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); const userInfo = await userDAL.transaction(async (tx) => { const newUser = await userDAL.create( { @@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({ ); const userEnc = await userDAL.createUserEncryption( { - salt, encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - verifier, userId: newUser.id, - hashedPassword, - serverEncryptedPrivateKey: ciphertext, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyEncoding: encoding + hashedPassword }, tx ); + return { user: newUser, enc: userEnc }; }); @@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({ }, tx ); - const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(password); - const encKeys = await generateUserSrpKeys(sanitizedEmail, password); + + const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); const userEnc = await userDAL.createUserEncryption( { userId: newUser.id, encryptionVersion: 2, - protectedKey: encKeys.protectedKey, - protectedKeyIV: encKeys.protectedKeyIV, - protectedKeyTag: encKeys.protectedKeyTag, - publicKey: encKeys.publicKey, - encryptedPrivateKey: encKeys.encryptedPrivateKey, - iv: encKeys.encryptedPrivateKeyIV, - tag: encKeys.encryptedPrivateKeyTag, - salt: encKeys.salt, - verifier: encKeys.verifier, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext + hashedPassword }, tx ); diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index b57a015a4..919b0541c 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env"; export type TAdminSignUpDTO = { email: string; password: string; - publicKey: string; - salt: string; lastName?: string; - verifier: string; firstName: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; ip: string; userAgent: string; }; diff --git a/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx new file mode 100644 index 000000000..2b9896f8a --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project By Slug" +openapi: "GET /api/v2/workspace/{slug}" +--- diff --git a/docs/docs.json b/docs/docs.json index 02cfd236a..894004b27 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -439,6 +439,7 @@ "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/azure-entra-id", "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/couchbase", "documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/gcp-iam", "documentation/platform/dynamic-secrets/github", @@ -995,6 +996,7 @@ { "group": "Projects", "pages": [ + "api-reference/endpoints/workspaces/get-workspace-by-slug", "api-reference/endpoints/workspaces/create-workspace", "api-reference/endpoints/workspaces/delete-workspace", "api-reference/endpoints/workspaces/get-workspace", diff --git a/docs/documentation/platform/dynamic-secrets/couchbase.mdx b/docs/documentation/platform/dynamic-secrets/couchbase.mdx new file mode 100644 index 000000000..6a803c1f8 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/couchbase.mdx @@ -0,0 +1,259 @@ +--- +title: "Couchbase" +description: "Learn how to dynamically generate Couchbase Database user credentials." +--- + +The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions. + +## Prerequisite + +Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key). + +The API Key must have permission to manage database users in your Couchbase Cloud organization and project. + +## Set up Dynamic Secrets with Couchbase + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + The Couchbase Cloud API URL + + + + Your Couchbase Cloud organization ID + + + + Your Couchbase Cloud project ID + + + + Your Couchbase Cloud cluster ID where users will be created + + + + Database credential roles to assign to the generated user. Available options: + - **read**: Read access to bucket data (alias for data_reader) + - **write**: Read and write access to bucket data (alias for data_writer) + + + + Specify bucket access configuration: + - Use `*` for access to all buckets + - Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets + - Use Advanced Bucket Configuration for granular scope and collection access + + + + Your Couchbase Cloud API Key for authentication + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png) + + + + + ![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png) + + + Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections + + + When Advanced Bucket Configuration is enabled, you can configure: + + + List of buckets with optional scope and collection specifications: + - **Bucket Name**: Name of the bucket (e.g., travel-sample) + - **Scopes**: Optional array of scopes within the bucket + - **Scope Name**: Name of the scope (e.g., inventory, _default) + - **Collections**: Optional array of collection names within the scope + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random 5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + + Optional password generation requirements for Couchbase users: + + + Length of the generated password + + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum special characters (default: 1) + + + + Special characters allowed in passwords. Cannot contain: `< > ; . * & | £` + + + + Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £` + + + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png) + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png) + + + + +## Advanced Bucket Configuration Examples + +The advanced bucket configuration allows you to specify granular access control: + +### Example 1: Specific Bucket Access +```json +[ + { + "name": "travel-sample" + } +] +``` + +### Example 2: Bucket with Specific Scopes +```json +[ + { + "name": "travel-sample", + "scopes": [ + { + "name": "inventory" + }, + { + "name": "_default" + } + ] + } +] +``` + +### Example 3: Bucket with Scopes and Collections +```json +[ + { + "name": "travel-sample", + "scopes": [ + { + "name": "inventory", + "collections": ["airport", "airline"] + }, + { + "name": "_default", + "collections": ["users"] + } + ] + } +] +``` + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the expiration time of the lease or delete a lease before its set time to live. + +![Provision Lease](../../../images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases + +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. +![Provision Lease](../../../images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + + +## Couchbase Roles and Permissions + +The Couchbase dynamic secret integration supports the following database credential roles: + +- **read**: Provides read-only access to bucket data +- **write**: Provides read and write access to bucket data + + +These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration. + + +## Troubleshooting + +### Common Issues + +1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users +2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key +3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write) +4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible \ No newline at end of file diff --git a/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png new file mode 100644 index 000000000..9167f1a48 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png new file mode 100644 index 000000000..b2ad14bd7 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png new file mode 100644 index 000000000..6a1b5ce91 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png new file mode 100644 index 000000000..c1fad1a22 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png differ diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index 0a2d52ce1..9a4e5f342 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. + + For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting. + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index c60db5afc..c823cf4a7 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their Supports conditions and permission inversion | Action | Description | Notes | | -------- | ------------------------------- | ----- | -| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | +| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | | `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. | | `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. | -| `create` | Add new secrets to the project | | -| `edit` | Modify existing secret values | | -| `delete` | Remove secrets from the project | | +| `create` | Add new secrets to the project | | +| `edit` | Modify existing secret values | | +| `delete` | Remove secrets from the project | | #### Subject: `secret-folders` @@ -169,6 +169,15 @@ Supports conditions and permission inversion | `edit` | Modify secret imports | | `delete` | Remove secret imports | +#### Subject: `secret-events` + +| Action | Description | +| ------------------------------- | ------------------------------------------------------------- | +| `subscribe-on-created` | Subscribe to events when secrets are created | +| `subscribe-on-updated` | Subscribe to events when secrets are updated | +| `subscribe-on-deleted` | Subscribe to events when secrets are deleted | +| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports | + #### Subject: `secret-rollback` | Action | Description | @@ -178,10 +187,10 @@ Supports conditions and permission inversion #### Subject: `commits` -| Action | Description | -| -------- | ---------------------------------- | -| `read` | View commits and changes across folders | -| `perform-rollback` | Roll back commits changes and restore folders to previous state| +| Action | Description | +| ------------------ | --------------------------------------------------------------- | +| `read` | View commits and changes across folders | +| `perform-rollback` | Roll back commits changes and restore folders to previous state | #### Subject: `secret-approval` @@ -197,14 +206,14 @@ Supports conditions and permission inversion #### Subject: `secret-rotation` Supports conditions and permission inversion -| Action | Description | +| Action | Description | | ------------------------------ | ---------------------------------------------- | -| `read` | View secret rotation configurations | -| `read-generated-credentials` | View the generated credentials of a rotation | -| `create` | Set up secret rotation configurations | -| `edit` | Modify secret rotation configurations | -| `rotate-secrets` | Rotate the generated credentials of a rotation | -| `delete` | Remove secret rotation configurations | +| `read` | View secret rotation configurations | +| `read-generated-credentials` | View the generated credentials of a rotation | +| `create` | Set up secret rotation configurations | +| `edit` | Modify secret rotation configurations | +| `rotate-secrets` | Rotate the generated credentials of a rotation | +| `delete` | Remove secret rotation configurations | #### Subject: `secret-syncs` @@ -263,12 +272,12 @@ Supports conditions and permission inversion #### Subject: `certificates` -| Action | Description | -| -------------------- | ----------------------------- | -| `read` | View certificates | -| `read-private-key` | Read certificate private key | -| `create` | Issue new certificates | -| `delete` | Revoke or remove certificates | +| Action | Description | +| ------------------ | ----------------------------- | +| `read` | View certificates | +| `read-private-key` | Read certificate private key | +| `create` | Issue new certificates | +| `delete` | Revoke or remove certificates | #### Subject: `certificate-templates` @@ -330,8 +339,8 @@ Supports conditions and permission inversion #### Subject: `secret-scanning-data-sources` -| Action | Description | -| -------- | ---------------------------------------------------- | +| Action | Description | +| ---------------------------- | -------------------------------- | | `read-data-sources` | View Data Sources | | `create-data-sources` | Create new Data Sources | | `edit-data-sources` | Modify Data Sources | @@ -342,15 +351,14 @@ Supports conditions and permission inversion #### Subject: `secret-scanning-findings` -| Action | Description | -| -------- | --------------------------------- | -| `read-findings` | View Secret Scanning Findings | -| `update-findings` | Update Secret Scanning Findings | - +| Action | Description | +| ----------------- | ------------------------------- | +| `read-findings` | View Secret Scanning Findings | +| `update-findings` | Update Secret Scanning Findings | #### Subject: `secret-scanning-configs` -| Action | Description | -| ---------------- | ------------------------------------------------ | -| `read-configs` | View Secret Scanning Project Configuration | -| `update-configs` | Update Secret Scanning Project Configuration | +| Action | Description | +| ---------------- | -------------------------------------------- | +| `read-configs` | View Secret Scanning Project Configuration | +| `update-configs` | Update Secret Scanning Project Configuration | diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 85138be9c..c2ca3e141 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag ## Cross-Region Replication for Disaster Recovery (Infisical Cloud) -To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed. +To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed. - ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down. -- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage. +- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues. -With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages. ## Penetration testing diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 167e1c7b5..fdfefcf93 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -53,8 +53,8 @@ "project-id": "Project ID", "save-changes": "Save Changes", "saved": "Saved", - "drop-zone": "Drag and drop a .env, .json, or .yml file here.", - "drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.", + "drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.", + "drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.", "role": "Role", "role_admin": "admin", "display-name": "Display Name", diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index fdb9aea5e..83f058c7a 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -1,4 +1,4 @@ -import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormLabel, Tooltip } from "../v2"; @@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( label={label} icon={ + Examples: 30m, 1h, 3d, etc.{" "} - More + See More Examples{" "} + } @@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( } diff --git a/frontend/src/components/utilities/parseSecrets.ts b/frontend/src/components/utilities/parseSecrets.ts index 3e5a57edf..e33f25ace 100644 --- a/frontend/src/components/utilities/parseSecrets.ts +++ b/frontend/src/components/utilities/parseSecrets.ts @@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) { return result; } + +function detectSeparator(csvContent: string): string { + const firstLine = csvContent.split("\n")[0]; + const separators = [",", ";", "\t", "|"]; + + const counts = separators.map((sep) => ({ + separator: sep, + count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length + })); + + const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max)); + + return detected.count > 0 ? detected.separator : ","; +} + +export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] { + let csvContent: string; + if (typeof src === "string") { + csvContent = src; + } else { + csvContent = new TextDecoder("utf-8").decode(src); + } + + const separator = detectSeparator(csvContent); + const lines = csvContent.replace(/\r\n?/g, "\n").split("\n"); + const matrix: string[][] = []; + + lines.forEach((line) => { + if (line.trim() !== "") { + const cells: string[] = []; + let currentCell = ""; + let inQuote = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuote && nextChar === '"') { + currentCell += '"'; + i += 1; + } else { + inQuote = !inQuote; + } + } else if (char === separator && !inQuote) { + cells.push(currentCell.trim()); + currentCell = ""; + } else { + currentCell += char; + } + } + cells.push(currentCell.trim()); + matrix.push(cells); + } + }); + + return matrix; +} diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 3c0f9df32..dad72cada 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -143,6 +143,13 @@ export enum ProjectPermissionSecretScanningConfigActions { Update = "update-configs" } +export enum ProjectPermissionSecretEventActions { + SubscribeCreated = "subscribe-on-created", + SubscribeUpdated = "subscribe-on-updated", + SubscribeDeleted = "subscribe-on-deleted", + SubscribeImportMutations = "subscribe-on-import-mutations" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports - | ProjectPermissionSub.SecretRotation; + | ProjectPermissionSub.SecretRotation + | ProjectPermissionSub.SecretEvents; export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { [PermissionConditionOperators.$EQ]: "equal to", @@ -250,7 +258,8 @@ export enum ProjectPermissionSub { Commits = "commits", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", - SecretScanningConfigs = "secret-scanning-configs" + SecretScanningConfigs = "secret-scanning-configs", + SecretEvents = "secret-events" } export type SecretSubjectFields = { @@ -260,6 +269,14 @@ export type SecretSubjectFields = { secretTags: string[]; }; +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName: string; + secretTags: string[]; + action: string; +}; + export type SecretFolderSubjectFields = { environment: string; secretPath: string; @@ -403,6 +420,13 @@ export type ProjectPermissionSet = ProjectPermissionSub.SecretScanningDataSources ] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] - | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs] + | [ + ProjectPermissionSecretEventActions, + ( + | ProjectPermissionSub.SecretEvents + | (ForcedSubject & SecretEventSubjectFields) + ) + ]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/helpers/key.ts b/frontend/src/helpers/key.ts deleted file mode 100644 index c516826f1..000000000 --- a/frontend/src/helpers/key.ts +++ /dev/null @@ -1,84 +0,0 @@ -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; - -/** - * @param {Object} obj - * @param {Number} obj.encryptionVersion - * @param {String} obj.encryptedPrivateKey - * @param {String} obj.iv - * @param {String} obj.tag - * @param {String} obj.password - * @param {String} obj.salt - * @param {String} obj.protectedKey - * @param {String} obj.protectedKeyIV - * @param {String} obj.protectedKeyTag - */ -const decryptPrivateKeyHelper = async ({ - encryptionVersion, - encryptedPrivateKey, - iv, - tag, - password, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag -}: { - encryptionVersion: number; - encryptedPrivateKey: string; - iv: string; - tag: string; - password: string; - salt: string; - protectedKey?: string; - protectedKeyIV?: string; - protectedKeyTag?: string; -}) => { - let privateKey; - try { - if (encryptionVersion === 1) { - privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0") - }); - } else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) { - const derivedKey = await deriveArgonKey({ - password, - salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); - - if (!derivedKey) throw new Error("Failed to generate derived key"); - - const key = Aes256Gcm.decrypt({ - ciphertext: protectedKey, - iv: protectedKeyIV, - tag: protectedKeyTag, - secret: Buffer.from(derivedKey.hash) - }); - - // decrypt back the private key - privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: Buffer.from(key, "hex") - }); - } else { - throw new Error("Insufficient details to decrypt private key"); - } - } catch { - throw new Error("Failed to decrypt private key"); - } - - return privateKey; -}; - -export { decryptPrivateKeyHelper }; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 69637243f..a31ec11a0 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request"; import { accessApprovalKeys } from "./queries"; import { TAccessApproval, + TAccessApprovalRequest, TCreateAccessPolicyDTO, TCreateAccessRequestDTO, TDeleteSecretPolicyDTO, - TUpdateAccessPolicyDTO + TUpdateAccessPolicyDTO, + TUpdateAccessRequestDTO } from "./types"; export const useCreateAccessApprovalPolicy = () => { @@ -134,6 +136,25 @@ export const useCreateAccessRequest = () => { }); }; +export const useUpdateAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ requestId, ...payload }) => { + const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>( + `/api/v1/access-approvals/requests/${requestId}`, + payload + ); + + return data.approval; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug) + }); + } + }); +}; + export const useReviewAccessRequest = () => { const queryClient = useQueryClient(); return useMutation< diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index c53f9d013..44f260f66 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -24,7 +24,7 @@ export const accessApprovalKeys = { envSlug?: string, requestedBy?: string, bypassReason?: string - ) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const, + ) => ["access-approvals-requests", projectSlug, envSlug, requestedBy, bypassReason] as const, getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) => [{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const }; diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index fc14352f8..0e4c01350 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -103,6 +103,8 @@ export type TAccessApprovalRequest = { }[]; note?: string; + editNote?: string; + editedByUserId?: string; }; export type TAccessApproval = { @@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = { note?: string; } & Omit; +export type TUpdateAccessRequestDTO = { + requestId: string; + editNote: string; + temporaryRange: string; + projectSlug: string; +}; + export type TGetAccessApprovalRequestsDTO = { projectSlug: string; policyId?: string; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index fe2cc3eed..aca8b5bb3 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -74,15 +74,6 @@ export type TCreateAdminUserDTO = { password: string; firstName: string; lastName?: string; - protectedKey: string; - protectedKeyTag: string; - protectedKeyIV: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - publicKey: string; - verifier: string; - salt: string; }; export type AdminGetOrganizationsFilters = { diff --git a/frontend/src/hooks/api/dynamicSecret/mutation.ts b/frontend/src/hooks/api/dynamicSecret/mutation.ts index e347e4493..2d04334e6 100644 --- a/frontend/src/hooks/api/dynamicSecret/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecret/mutation.ts @@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => { ); return data.dynamicSecret; }, - onSuccess: (_, { path, environmentSlug, projectSlug }) => { + onSuccess: (_, { path, environmentSlug, projectSlug, name }) => { // TODO: optimize but currently don't pass projectId queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); queryClient.invalidateQueries({ queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug }) }); + queryClient.invalidateQueries({ + queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name }) + }); } }); }; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 84b618153..289bc1d04 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -37,7 +37,8 @@ export enum DynamicSecretProviders { Kubernetes = "kubernetes", Vertica = "vertica", GcpIam = "gcp-iam", - Github = "github" + Github = "github", + Couchbase = "couchbase" } export enum KubernetesDynamicSecretCredentialType { @@ -353,6 +354,38 @@ export type TDynamicSecretProvider = installationId: number; privateKey: string; }; + } + | { + type: DynamicSecretProviders.Couchbase; + inputs: { + url: string; + orgId: string; + projectId: string; + clusterId: string; + roles: string[]; + buckets: + | string + | Array<{ + name: string; + scopes?: Array<{ + name: string; + collections?: string[]; + }>; + }>; + passwordRequirements?: { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; + }; + auth: { + apiKey: string; + }; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/lib/crypto/index.ts b/frontend/src/lib/crypto/index.ts deleted file mode 100644 index d722b68ea..000000000 --- a/frontend/src/lib/crypto/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import crypto from "crypto"; - -import jsrp from "jsrp"; - -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey, generateKeyPair } from "@app/components/utilities/cryptography/crypto"; - -export const generateUserPassKey = async ( - email: string, - password: string, - fipsEnabled: boolean -) => { - // eslint-disable-next-line new-cap - const client = new jsrp.client(); - - const { publicKey, privateKey } = await generateKeyPair(fipsEnabled); - - await new Promise((resolve) => { - client.init({ username: email, password }, () => resolve(null)); - }); - const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>( - (resolve, reject) => { - client.createVerifier((err, res) => { - if (err) return reject(err); - return resolve(res); - }); - } - ); - - const derivedKey = await deriveArgonKey({ - password, - salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); - - if (!derivedKey) throw new Error("Failed to derive key from password"); - - const key = crypto.randomBytes(32); - - // create encrypted private key by encrypting the private - // key with the symmetric key [key] - const { - ciphertext: encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - } = Aes256Gcm.encrypt({ - text: privateKey, - secret: key - }); - - // create the protected key by encrypting the symmetric key - // [key] with the derived key - const { - ciphertext: protectedKey, - iv: protectedKeyIV, - tag: protectedKeyTag - } = Aes256Gcm.encrypt({ - text: key.toString("hex"), - secret: Buffer.from(derivedKey.hash) - }); - - return { - protectedKey, - protectedKeyTag, - protectedKeyIV, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - publicKey, - verifier, - salt, - privateKey - }; -}; diff --git a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx index 19c1537a2..f7f3649ca 100644 --- a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx @@ -12,7 +12,6 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api"; -import { generateUserPassKey } from "@app/lib/crypto"; const formSchema = z .object({ @@ -48,17 +47,11 @@ export const SignUpPage = () => { // avoid multi submission if (isSubmitting) return; try { - const { privateKey, ...userPass } = await generateUserPassKey( - email, - password, - config.fipsEnabled - ); const res = await createAdminUser({ email, password, firstName, - lastName, - ...userPass + lastName }); SecurityClient.setToken(res.token); diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index faba9ef37..e0bc547e0 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { SingleValue } from "react-select"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -128,10 +128,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: caType === CaType.ACME }); - const availableConnections: TAvailableAppConnection[] = [ - ...(availableRoute53Connections || []), - ...(availableCloudflareConnections || []) - ]; + const availableConnections: TAvailableAppConnection[] = useMemo( + () => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])], + [availableRoute53Connections, availableCloudflareConnections] + ); const isPending = isRoute53Pending || isCloudflarePending; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 602ce457e..9ebc903a5 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -21,6 +21,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({ [ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional() }); +const SecretEventsPolicyActionSchema = z.object({ + [ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeUpdated]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeDeleted]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeImportMutations]: z.boolean().optional() +}); + const SecretRollbackPolicyActionSchema = z.object({ read: z.boolean().optional(), create: z.boolean().optional() @@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.SecretScanningFindings]: SecretScanningFindingPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretScanningConfigs]: - SecretScanningConfigPolicyActionSchema.array().default([]) + SecretScanningConfigPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({ + conditions: ConditionSchema + }) + .array() + .default([]) }) .partial() .optional() @@ -374,7 +387,8 @@ type TConditionalFields = | ProjectPermissionSub.SshHosts | ProjectPermissionSub.SecretRotation | ProjectPermissionSub.Identity - | ProjectPermissionSub.SecretSyncs; + | ProjectPermissionSub.SecretSyncs + | ProjectPermissionSub.SecretEvents; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -388,7 +402,8 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.PkiSubscribers || subject === ProjectPermissionSub.CertificateTemplates || - subject === ProjectPermissionSub.SecretSyncs; + subject === ProjectPermissionSub.SecretSyncs || + subject === ProjectPermissionSub.SecretEvents; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshHostGroups, - ProjectPermissionSub.SecretSyncs + ProjectPermissionSub.SecretSyncs, + ProjectPermissionSub.SecretEvents ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.SecretEvents) { + const canSubscribeCreate = action.includes( + ProjectPermissionSecretEventActions.SubscribeCreated + ); + const canSubscribeUpdate = action.includes( + ProjectPermissionSecretEventActions.SubscribeUpdated + ); + const canSubscribeDelete = action.includes( + ProjectPermissionSecretEventActions.SubscribeDeleted + ); + const canSubscribeImportMutations = action.includes( + ProjectPermissionSecretEventActions.SubscribeImportMutations + ); + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + "subscribe-on-created": canSubscribeCreate, + "subscribe-on-deleted": canSubscribeDelete, + "subscribe-on-updated": canSubscribeUpdate, + "subscribe-on-import-mutations": canSubscribeImportMutations, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [] + }); + + return; + } + // for other subjects const canRead = action.includes(ProjectPermissionActions.Read); const canEdit = action.includes(ProjectPermissionActions.Edit); @@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Read Value", value: ProjectPermissionSecretActions.ReadValue }, { label: "Modify", value: ProjectPermissionSecretActions.Edit }, { label: "Remove", value: ProjectPermissionSecretActions.Delete }, - { label: "Create", value: ProjectPermissionSecretActions.Create }, - { label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe } + { label: "Create", value: ProjectPermissionSecretActions.Create } ] }, [ProjectPermissionSub.SecretFolders]: { @@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { value: ProjectPermissionSecretScanningConfigActions.Update } ] + }, + [ProjectPermissionSub.SecretEvents]: { + title: "Secret Events", + actions: [ + { + label: "Subscribe on Created", + value: ProjectPermissionSecretEventActions.SubscribeCreated + }, + { + label: "Subscribe on Deleted", + value: ProjectPermissionSecretEventActions.SubscribeDeleted + }, + { + label: "Subscribe on Updated", + value: ProjectPermissionSecretEventActions.SubscribeUpdated + }, + { + label: "Subscribe on Import Mutations", + value: ProjectPermissionSecretEventActions.SubscribeImportMutations + } + ] } }; @@ -1564,7 +1626,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({ [ProjectPermissionSub.SecretRollback]: enabled, [ProjectPermissionSub.SecretRotation]: enabled, [ProjectPermissionSub.ServiceTokens]: enabled, - [ProjectPermissionSub.Commits]: enabled + [ProjectPermissionSub.Commits]: enabled, + [ProjectPermissionSub.SecretEvents]: enabled }); const KmsPermissionSubjects = (enabled = false) => ({ diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 4dbf84d92..f50446a8e 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -32,6 +32,7 @@ import { rolePermission2Form, TFormSchema } from "./ProjectRoleModifySection.utils"; +import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions"; import { SecretPermissionConditions } from "./SecretPermissionConditions"; import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions"; import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; @@ -72,6 +73,10 @@ export const renderConditionalComponents = ( return ; } + if (subject === ProjectPermissionSub.SecretEvents) { + return ; + } + return ; } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx new file mode 100644 index 000000000..801375f0c --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx @@ -0,0 +1,22 @@ +import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; + +import { ConditionsFields } from "./ConditionsFields"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const SecretEventPermissionConditions = ({ position = 0, isDisabled }: Props) => { + return ( + + ); +}; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx index b7f846bbd..9978b3e45 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx @@ -17,8 +17,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) { value: "environment", label: "Environment Slug" }, { value: "secretPath", label: "Secret Path" }, { value: "secretName", label: "Secret Name" }, - { value: "secretTags", label: "Secret Tags" }, - { value: "eventType", label: "Event Type" } + { value: "secretTags", label: "Secret Tags" } ]} /> ); diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx index 26a64f8a5..11ee3def8 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, + useSubscription, useWorkspace } from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; @@ -51,6 +52,7 @@ export const SelectionPanel = ({ usedBySecretSyncs = [] }: Props) => { const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "bulkDeleteEntries", @@ -101,6 +103,16 @@ export const SelectionPanel = ({ return "Do you want to delete the selected folders across environments?"; }; + const getDeleteModalSubTitle = () => { + if (selectedFolderCount > 0) { + if (subscription?.pitRecovery) { + return "All selected folders and their contents will be removed. You can reverse this action by rolling back to a previous commit."; + } + return "All selected folders and their contents will be removed. Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature."; + } + return undefined; + }; + const handleBulkDelete = async () => { let processedEntries = 0; @@ -279,6 +291,7 @@ export const SelectionPanel = ({ isOpen={popUp.bulkDeleteEntries.isOpen} deleteKey="delete" title={getDeleteModalTitle()} + subTitle={getDeleteModalSubTitle()} onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)} onDeleteApproved={handleBulkDelete} formContent={ diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index a8cb199d4..957091fe5 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({ setSelectedRequest(null); refetchRequests(); }} + onUpdate={(request) => { + // scott: this isn't ideal but our current use of state makes this complicated... + // we shouldn't be using state like this... + handleSelectRequest({ + ...selectedRequest, + isTemporary: request.isTemporary, + temporaryRange: request.temporaryRange, + reviewers: [] + }); + }} canBypass={generateRequestDetails(selectedRequest).canBypass} /> )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx new file mode 100644 index 000000000..c9c825a3c --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx @@ -0,0 +1,185 @@ +import { Controller, useForm } from "react-hook-form"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + TextArea +} from "@app/components/v2"; +import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation"; +import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; + +type ContentProps = { + accessRequest: TAccessApprovalRequest; + onComplete: (request: TAccessApprovalRequest) => void; + projectSlug: string; +}; + +const EditSchema = z.object({ + temporaryRange: z + .string() + .nonempty("Required") + .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().nonempty("Required") +}); + +type FormData = z.infer; + +const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => { + const update = useUpdateAccessRequest(); + const queryClient = useQueryClient(); + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(EditSchema), + defaultValues: { + temporaryRange: accessRequest.temporaryRange ?? "1h", + editNote: "" + } + }); + + const onSubmit = async (form: FormData) => { + try { + const request = await update.mutateAsync({ + requestId: accessRequest.id, + projectSlug, + ...form + }); + await queryClient.refetchQueries({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug) + }); + + createNotification({ + type: "success", + text: "Access request updated successfully." + }); + onComplete(request); + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to update access request" + }); + } + }; + + return ( +
+
+ + Updating this access request will restart the review process and require all approvers to + re-approve it. +
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`} + > + + + )} + /> + ( + +