diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index adf9489d4..c25d8d4d1 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -148,6 +148,7 @@ declare module "fastify" { interface Session { callbackPort: string; isAdminLogin: boolean; + orgSlug?: string; } interface FastifyRequest { diff --git a/backend/src/db/manual-migrations/partition-audit-logs.ts b/backend/src/db/manual-migrations/partition-audit-logs.ts index fbead3a24..e08f811ae 100644 --- a/backend/src/db/manual-migrations/partition-audit-logs.ts +++ b/backend/src/db/manual-migrations/partition-audit-logs.ts @@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise => { t.index("expiresAt"); t.index("orgId"); t.index("projectId"); + t.index("eventType"); + t.index("userAgentType"); + t.index("actor"); }); console.log("Adding GIN indices..."); @@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise => { console.log("Creating audit log partitions ahead of time... next date:", nextDateStr); await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1)); - // create partitions 4 years ahead - const partitionMonths = 4 * 12; + // create partitions 20 years ahead + const partitionMonths = 20 * 12; const partitionPromises: Promise[] = []; for (let x = 1; x <= partitionMonths; x += 1) { partitionPromises.push( diff --git a/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts new file mode 100644 index 000000000..03a45b1fd --- /dev/null +++ b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 1000; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified"))) { + // Add the column + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + + const aliasesToUpdate: { aliasId: string; isEmailVerified: boolean }[] = await knex(TableName.UserAliases) + .join(TableName.Users, `${TableName.UserAliases}.userId`, `${TableName.Users}.id`) + .select([`${TableName.UserAliases}.id as aliasId`, `${TableName.Users}.isEmailVerified`]); + + for (let i = 0; i < aliasesToUpdate.length; i += BATCH_SIZE) { + const batch = aliasesToUpdate.slice(i, i + BATCH_SIZE); + + const trueIds = batch.filter((row) => row.isEmailVerified).map((row) => row.aliasId); + + if (trueIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.UserAliases).whereIn("id", trueIds).update({ isEmailVerified: true }); + } + } + } + + if (!(await knex.schema.hasColumn(TableName.AuthTokens, "aliasId"))) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.string("aliasId").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified")) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.AuthTokens, "aliasId")) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("aliasId"); + }); + } +} 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/migrations/20250813214709_enforce-google-sso.ts b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts new file mode 100644 index 000000000..2346c91a4 --- /dev/null +++ b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced"; +const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed"; +export async function up(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (!hasGoogleSsoAuthEnforcedColumn) + table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable(); + if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable(); + }); +} + +export async function down(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME); + if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME); + }); +} 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/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index dd8563b85..0d3e93219 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid().nullable().optional() + orgId: z.string().uuid().nullable().optional(), + aliasId: z.string().nullable().optional() }); export type TAuthTokens = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index fb0728707..afc9e2b73 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({ scannerProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), - maxSharedSecretViewLimit: z.number().nullable().optional() + maxSharedSecretViewLimit: z.number().nullable().optional(), + googleSsoAuthEnforced: z.boolean().default(false), + googleSsoAuthLastUsed: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index 14147abf8..428fa62ca 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({ emails: z.string().array().nullable().optional(), orgId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUserAliases = 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..2cfc90564 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({ @@ -116,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string().nullable().optional(), sequence: z.number().nullable().optional(), approvalsRequired: z.number().nullable().optional(), @@ -133,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv }), reviewers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string(), status: z.string() }) @@ -190,4 +209,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv return { review }; } }); + + server.route({ + url: "/:requestId", + method: "PATCH", + schema: { + params: z.object({ + requestId: z.string().trim() + }), + body: z.object({ + temporaryRange: z.string().transform((val, ctx) => { + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return val; + }), + editNote: z.string().max(255) + }), + response: { + 200: z.object({ + approval: AccessApprovalRequestsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + temporaryRange: req.body.temporaryRange, + editNote: req.body.editNote, + requestId: req.params.requestId + }); + return { approval: request }; + } + }); }; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 3d07af562..104738140 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -294,12 +294,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv 200: z.object({ approval: SecretApprovalRequestsSchema.merge( z.object({ - // secretPath: z.string(), policy: z.object({ id: z.string(), name: z.string(), approvals: z.number(), - approvers: approvalRequestUser.array(), + approvers: approvalRequestUser + .extend({ isOrgMembershipActive: z.boolean().nullable().optional() }) + .array(), bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), @@ -309,7 +310,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), committerUser: approvalRequestUser.nullish(), - reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), + reviewers: approvalRequestUser + .extend({ + status: z.string(), + comment: z.string().optional(), + isOrgMembershipActive: z.boolean().nullable().optional() + }) + .array(), secretPath: z.string(), commits: secretRawSchema .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 68f9240a6..f3972fc1c 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -5,6 +5,7 @@ import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, + TOrgMemberships, TUserGroupMembership, TUsers } from "@app/db/schemas"; @@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `${TableName.Users}.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) @@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), + + db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), + db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR { key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) + mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) => + userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined }, { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ + mapper: ({ + approverUserId, + approverSequence, + approvalsRequired, + approverUsername, + approverEmail, + approverIsOrgMembershipActive + }) => ({ userId: approverUserId, sequence: approverSequence, approvalsRequired, email: approverEmail, - username: approverUsername + username: approverUsername, + isOrgMembershipActive: approverIsOrgMembershipActive }) }, { @@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR approverSequence, approvalsRequired, approverGroupEmail, - approverGroupUsername + approverGroupUsername, + approverGroupIsOrgMembershipActive }) => ({ userId: approverGroupUserId, sequence: approverSequence, approvalsRequired, email: approverGroupEmail, - username: approverGroupUsername + username: approverGroupUsername, + isOrgMembershipActive: approverGroupIsOrgMembershipActive }) }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, 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..f1d3102d6 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: { @@ -64,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -71,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; @@ -122,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory { reviewers: { userId: string; status: string; + isOrgMembershipActive: boolean; }[]; approvers: ( | { @@ -130,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -137,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index b58f58164..7a986a2d3 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type"; import { EventType, filterableSecretEvents } from "./audit-log-types"; export interface TAuditLogDALFactory extends Omit, "find"> { - pruneAuditLog: (tx?: knex.Knex) => Promise; + pruneAuditLog: () => Promise; find: ( arg: Omit & { actorId?: string | undefined; @@ -41,6 +41,10 @@ type TFindQuery = { offset?: number; }; +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + export const auditLogDALFactory = (db: TDbClient) => { const auditLogOrm = ormify(db, TableName.AuditLog); @@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => { }; // delete all audit log that have expired - const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { - const runPrune = async (dbClient: knex.Knex) => { - const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; - const MAX_RETRY_ON_FAILURE = 3; + const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => { + const today = new Date(); + let deletedAuditLogIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + let isRetrying = false; - const today = new Date(); - let deletedAuditLogIds: { id: string }[] = []; - let numberOfRetryOnFailure = 0; - let isRetrying = false; + logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedAuditLogIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); - do { - try { - const findExpiredLogSubQuery = dbClient(TableName.AuditLog) + const findExpiredLogSubQuery = trx(TableName.AuditLog) .where("expiresAt", "<", today) .where("createdAt", "<", today) // to use audit log partition .orderBy(`${TableName.AuditLog}.createdAt`, "desc") @@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => { .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); // eslint-disable-next-line no-await-in-loop - deletedAuditLogIds = await dbClient(TableName.AuditLog) - .whereIn("id", findExpiredLogSubQuery) - .del() - .returning("id"); - numberOfRetryOnFailure = 0; // reset - } catch (error) { - numberOfRetryOnFailure += 1; - logger.error(error, "Failed to delete audit log on pruning"); - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 10); // time to breathe for db - }); - } - isRetrying = numberOfRetryOnFailure > 0; - } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); - }; + const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id"); - if (tx) { - await runPrune(tx); - } else { - const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes - await db.transaction(async (trx) => { - await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - await runPrune(trx); - }); - } + return results; + }); + + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + deletedAuditLogIds = []; + logger.error(error, "Failed to delete audit log on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); }; const create: TAuditLogDALFactory["create"] = async (tx) => { 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 258783c2e..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"); @@ -126,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien await redis.set(key, "1", "EX", 60); - stream.push("1"); + send({ type: "ping" }); }; const close = () => { 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/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 338099da9..a72d50760 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({ userAlias = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustLdapEmails) { - newUser = await userDAL.findOne( - { - email: email.toLowerCase(), - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email: email.toLowerCase(), + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(username, userDAL); @@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({ aliasType: UserAliasType.LDAP, externalId, emails: [email], - orgId + orgId, + isEmailVerified: serverCfg.trustLdapEmails }, tx ); @@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({ return newUser; }); - const isUserCompleted = Boolean(user.isAccepted); - + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, hasExchangedPrivateKey: true, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, authType: UserAliasType.LDAP, + aliasId: userAlias.id, isUserCompleted, ...(relayState ? { @@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({ } ); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index bd3949a7e..8d2d6fdbe 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + enforceGoogleSSO: false, hsm: false, oidcSSO: false, scim: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 098d00feb..345e26638 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -47,6 +47,7 @@ export type TFeatureSet = { auditLogStreamLimit: 3; githubOrgSync: false; samlSSO: false; + enforceGoogleSSO: false; hsm: false; oidcSSO: false; secretAccessInsights: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index b9cf011d9..8f479b12c 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({ } const appCfg = getConfig(); - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.OIDC @@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + // we prioritize getting the most complete user to create the new alias under + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); - if (serverCfg.trustOidcEmails) { - // we prioritize getting the most complete user to create the new alias under + if (!newUser) { + // this fetches user entries created via invites newUser = await userDAL.findOne( { - email, - isEmailVerified: true + username: email }, tx ); - if (!newUser) { - // this fetches user entries created via invites - newUser = await userDAL.findOne( - { - username: email - }, - tx - ); - - if (newUser && !newUser.isEmailVerified) { - // we automatically mark it as email-verified because we've configured trust for OIDC emails - newUser = await userDAL.updateById(newUser.id, { - isEmailVerified: true - }); - } + if (newUser && !newUser.isEmailVerified) { + // we automatically mark it as email-verified because we've configured trust for OIDC emails + newUser = await userDAL.updateById(newUser.id, { + isEmailVerified: serverCfg.trustOidcEmails + }); } } @@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.OIDC, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustOidcEmails }, tx ); @@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({ await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted); + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, organizationSlug: organization.slug, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, isUserCompleted, @@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({ await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 11d8e0518..349130d8e 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -13,6 +13,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -161,8 +162,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -253,6 +253,16 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretScanningConfigs ); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -266,8 +276,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -457,6 +466,16 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -507,6 +526,16 @@ const buildViewerPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9677c69b1..cdf55127f 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -35,6 +35,7 @@ export interface TPermissionDALFactory { projectFavorites?: string[] | null | undefined; customRoleSlug?: string | null | undefined; orgAuthEnforced?: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; } & { groups: { id: string; @@ -87,6 +88,7 @@ export interface TPermissionDALFactory { }[]; orgId: string; orgAuthEnforced: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; orgRole: OrgMembershipRole; userId: string; projectId: string; @@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), @@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() @@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), @@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId, username, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole, membershipId, groupMembershipId, @@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { }) => ({ orgId, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole: orgRole as OrgMembershipRole, userId, projectId, diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index d645e2bec..7d61d7499 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { function validateOrgSSO( actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], orgRole: OrgMembershipRole ) { @@ -128,10 +129,16 @@ function validateOrgSSO( throw new UnauthorizedError({ name: "No auth method defined" }); } - if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { return; } + // case: google sso is enforced, but the actor is not using google sso + if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) { + throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" }); + } + + // case: SAML SSO is enforced, but the actor is not using SAML SSO if ( isOrgSsoEnforced && actorAuthMethod !== null && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 85ee82cca..461ceef46 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -146,6 +146,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, membership.orgAuthEnforced, + membership.orgGoogleSsoAuthEnforced, membership.bypassOrgAuthEnabled, membership.role as OrgMembershipRole ); @@ -238,6 +239,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, userProjectPermission.orgAuthEnforced, + userProjectPermission.orgGoogleSsoAuthEnforced, userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.orgRole ); 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/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index cbb99f7eb..6b8bbe304 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({ }); } - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.SAML @@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({ user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustSamlEmails) { - newUser = await userDAL.findOne( - { - email, - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); @@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.SAML, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustSamlEmails }, tx ); @@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({ } await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); + const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified); const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -424,6 +423,7 @@ export const samlConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: authProvider, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authType: UserAliasType.SAML, isUserCompleted, ...(relayState @@ -440,10 +440,11 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 49f31bdf6..49512768c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { SecretApprovalRequestsSchema, TableName, + TOrgMemberships, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, TUserGroupMembership, @@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `secretApprovalReviewerUser.id` ) + + .leftJoin( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.SecretApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `secretApprovalPolicyGroupApproverUser.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), + tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"), @@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"), + tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("projectId").withSchema(TableName.Environment), @@ -211,9 +234,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { reviewerLastName: lastName, reviewerUsername: username, reviewerFirstName: firstName, - reviewerComment: comment + reviewerComment: comment, + reviewerIsOrgMembershipActive: isOrgMembershipActive }) => - userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined + userId + ? { + userId, + status, + email, + firstName, + lastName, + username, + comment: comment ?? "", + isOrgMembershipActive + } + : undefined }, { key: "approverUserId", @@ -223,13 +258,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverEmail: email, approverUsername: username, approverLastName: lastName, - approverFirstName: firstName + approverFirstName: firstName, + approverIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { @@ -240,13 +277,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverGroupEmail: email, approverGroupUsername: username, approverGroupLastName: lastName, - approverGroupFirstName: firstName + approverGroupFirstName: firstName, + approverGroupIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { 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..9e32ad7de 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 @@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({ if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); + if (!secretApprovalRequest) throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` }); @@ -952,13 +953,39 @@ export const secretApprovalRequestServiceFactory = ({ if (!folder) { throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` }); } + + const { secrets } = mergeStatus; + await secretQueueService.syncSecrets({ projectId, orgId: actorOrgId, secretPath: folder.path, environmentSlug: folder.environmentSlug, actorId, - actor + actor, + event: { + created: secrets.created.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + updated: secrets.updated.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + deleted: secrets.deleted.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })) + } }); if (isSoftEnforcement) { @@ -1421,6 +1448,7 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; + const existingTagIds: Record = {}; const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1486,6 +1514,11 @@ export const secretApprovalRequestServiceFactory = ({ type: SecretType.Shared })) ); + + secretsToUpdateStoredInDB.forEach((el) => { + if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id); + }); + if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length) throw new NotFoundError({ message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}` @@ -1529,7 +1562,10 @@ export const secretApprovalRequestServiceFactory = ({ secretMetadata }) => { const secretId = updatingSecretsGroupByKey[secretKey][0].id; - if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds; + if (tagIds?.length || existingTagIds[secretKey]?.length) { + commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey]; + } + return { ...latestSecretVersions[secretId], secretMetadata, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 7ecaeff77..ff1196d30 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2504,6 +2504,7 @@ export const SecretSyncs = { }, RENDER: { serviceId: "The ID of the Render service to sync secrets to.", + environmentGroupId: "The ID of the Render environment group to sync secrets to.", scope: "The Render scope that secrets should be synced to.", type: "The Render resource type to sync secrets to." }, diff --git a/backend/src/lib/template/dot-access.ts b/backend/src/lib/template/dot-access.ts index ec3208feb..898252acc 100644 --- a/backend/src/lib/template/dot-access.ts +++ b/backend/src/lib/template/dot-access.ts @@ -1,11 +1,11 @@ /** * Safely retrieves a value from a nested object using dot notation path */ -export const getStringValueByDot = ( +export const getValueByDot = ( obj: Record | null | undefined, path: string, - defaultValue?: string -): string | undefined => { + defaultValue?: string | number | boolean +): string | number | boolean | undefined => { // Handle null or undefined input if (!obj) { return defaultValue; @@ -26,7 +26,7 @@ export const getStringValueByDot = ( current = (current as Record)[part]; } - if (typeof current !== "string") { + if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") { return defaultValue; } 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 0455a4da0..3c1038ded 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 }); @@ -727,7 +726,8 @@ export const registerRoutes = async ( permissionService, groupProjectDAL, smtpService, - projectMembershipDAL + projectMembershipDAL, + userAliasDAL }); const totpService = totpServiceFactory({ @@ -1121,7 +1121,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/app-connection-routers/render-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts index 97cff2cd3..bd0202371 100644 --- a/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts @@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider) return services; } }); + + server.route({ + method: "GET", + url: `/:connectionId/environment-groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission); + + return groups; + } + }); }; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index a91548285..911979b60 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: () => ({ message: "Authenticated" as const }) }); 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/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index ad0411b8c..65b9448c5 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -479,4 +479,30 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { return { identityMemberships }; } }); + + server.route({ + method: "GET", + url: "/details", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + identityDetails: z.object({ + organization: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN], { requireOrg: false }), + handler: async (req) => { + const organization = await server.services.org.findIdentityOrganization(req.permission.id); + return { identityDetails: { organization } }; + } + }); }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 323354bc1..b8eb3ad6b 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), + googleSsoAuthEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7a5a9341f..5e7dce76e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { firstName: true, lastName: true, id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + }) + .merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + .extend({ + isOrgMembershipActive: z.boolean() + }), project: SanitizedProjectSchema.pick({ name: true, id: true }), roles: z.array( z.object({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 0aec39f3e..366fa331d 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { try { // @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); const email = profile?.emails?.[0]?.value; if (!email) @@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort + callbackPort, + orgSlug }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { schema: { querystring: z.object({ callback_port: z.string().optional(), + org_slug: z.string().optional(), is_admin_login: z .string() .optional() @@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (orgSlug) { + req.session.set("orgSlug", orgSlug); + } if (isAdminLogin) { req.session.set("isAdminLogin", isAdminLogin); } diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 92b4138f2..f416fe8bb 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - username: z.string().trim() + token: z.string().trim() }), response: { 200: z.object({}) } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.body.username); + await server.services.user.sendEmailVerificationCode(req.body.token); return {}; } }); 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..164aa31d9 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,5 +1,3 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { request } from "@octokit/request"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getConfig } from "@app/lib/config/env"; import { request as httpRequest } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; @@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async ( ); }; -export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => { +export const getGitHubAppAuthToken = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { const appCfg = getConfig(); const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; - const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; + let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; if (!appId || !appPrivateKey) { throw new InternalServerError({ @@ -125,33 +127,65 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => }); } + appPrivateKey = appPrivateKey + .split("\n") + .map((line) => line.trim()) + .join("\n"); + if (appConnection.method !== GitHubConnectionMethod.App) { throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" }); } - const appAuth = createAppAuth({ - appId, - privateKey: appPrivateKey, - installationId: appConnection.credentials.installationId, - request: request.defaults({ - baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` - }) - }); + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now, + exp: now + 5 * 60, + iss: appId + }; - const { token } = await appAuth({ type: "installation" }); - return token; + const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" }); + + const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection); + const { installationId } = appConnection.credentials; + + const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>( + appConnection, + gatewayService, + { + url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`, + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${appJwt}`, + "X-GitHub-Api-Version": "2022-11-28" + } + } + ); + + return response.data.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 ( @@ -163,28 +197,86 @@ export const makePaginatedGitHubRequest = async ( const { credentials, method } = appConnection; const token = - method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); - let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + method === GitHubConnectionMethod.OAuth + ? credentials.accessToken + : await getGitHubAppAuthToken(appConnection, gatewayService); + + 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/app-connection/render/render-connection-fns.ts b/backend/src/services/app-connection/render/render-connection-fns.ts index bf85b9b71..c41c46c50 100644 --- a/backend/src/services/app-connection/render/render-connection-fns.ts +++ b/backend/src/services/app-connection/render/render-connection-fns.ts @@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list import { AppConnection } from "../app-connection-enums"; import { RenderConnectionMethod } from "./render-connection-enums"; import { + TRawRenderEnvironmentGroup, TRawRenderService, TRenderConnection, TRenderConnectionConfig, + TRenderEnvironmentGroup, TRenderService } from "./render-connection-types"; @@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom const perPage = 100; let cursor; + let maxIterations = 10; + while (hasMorePages) { + if (maxIterations <= 0) break; + const res: TRawRenderService[] = ( await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { params: new URLSearchParams({ @@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom } else { cursor = res[res.length - 1].cursor; } + + maxIterations -= 1; } return services; @@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect return inputCredentials; }; + +export const listRenderEnvironmentGroups = async ( + appConnection: TRenderConnection +): Promise => { + const { + credentials: { apiKey } + } = appConnection; + + const groups: TRenderEnvironmentGroup[] = []; + let hasMorePages = true; + const perPage = 100; + let cursor; + let maxIterations = 10; + + while (hasMorePages) { + if (maxIterations <= 0) break; + + const res: TRawRenderEnvironmentGroup[] = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, { + params: new URLSearchParams({ + ...(cursor ? { cursor: String(cursor) } : {}), + limit: String(perPage) + }), + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + res.forEach((item) => { + groups.push({ + name: item.envGroup.name, + id: item.envGroup.id + }); + }); + + if (res.length < perPage) { + hasMorePages = false; + } else { + cursor = res[res.length - 1].cursor; + } + + maxIterations -= 1; + } + + return groups; +}; diff --git a/backend/src/services/app-connection/render/render-connection-service.ts b/backend/src/services/app-connection/render/render-connection-service.ts index 371790bcb..7cf805e9c 100644 --- a/backend/src/services/app-connection/render/render-connection-service.ts +++ b/backend/src/services/app-connection/render/render-connection-service.ts @@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listRenderServices } from "./render-connection-fns"; +import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns"; import { TRenderConnection } from "./render-connection-types"; type TGetAppConnectionFunc = ( @@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc) } }; + const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor); + try { + const groups = await listRenderEnvironmentGroups(appConnection); + + return groups; + } catch (error) { + logger.error(error, "Failed to list environment groups for Render connection"); + return []; + } + }; + return { - listServices + listServices, + listEnvironmentGroups }; }; diff --git a/backend/src/services/app-connection/render/render-connection-types.ts b/backend/src/services/app-connection/render/render-connection-types.ts index 0902472e5..c3922a071 100644 --- a/backend/src/services/app-connection/render/render-connection-types.ts +++ b/backend/src/services/app-connection/render/render-connection-types.ts @@ -33,3 +33,16 @@ export type TRawRenderService = { name: string; }; }; + +export type TRenderEnvironmentGroup = { + name: string; + id: string; +}; + +export type TRawRenderEnvironmentGroup = { + cursor: string; + envGroup: { + id: string; + name: string; + }; +}; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 1a2f290ec..c309b3998 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); @@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu type, userId, orgId, - triesLeft: tkCfg?.triesLeft + triesLeft: tkCfg?.triesLeft, + aliasId }, tx ); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 5f5843bc6..7deb719a9 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = { type: TokenType; userId: string; orgId?: string; + aliasId?: string; }; export type TCreateOrgInviteTokenDTO = { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6a3456508..e3afb754a 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -448,15 +448,41 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); + + const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); + const selectedOrg = await orgDAL.findById(organizationId); - if (!hasOrganizationMembership) { + // Check if authEnforced is true, if that's the case, throw an error + if (selectedOrg.authEnforced) { + throw new BadRequestError({ + message: "Authentication is required by your organization before you can log in." + }); + } + + if (!selectedOrgMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` }); } + if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; + + if (!canBypass) { + throw new ForbiddenRequestError({ + message: "Google SSO is enforced for this organization. Please use Google SSO to login.", + error: "GoogleSsoEnforced" + }); + } + } + + if (decodedToken.authMethod === AuthMethod.GOOGLE) { + await orgDAL.updateById(selectedOrg.id, { + googleSsoAuthLastUsed: new Date() + }); + } + const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; @@ -502,7 +528,8 @@ export const authLoginServiceFactory = ({ selectedOrg.authEnforced && selectedOrg.bypassOrgAuthEnabled && !isAuthMethodSaml(decodedToken.authMethod) && - decodedToken.authMethod !== AuthMethod.OIDC + decodedToken.authMethod !== AuthMethod.OIDC && + decodedToken.authMethod !== AuthMethod.GOOGLE ) { await auditLogService.createAuditLog({ orgId: organizationId, @@ -705,7 +732,7 @@ export const authLoginServiceFactory = ({ /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => { // akhilmhdh: case sensitive email resolution const usersByUsername = await userDAL.findUserByUsername(email); let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; @@ -759,6 +786,8 @@ export const authLoginServiceFactory = ({ const appCfg = getConfig(); + let orgId = ""; + let orgName: undefined | string; if (!user) { // Create a new user based on oAuth if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); @@ -784,7 +813,6 @@ export const authLoginServiceFactory = ({ }); if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { - let orgId = ""; const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); if (!defaultOrg) { throw new BadRequestError({ @@ -824,11 +852,39 @@ export const authLoginServiceFactory = ({ } } + if (!orgId && orgSlug) { + const org = await orgDAL.findOrgBySlug(orgSlug); + + if (org) { + // checks for the membership and only sets the orgId / orgName if the user is a member of the specified org + const orgMembership = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "orgId"]: org.id, + [`${TableName.OrgMembership}.isActive` as "isActive"]: true, + [`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted + }); + + if (orgMembership) { + orgId = org.id; + orgName = org.name; + } + } + } + const isUserCompleted = user.isAccepted; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, + + ...(orgId && orgSlug && orgName !== undefined + ? { + organizationId: orgId, + organizationName: orgName, + organizationSlug: orgSlug + } + : {}), + username: user.username, email: user.email, isEmailVerified: user.isEmailVerified, diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index d9d9520a8..09d81033f 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -32,6 +32,7 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; + orgSlug?: string; }; export type TOauthTokenExchangeDTO = { diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index a776f7245..263838a9d 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { `${TableName.GroupProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership), @@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }); @@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ isGroupMember: true, id, @@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { id: projectId, name: projectName }, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive }, createdAt }), key: "id", diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 7b0a19414..9cc851437 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -21,7 +21,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({ if (identityJwtAuth.boundClaims) { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({ } // handle both single and multi-valued claims - if ( - !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) - ) { + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { throw new UnauthorizedError({ message: `Access denied: claim mismatch for field ${claimKey}` }); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts index 7d386afcb..8ed134cba 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts @@ -1,7 +1,16 @@ import picomatch from "picomatch"; -export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) => - policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => { + if (typeof fieldValue === "boolean") { + return fieldValue === (policyValue === "true"); + } + + if (typeof fieldValue === "number") { + return fieldValue === parseInt(policyValue, 10); + } + + return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +}; export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => { if (Array.isArray(fieldValue)) { diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 617b21a1f..6585e61f3 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -22,7 +22,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` }); } - filteredClaims[permissionKey] = value; + filteredClaims[permissionKey] = value.toString(); }); } 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/org/org-dal.ts b/backend/src/services/org/org-dal.ts index b46efc46c..aa22b11c7 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -630,6 +630,25 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findIdentityOrganization = async ( + identityId: string + ): Promise<{ id: string; name: string; slug: string; role: string }> => { + try { + const org = await db + .replicaNode()(TableName.IdentityOrgMembership) + .where({ identityId }) + .join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`) + .select(db.ref("id").withSchema(TableName.Organization).as("id")) + .select(db.ref("name").withSchema(TableName.Organization).as("name")) + .select(db.ref("slug").withSchema(TableName.Organization).as("slug")) + .select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role")); + + return org?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "Find identity organization" }); + } + }; + return withTransaction(db, { ...orgOrm, findOrgByProjectId, @@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => { updateMembershipById, deleteMembershipById, deleteMembershipsById, - updateMembership + updateMembership, + findIdentityOrganization }); }; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ae82cd1bc..4a3bdb06e 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ createdAt: true, updatedAt: true, authEnforced: true, + googleSsoAuthEnforced: true, scimEnabled: true, kmsDefaultKeyId: true, defaultMembershipRole: true, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 5779fa7d3..356a8451c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -198,6 +198,15 @@ export const orgServiceFactory = ({ // Filter out orgs where the membership object is an invitation return orgs.filter((org) => org.userStatus !== "invited"); }; + + /* + * Get all organization an identity is part of + * */ + const findIdentityOrganization = async (identityId: string) => { + const org = await orgDAL.findIdentityOrganization(identityId); + + return org; + }; /* * Get all workspace members * */ @@ -355,6 +364,7 @@ export const orgServiceFactory = ({ name, slug, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, @@ -421,6 +431,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced !== undefined) { + if (!plan.enforceGoogleSSO) { + throw new BadRequestError({ + message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO." + }); + } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + } + + if (authEnforced && googleSsoAuthEnforced) { + throw new BadRequestError({ + message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time." + }); + } + if (authEnforced) { const samlCfg = await samlConfigDAL.findOne({ orgId, @@ -451,6 +476,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced) { + if (googleSsoAuthEnforced && currentOrg.authEnforced) { + throw new BadRequestError({ + message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled." + }); + } + + if (!currentOrg.googleSsoAuthLastUsed) { + throw new BadRequestError({ + message: + "Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization." + }); + } + } + let defaultMembershipRole: string | undefined; if (defaultMembershipRoleSlug) { defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ @@ -465,6 +505,7 @@ export const orgServiceFactory = ({ name, slug: slug ? slugify(slug) : undefined, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRole, enforceMfa, @@ -1403,6 +1444,7 @@ export const orgServiceFactory = ({ findOrganizationById, findAllOrgMembers, findAllOrganizationOfUser, + findIdentityOrganization, inviteUserToOrganization, verifyUserToOrg, updateOrg, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 645692145..1a27d131f 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { name: string; slug: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index 7fbe7343e..bf0bb18af 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -177,6 +177,18 @@ export const projectEnvServiceFactory = ({ } } + const envs = await projectEnvDAL.find({ projectId }); + const project = await projectDAL.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (plan.environmentLimit !== null && envs.length > plan.environmentLimit) { + // case: limit imposed on number of environments allowed + // case: number of environments used exceeds the number of environments allowed + throw new BadRequestError({ + message: + "Failed to update environment due to environment limit exceeded. To update an environment, please upgrade your plan or remove unused environments." + }); + } + const env = await projectEnvDAL.transaction(async (tx) => { if (position) { const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx); diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 7dfd1f65c..dd503c2a3 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => { .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join(TableName.OrgMembership, (qb) => { + qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn( + `${TableName.OrgMembership}.orgId`, + "=", + `${TableName.Project}.orgId` + ); + }) + .where((qb) => { if (filter.usernames) { void qb.whereIn("username", filter.usernames); @@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }) .orderBy(`${TableName.Users}.username` as "username"); @@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { + email, + username, + firstName, + lastName, + id: userId, + publicKey, + isGhost, + isOrgMembershipActive: isActive + }, project: { id: projectId, name: projectName diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index b9e502922..7cf665141 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({ const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); - // projectMembers[0].project if (includeGroupMembers) { const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); const allMembers = [ 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-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts index d0e97afa1..680dd4256 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -1,4 +1,5 @@ import AWS, { AWSError } from "aws-sdk"; +import handlebars from "handlebars"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; @@ -34,18 +35,51 @@ const sleep = async () => setTimeout(resolve, 1000); }); -const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise => { +const getFullPath = ({ path, keySchema, environment }: { path: string; keySchema?: string; environment: string }) => { + if (!keySchema || !keySchema.includes("/")) return path; + + if (keySchema.startsWith("/")) { + throw new SecretSyncError({ message: `Key schema cannot contain leading '/'`, shouldRetry: false }); + } + + const keySchemaSegments = handlebars + .compile(keySchema)({ + environment, + secretKey: "{{secretKey}}" + }) + .split("/"); + + const pathSegments = keySchemaSegments.slice(0, keySchemaSegments.length - 1); + + if (pathSegments.some((segment) => segment.includes("{{secretKey}}"))) { + throw new SecretSyncError({ + message: "Key schema cannot contain '/' after {{secretKey}}", + shouldRetry: false + }); + } + + return `${path}${pathSegments.join("/")}/`; +}; + +const getParametersByPath = async ( + ssm: AWS.SSM, + path: string, + keySchema: string | undefined, + environment: string +): Promise => { const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {}; let hasNext = true; let nextToken: string | undefined; let attempt = 0; + const fullPath = getFullPath({ path, keySchema, environment }); + while (hasNext) { try { // eslint-disable-next-line no-await-in-loop const parameters = await ssm .getParametersByPath({ - Path: path, + Path: fullPath, Recursive: false, WithDecryption: true, MaxResults: BATCH_SIZE, @@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise { if (parameter.Name) { // no leading slash if path is '/' - const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; + const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; awsParameterStoreSecretsRecord[secKey] = parameter; } }); @@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise => { +const getParameterMetadataByPath = async ( + ssm: AWS.SSM, + path: string, + keySchema: string | undefined, + environment: string +): Promise => { const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {}; let hasNext = true; let nextToken: string | undefined; let attempt = 0; + const fullPath = getFullPath({ path, keySchema, environment }); + while (hasNext) { try { // eslint-disable-next-line no-await-in-loop @@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise { if (parameter.Name) { // no leading slash if path is '/' - const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; + const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; awsParameterStoreMetadataRecord[secKey] = parameter; } }); @@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = { const ssm = await getSSM(secretSync); - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + const awsParameterStoreSecretsRecord = await getParametersByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); - const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path); + const awsParameterStoreMetadataRecord = await getParameterMetadataByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord( ssm, @@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = { await deleteParametersBatch(ssm, parametersToDelete); }, getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + const awsParameterStoreSecretsRecord = await getParametersByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); return Object.fromEntries( Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }]) ); }, removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + const awsParameterStoreSecretsRecord = await getParametersByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); const parametersToDelete: AWS.SSM.Parameter[] = []; diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index e2cf8f6e8..2cae048aa 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -207,7 +207,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const publicKey = await getPublicKey(secretSync, gatewayService, token); @@ -264,7 +264,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); diff --git a/backend/src/services/secret-sync/render/render-sync-enums.ts b/backend/src/services/secret-sync/render/render-sync-enums.ts index dc0af3b91..098f90de0 100644 --- a/backend/src/services/secret-sync/render/render-sync-enums.ts +++ b/backend/src/services/secret-sync/render/render-sync-enums.ts @@ -1,5 +1,6 @@ export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 71347f998..4ab67bdd9 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ -import { isAxiosError } from "axios"; +import { AxiosRequestConfig, isAxiosError } from "axios"; import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { RenderSyncScope } from "./render-sync-enums"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; const MAX_RETRIES = 5; @@ -27,6 +29,80 @@ const makeRequestWithRetry = async (requestFn: () => Promise, attempt = 0) } }; +async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) { + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "GET", + headers: { + Authorization: `Bearer ${input.token}`, + Accept: "application/json" + } + }; + + switch (input.destination.scope) { + case RenderSyncScope.Service: { + req.url = `/services/${input.destination.serviceId}/env-vars`; + + const allSecrets: TRenderSecret[] = []; + let cursor: string | undefined; + + do { + // eslint-disable-next-line @typescript-eslint/no-loop-func + const { data } = await makeRequestWithRetry(() => + request.request< + { + envVar: { + key: string; + value: string; + }; + cursor: string; + }[] + >({ + ...req, + params: { + cursor + } + }) + ); + + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); + + allSecrets.push(...secrets); + + if (data.length > 0 && data[data.length - 1]?.cursor) { + cursor = data[data.length - 1].cursor; + } else { + cursor = undefined; + } + } while (cursor); + + return allSecrets; + } + case RenderSyncScope.EnvironmentGroup: { + req.url = `/env-groups/${input.destination.environmentGroupId}`; + + const res = await makeRequestWithRetry(() => + request.request<{ + envVars: { + key: string; + value: string; + }[]; + }>(req) + ); + + return res.data.envVars.map((item) => ({ + key: item.key, + value: item.value + })); + } + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +} + const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise => { const { destinationConfig, @@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential } } = secretSync; - const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; - const allSecrets: TRenderSecret[] = []; - let cursor: string | undefined; + const secrets = await getSecrets({ + destination: destinationConfig, + token: apiKey + }); - do { - const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; - - const { data } = await makeRequestWithRetry(() => - request.get< - { - envVar: { - key: string; - value: string; - }; - cursor: string; - }[] - >(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } - }) - ); - - const secrets = data.map((item) => ({ - key: item.envVar.key, - value: item.envVar.value - })); - - allSecrets.push(...secrets); - - if (data.length > 0 && data[data.length - 1]?.cursor) { - cursor = data[data.length - 1].cursor; - } else { - cursor = undefined; - } - } while (cursor); - - return allSecrets; + return secrets; }; const batchUpdateEnvironmentSecrets = async ( @@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async ( } } = secretSync; - await makeRequestWithRetry(() => - request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "PUT", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars`, + data: envVars + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + for await (const variable of envVars) { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`, + data: { + value: variable.value + } + }) + ); } - }) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +}; + +const deleteEnvironmentSecret = async ( + secretSync: TRenderSyncWithCredentials, + envVar: { key: string; value: string } +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "DELETE", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}` + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}` + }) + ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; const redeployService = async (secretSync: TRenderSyncWithCredentials) => { @@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => { } } = secretSync; - await makeRequestWithRetry(() => - request.post( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, - {}, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + method: "POST", + url: `/services/${destinationConfig.serviceId}/deploys`, + data: {} + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + const { data } = await request.request<{ serviceLinks: { id: string }[] }>({ + ...req, + method: "GET", + url: `/env-groups/${destinationConfig.environmentGroupId}` + }); + + for await (const link of data.serviceLinks) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${link.id}/deploys`, + data: {} + }) + ); } - ) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; export const RenderSyncFns = { @@ -169,14 +321,15 @@ export const RenderSyncFns = { const finalEnvVars: Array<{ key: string; value: string }> = []; for (const renderSecret of renderSecrets) { - if (!(renderSecret.key in secretMap)) { + if (renderSecret.key in secretMap) { finalEnvVars.push({ key: renderSecret.key, value: renderSecret.value }); } } - await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); + + await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el))); if (secretSync.syncOptions.autoRedeployServices) { await redeployService(secretSync); diff --git a/backend/src/services/secret-sync/render/render-sync-schemas.ts b/backend/src/services/secret-sync/render/render-sync-schemas.ts index 0d6e93987..0bc116255 100644 --- a/backend/src/services/secret-sync/render/render-sync-schemas.ts +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId), type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), + environmentGroupId: z + .string() + .min(1, "Environment Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId), + type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) }) ]); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index c2a72f2e6..8d9c6958a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { throw new BadRequestError({ message: "Missing personal user id" }); } void bd.orWhere({ - key: el.key, - type: el.type, - userId: el.type === SecretType.Personal ? el.userId : null + [`${TableName.SecretV2}.key` as "key"]: el.key, + [`${TableName.SecretV2}.type` as "type"]: el.type, + [`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null }); }); }) @@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select( + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); - return secrets.map((secret) => ({ - ...secret, - isRotatedSecret: Boolean(secret.rotationId) - })); + + const docs = sqlNestRelationships({ + data: secrets, + key: "id", + parentMapper: (secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + }, + { + key: "metadataId", + label: "secretMetadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return docs; } catch (error) { throw new DatabaseError({ error, name: "find by secret keys" }); } 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..b23cd0c56 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 + } + } }); } @@ -1050,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({ currentPath: path }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } @@ -1708,7 +1742,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 +2117,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 +2264,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 +2809,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 +2827,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/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f776b4527..6bafa3ba6 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -637,7 +637,12 @@ export const secretServiceFactory = ({ } }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { @@ -647,7 +652,12 @@ export const secretServiceFactory = ({ }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } 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/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 30bf750c3..b0d7be0fe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -9,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { AuthMethod } from "../auth/auth-type"; +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; @@ -37,6 +39,7 @@ type TUserServiceFactoryDep = { projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; @@ -48,22 +51,38 @@ export const userServiceFactory = ({ groupProjectDAL, tokenService, smtpService, - permissionService + permissionService, + userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (username: string) => { + const sendEmailVerificationCode = async (token: string) => { + const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as { + authType: string; + aliasId?: string; + username: string; + authTokenType: AuthTokenType; + }; + if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" }); + // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + let { isEmailVerified } = user; + if (aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` }); + isEmailVerified = userAlias.isEmailVerified; + } if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); - if (user.isEmailVerified) + if (isEmailVerified) throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); - const token = await tokenService.createTokenForUser({ + const userToken = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId }); await smtpService.sendMail({ @@ -71,7 +90,7 @@ export const userServiceFactory = ({ subjectLine: "Infisical confirmation code", recipients: [user.email], substitutions: { - code: token + code: userToken } }); }; @@ -95,15 +114,21 @@ export const userServiceFactory = ({ if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); - if (user.isEmailVerified) - throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); - await tokenService.validateTokenForUser({ + const token = await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, userId: user.id, code }); + if (token?.aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` }); + if (userAlias?.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true }); + } const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; await userDAL.updateById(user.id, { diff --git a/docs/docs.json b/docs/docs.json index 922e80d32..3b116f4db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -310,7 +310,8 @@ "self-hosting/guides/mongo-to-postgres", "self-hosting/guides/custom-certificates", "self-hosting/guides/automated-bootstrapping", - "self-hosting/guides/production-hardening" + "self-hosting/guides/production-hardening", + "self-hosting/guides/monitoring-telemetry" ] }, { @@ -416,6 +417,9 @@ "pages": [ "documentation/platform/secrets-mgmt/project", "documentation/platform/folder", + "documentation/platform/secret-versioning", + "documentation/platform/pit-recovery", + "documentation/platform/secret-reference", { "group": "Secret Rotation", "pages": [ @@ -439,6 +443,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", @@ -458,7 +463,8 @@ "documentation/platform/dynamic-secrets/kubernetes", "documentation/platform/dynamic-secrets/vertica" ] - } + }, + "documentation/platform/webhooks" ] }, { 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/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx index f7ac0d545..7ad11948c 100644 --- a/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx @@ -1,6 +1,6 @@ --- -title: "Delivering Secrets" -description: "Learn how to get secrets out of Infisical and into the systems, applications, and environments that need them." +title: "Fetching Secrets" +description: "Learn how to deliver secrets from Infisical into the systems, applications, and environments that need them." --- Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them. 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/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 321dbec6e..5c4f34784 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -27,7 +27,7 @@ $ ansible-galaxy collection install infisical.vault The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: ```bash -$ pip install infisical-python +$ pip install infisicalsdk ``` ## Using this collection @@ -41,8 +41,13 @@ vars: read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] + + read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + # { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1 + + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" - # [{ "key": "HOST", "value": "google.com" }] + # { "key": "HOST", "value": "google.com" } ``` diff --git a/docs/integrations/platforms/kubernetes/overview.mdx b/docs/integrations/platforms/kubernetes/overview.mdx index ca700d777..ce9afcced 100644 --- a/docs/integrations/platforms/kubernetes/overview.mdx +++ b/docs/integrations/platforms/kubernetes/overview.mdx @@ -22,7 +22,7 @@ It can also automatically reload dependent Deployments resources whenever releva ## Install -The operator can be install via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. +The operator can be installed via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. **Install the latest Helm repository** ```bash @@ -229,9 +229,9 @@ The managed secret created by the operator will not be deleted when the operator - Install Infisical Helm repository + Uninstall Infisical Helm repository ```bash helm uninstall ``` - \ No newline at end of file + diff --git a/docs/integrations/secret-syncs/render.mdx b/docs/integrations/secret-syncs/render.mdx index 341d84ad5..c81048668 100644 --- a/docs/integrations/secret-syncs/render.mdx +++ b/docs/integrations/secret-syncs/render.mdx @@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical." ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) - **Render Connection**: The Render Connection to authenticate with. - - **Scope**: Select **Service**. - - **Service**: Choose the Render service you want to sync secrets to. + - **Scope**: Select **Service** or **Environment Group**. + - **Service**: Choose the Render service you want to sync secrets to. + - **Environment Group**: Choose the Render environment group you want to sync secrets to. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. ![Configure Options](/images/secret-syncs/render/render-sync-options.png) 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/self-hosting/guides/monitoring-telemetry.mdx b/docs/self-hosting/guides/monitoring-telemetry.mdx new file mode 100644 index 000000000..1c2d05702 --- /dev/null +++ b/docs/self-hosting/guides/monitoring-telemetry.mdx @@ -0,0 +1,440 @@ +--- +title: "Monitoring and Telemetry Setup" +description: "Learn how to set up monitoring and telemetry for your self-hosted Infisical instance using Grafana, Prometheus, and OpenTelemetry." +--- + +Infisical provides comprehensive monitoring and telemetry capabilities to help you monitor the health, performance, and usage of your self-hosted instance. This guide covers setting up monitoring using Grafana with two different telemetry collection approaches. + +## Overview + +Infisical exports metrics in **OpenTelemetry (OTEL) format**, which provides maximum flexibility for your monitoring infrastructure. While this guide focuses on Grafana, the OTEL format means you can easily integrate with: + +- **Cloud-native monitoring**: AWS CloudWatch, Google Cloud Monitoring, Azure Monitor +- **Observability platforms**: Datadog, New Relic, Splunk, Dynatrace +- **Custom backends**: Any system that supports OTEL ingestion +- **Traditional monitoring**: Prometheus, Grafana (as covered in this guide) + +Infisical supports two telemetry collection methods: + +1. **Pull-based (Prometheus)**: Exposes metrics on a dedicated endpoint for Prometheus to scrape +2. **Push-based (OTLP)**: Sends metrics to an OpenTelemetry Collector via OTLP protocol + +Both approaches provide the same metrics data in OTEL format, so you can choose the one that best fits your infrastructure and monitoring strategy. + +## Prerequisites + +- Self-hosted Infisical instance running +- Access to deploy monitoring services (Prometheus, Grafana, etc.) +- Basic understanding of Prometheus and Grafana + +## Environment Variables + +Configure the following environment variables in your Infisical backend: + +```bash +# Enable telemetry collection +OTEL_TELEMETRY_COLLECTION_ENABLED=true + +# Choose export type: "prometheus" or "otlp" +OTEL_EXPORT_TYPE=prometheus + +# For OTLP push mode, also configure: +# OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics +# OTEL_COLLECTOR_BASIC_AUTH_USERNAME=your_collector_username +# OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=your_collector_password +# OTEL_OTLP_PUSH_INTERVAL=30000 +``` + +**Note**: The `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` values must match the credentials configured in your OpenTelemetry Collector's `basicauth/server` extension. These are not hardcoded values - you configure them in your collector configuration file. + +## Option 1: Pull-based Monitoring (Prometheus) + +This approach exposes metrics on port 9464 at the `/metrics` endpoint, allowing Prometheus to scrape the data. The metrics are exposed in Prometheus format but originate from OpenTelemetry instrumentation. + +### Configuration + +1. **Enable Prometheus export in Infisical**: + + ```bash + OTEL_TELEMETRY_COLLECTION_ENABLED=true + OTEL_EXPORT_TYPE=prometheus + ``` + +2. **Expose the metrics port** in your Infisical backend: + + - **Docker**: Expose port 9464 + - **Kubernetes**: Create a service exposing port 9464 + - **Other**: Ensure port 9464 is accessible to your monitoring stack + +3. **Create Prometheus configuration** (`prometheus.yml`): + + ```yaml + global: + scrape_interval: 30s + evaluation_interval: 30s + + scrape_configs: + - job_name: "infisical" + scrape_interval: 30s + static_configs: + - targets: ["infisical-backend:9464"] # Adjust hostname/port based on your deployment + metrics_path: "/metrics" + ``` + + **Note**: Replace `infisical-backend:9464` with the actual hostname and port where your Infisical backend is running. This could be: + + - **Docker Compose**: `infisical-backend:9464` (service name) + - **Kubernetes**: `infisical-backend.default.svc.cluster.local:9464` (service name) + - **Bare Metal**: `192.168.1.100:9464` (actual IP address) + - **Cloud**: `your-infisical.example.com:9464` (domain name) + +### Deployment Options + +#### Docker Compose + +```yaml +services: + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin +``` + +#### Kubernetes + +```yaml +# prometheus-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + containers: + - name: prometheus + image: prom/prometheus:latest + ports: + - containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + volumes: + - name: config + configMap: + name: prometheus-config + +--- +# prometheus-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: prometheus +spec: + selector: + app: prometheus + ports: + - port: 9090 + targetPort: 9090 + type: ClusterIP +``` + +#### Helm + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm install prometheus prometheus-community/prometheus \ + --set server.config.global.scrape_interval=30s \ + --set server.config.scrape_configs[0].job_name=infisical \ + --set server.config.scrape_configs[0].static_configs[0].targets[0]=infisical-backend:9464 +``` + +## Option 2: Push-based Monitoring (OTLP) + +This approach sends metrics directly to an OpenTelemetry Collector via the OTLP protocol. This gives you the most flexibility as you can configure the collector to export to multiple backends simultaneously. + +### Configuration + +1. **Enable OTLP export in Infisical**: + + ```bash + OTEL_TELEMETRY_COLLECTION_ENABLED=true + OTEL_EXPORT_TYPE=otlp + OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics + OTEL_COLLECTOR_BASIC_AUTH_USERNAME=infisical + OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=infisical + OTEL_OTLP_PUSH_INTERVAL=30000 + ``` + +2. **Create OpenTelemetry Collector configuration** (`otel-collector-config.yaml`): + + ```yaml + extensions: + health_check: + pprof: + zpages: + basicauth/server: + htpasswd: + inline: | + your_username:your_password + + receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + auth: + authenticator: basicauth/server + + prometheus: + config: + scrape_configs: + - job_name: otel-collector + scrape_interval: 30s + static_configs: + - targets: [infisical-backend:9464] + metric_relabel_configs: + - action: labeldrop + regex: "service_instance_id|service_name" + + processors: + batch: + + exporters: + prometheus: + endpoint: "0.0.0.0:8889" + auth: + authenticator: basicauth/server + resource_to_telemetry_conversion: + enabled: true + + service: + extensions: [basicauth/server, health_check, pprof, zpages] + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] + ``` + + **Important**: Replace `your_username:your_password` with your chosen credentials. These must match the values you set in Infisical's `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` environment variables. + +3. **Create Prometheus configuration** for the collector: + + ```yaml + global: + scrape_interval: 30s + evaluation_interval: 30s + + scrape_configs: + - job_name: "otel-collector" + scrape_interval: 30s + static_configs: + - targets: ["otel-collector:8889"] # Adjust hostname/port based on your deployment + metrics_path: "/metrics" + ``` + + **Note**: Replace `otel-collector:8889` with the actual hostname and port where your OpenTelemetry Collector is running. This could be: + + - **Docker Compose**: `otel-collector:8889` (service name) + - **Kubernetes**: `otel-collector.default.svc.cluster.local:8889` (service name) + - **Bare Metal**: `192.168.1.100:8889` (actual IP address) + - **Cloud**: `your-collector.example.com:8889` (domain name) + +### Deployment Options + +#### Docker Compose + +```yaml +services: + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + ports: + - 4318:4318 # OTLP http receiver + - 8889:8889 # Prometheus exporter metrics + volumes: + - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + command: + - "--config=/etc/otelcol-contrib/config.yaml" +``` + +#### Kubernetes + +```yaml +# otel-collector-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector +spec: + replicas: 1 + selector: + matchLabels: + app: otel-collector + template: + metadata: + labels: + app: otel-collector + spec: + containers: + - name: otel-collector + image: otel/opentelemetry-collector-contrib:latest + ports: + - containerPort: 4318 + - containerPort: 8889 + volumeMounts: + - name: config + mountPath: /etc/otelcol-contrib + volumes: + - name: config + configMap: + name: otel-collector-config +``` + +#### Helm + +```bash +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts +helm install otel-collector open-telemetry/opentelemetry-collector \ + --set config.receivers.otlp.protocols.http.endpoint=0.0.0.0:4318 \ + --set config.exporters.prometheus.endpoint=0.0.0.0:8889 +``` + +## Alternative Backends + +Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus: + +### Cloud-Native Examples + +```yaml +# Add to your otel-collector-config.yaml exporters section +exporters: + # AWS CloudWatch + awsemf: + region: us-west-2 + log_group_name: /aws/emf/infisical + log_stream_name: metrics + + # Google Cloud Monitoring + googlecloud: + project_id: your-project-id + + # Azure Monitor + azuremonitor: + connection_string: "your-connection-string" + + # Datadog + datadog: + api: + key: "your-api-key" + site: "datadoghq.com" + + # New Relic + newrelic: + apikey: "your-api-key" + host_override: "otlp.nr-data.net" +``` + +### Multi-Backend Configuration + +```yaml +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus, awsemf, datadog] # Send to multiple backends +``` + +## Setting Up Grafana + +1. **Access Grafana**: Navigate to your Grafana instance +2. **Login**: Use your configured credentials +3. **Add Prometheus Data Source**: + - Go to Configuration → Data Sources + - Click "Add data source" + - Select "Prometheus" + - Set URL to your Prometheus endpoint + - Click "Save & Test" + +## Available Metrics + +Infisical exposes the following key metrics in OpenTelemetry format: + +### API Performance Metrics + +- `API_latency` - API request latency histogram in milliseconds + + - **Labels**: `route`, `method`, `statusCode` + - **Example**: Monitor response times for specific endpoints + +- `API_errors` - API error count histogram + - **Labels**: `route`, `method`, `type`, `name` + - **Example**: Track error rates by endpoint and error type + +### Integration & Secret Sync Metrics + +- `integration_secret_sync_errors` - Integration secret sync error count + + - **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId` + - **Example**: Monitor integration sync failures across different services + +- `secret_sync_sync_secrets_errors` - Secret sync operation error count + + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Track secret sync failures to external systems + +- `secret_sync_import_secrets_errors` - Secret import operation error count + + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Monitor secret import failures + +- `secret_sync_remove_secrets_errors` - Secret removal operation error count + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Track secret removal operation failures + +### System Metrics + +These metrics are automatically collected by OpenTelemetry's HTTP instrumentation: + +- `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum) +- `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum) + +### Custom Business Metrics + +- `infisical_secret_operations_total` - Total secret operations +- `infisical_secrets_processed_total` - Total secrets processed + +## Troubleshooting + +### Common Issues + +1. **Metrics not appearing**: + + - Check if `OTEL_TELEMETRY_COLLECTION_ENABLED=true` + - Verify the correct `OTEL_EXPORT_TYPE` is set + - Check network connectivity between services + +2. **Authentication errors**: + + - Verify basic auth credentials in OTLP configuration + - Check if credentials match between Infisical and collector 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/roles/RoleOption.tsx b/frontend/src/components/roles/RoleOption.tsx new file mode 100644 index 000000000..857a13c4c --- /dev/null +++ b/frontend/src/components/roles/RoleOption.tsx @@ -0,0 +1,29 @@ +import { components, OptionProps } from "react-select"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +export const RoleOption = ({ + isSelected, + children, + ...props +}: OptionProps<{ name: string; slug: string; description?: string | undefined }>) => { + return ( + +
+
+

{children}

+ {props.data.description ? ( +

+ {props.data.description} +

+ ) : ( +

No Description

+ )} +
+ {isSelected && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/roles/index.tsx b/frontend/src/components/roles/index.tsx new file mode 100644 index 000000000..61f1aaa67 --- /dev/null +++ b/frontend/src/components/roles/index.tsx @@ -0,0 +1 @@ +export * from "./RoleOption"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx index 3d3f8df93..b88e0d6e6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx @@ -5,7 +5,9 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs"; import { + TRenderEnvironmentGroup, TRenderService, + useRenderConnectionListEnvironmentGroups, useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; import { SecretSync } from "@app/hooks/api/secretSyncs"; @@ -19,6 +21,7 @@ export const RenderSyncFields = () => { >(); const connectionId = useWatch({ name: "connection.id", control }); + const selectedScope = useWatch({ name: "destinationConfig.scope", control }); const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices( connectionId, @@ -27,11 +30,17 @@ export const RenderSyncFields = () => { } ); + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(connectionId, { + enabled: Boolean(connectionId) && selectedScope === RenderSyncScope.EnvironmentGroup + }); + return ( <> { setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.environmentGroupId", ""); setValue("destinationConfig.type", RenderSyncType.Env); setValue("destinationConfig.scope", RenderSyncScope.Service); }} @@ -83,30 +92,67 @@ export const RenderSyncFields = () => { )} /> - ( - - service.id === value) ?? []) : []} - onChange={(option) => { - onChange((option as SingleValue)?.id ?? null); - setValue( - "destinationConfig.serviceName", - (option as SingleValue)?.name ?? "" - ); - }} - options={services} - placeholder="Select a service..." - getOptionLabel={(option) => option.name} - getOptionValue={(option) => option.id.toString()} - /> - - )} - /> + {selectedScope === RenderSyncScope.Service && ( + ( + + service.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.serviceName", + (option as SingleValue)?.name ?? "" + ); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + + {selectedScope === RenderSyncScope.EnvironmentGroup && ( + ( + + g.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.environmentGroupName", + (option as SingleValue)?.name ?? "" + ); + }} + options={groups} + placeholder="Select an environment group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx index 15f5b6625..f195f0e72 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -4,6 +4,7 @@ import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { Badge } from "@app/components/v2"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; export const RenderSyncOptionsReviewFields = () => { const { watch } = useFormContext(); @@ -27,13 +28,20 @@ export const RenderSyncOptionsReviewFields = () => { export const RenderSyncReviewFields = () => { const { watch } = useFormContext(); - const serviceName = watch("destinationConfig.serviceName"); - const scope = watch("destinationConfig.scope"); + const config = watch("destinationConfig"); return ( <> - {scope} - {serviceName} + {config.scope} + {config.scope === RenderSyncScope.Service ? ( + + {config.serviceName ?? config.serviceId} + + ) : ( + + {config.environmentGroupName ?? config.environmentGroupId} + + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts index 83e5347eb..b65705c14 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts @@ -17,6 +17,12 @@ export const RenderSyncDestinationSchema = BaseSecretSyncSchema( serviceId: z.string().trim().min(1, "Service is required"), serviceName: z.string().trim().optional(), type: z.nativeEnum(RenderSyncType) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup), + environmentGroupId: z.string().trim().min(1, "Environment Group ID is required"), + environmentGroupName: z.string().trim().optional(), + type: z.nativeEnum(RenderSyncType) }) ]) }) 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/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 8da61583e..6ce82cfb2 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -212,5 +212,9 @@ export const RENDER_SYNC_SCOPES: Record { @@ -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..5063f4fff 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -36,6 +36,7 @@ export type Approver = { type: ApproverType; sequence?: number; approvalsRequired?: number; + isOrgMembershipActive: boolean; }; export type Bypasser = { @@ -82,6 +83,7 @@ export type TAccessApprovalRequest = { name: string; approvals: number; approvers: { + isOrgMembershipActive: boolean; userId: string; sequence?: number; approvalsRequired?: number; @@ -98,11 +100,14 @@ export type TAccessApprovalRequest = { }; reviewers: { + isOrgMembershipActive: boolean; userId: string; status: string; }[]; note?: string; + editNote?: string; + editedByUserId?: string; }; export type TAccessApproval = { @@ -146,6 +151,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; @@ -168,7 +180,7 @@ export type TCreateAccessPolicyDTO = { projectSlug: string; name?: string; environments: string[]; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; approvals?: number; secretPath: string; @@ -181,7 +193,7 @@ export type TCreateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; environments?: 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/appConnections/render/queries.tsx b/frontend/src/hooks/api/appConnections/render/queries.tsx index 728c46bd9..93ec24738 100644 --- a/frontend/src/hooks/api/appConnections/render/queries.tsx +++ b/frontend/src/hooks/api/appConnections/render/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TRenderService } from "./types"; +import { TRenderEnvironmentGroup, TRenderService } from "./types"; const renderConnectionKeys = { all: [...appConnectionKeys.all, "render"] as const, listServices: (connectionId: string) => - [...renderConnectionKeys.all, "services", connectionId] as const + [...renderConnectionKeys.all, "services", connectionId] as const, + listEnvironmentGroups: (connectionId: string) => + [...renderConnectionKeys.all, "environment-groups", connectionId] as const }; export const useRenderConnectionListServices = ( @@ -35,3 +37,28 @@ export const useRenderConnectionListServices = ( ...options }); }; + +export const useRenderConnectionListEnvironmentGroups = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRenderEnvironmentGroup[], + unknown, + TRenderEnvironmentGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: renderConnectionKeys.listEnvironmentGroups(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/render/${connectionId}/environment-groups` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/render/types.ts b/frontend/src/hooks/api/appConnections/render/types.ts index ec51adb78..d422a7ae5 100644 --- a/frontend/src/hooks/api/appConnections/render/types.ts +++ b/frontend/src/hooks/api/appConnections/render/types.ts @@ -2,3 +2,8 @@ export type TRenderService = { id: string; name: string; }; + +export type TRenderEnvironmentGroup = { + id: string; + name: string; +}; 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/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index cd620bb64..15e1b861c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -104,6 +104,7 @@ export const useUpdateOrg = () => { mutationFn: ({ name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, orgId, @@ -125,6 +126,7 @@ export const useUpdateOrg = () => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, defaultMembershipRoleSlug, diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 73267f0f6..71f2a8b90 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,6 +9,7 @@ export type Organization = { createAt: string; updatedAt: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; bypassOrgAuthEnabled: boolean; orgAuthMethod: string; scimEnabled: boolean; @@ -34,6 +35,7 @@ export type UpdateOrgDTO = { orgId: string; name?: string; authEnforced?: boolean; + googleSsoAuthEnforced?: boolean; scimEnabled?: boolean; slug?: string; defaultMembershipRoleSlug?: string; diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 8fd86624d..1785ffa8a 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -20,6 +20,7 @@ export enum ApproverType { } export type Approver = { + isOrgMembershipActive: boolean; id: string; type: ApproverType; }; @@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = { name?: string; environments: string[]; secretPath: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; approvals?: number; enforcementLevel: EnforcementLevel; @@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; approvals?: number; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index caf1df320..0eef3fa3d 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -53,6 +53,7 @@ export type TSecretApprovalRequest = { firstName: string; lastName: string; username: string; + isOrgMembershipActive: boolean; }[]; workspace: string; environment: string; @@ -62,6 +63,7 @@ export type TSecretApprovalRequest = { status: "open" | "close"; policy: Omit & { approvers: { + isOrgMembershipActive: boolean; userId: string; email: string; firstName: string; diff --git a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts index 3d66de623..4d217e840 100644 --- a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts @@ -4,12 +4,19 @@ import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/typ export type TRenderSync = TRootSecretSync & { destination: SecretSync.Render; - destinationConfig: { - scope: RenderSyncScope.Service; - type: RenderSyncType; - serviceId: string; - serviceName?: string; - }; + destinationConfig: + | { + type: RenderSyncType; + scope: RenderSyncScope.Service; + serviceId: string; + serviceName?: string | undefined; + } + | { + type: RenderSyncType; + scope: RenderSyncScope.EnvironmentGroup; + environmentGroupId: string; + environmentGroupName?: string | undefined; + }; connection: { app: AppConnection.Render; @@ -23,7 +30,8 @@ export type TRenderSync = TRootSecretSync & { }; export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 4ded71cfe..338599fe0 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -48,6 +48,7 @@ export type SubscriptionPlan = { externalKms: boolean; pkiEst: boolean; enforceMfa: boolean; + enforceGoogleSSO: boolean; projectTemplates: boolean; kmip: boolean; secretScanning: boolean; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 84bd05e07..b108a98c4 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -26,16 +26,16 @@ export const useAddUserToWsNonE2EE = () => { }); }; -export const sendEmailVerificationCode = async (username: string) => { +export const sendEmailVerificationCode = async (token: string) => { return apiRequest.post("/api/v2/users/me/emails/code", { - username + token }); }; export const useSendEmailVerificationCode = () => { return useMutation({ - mutationFn: async (username: string) => { - await sendEmailVerificationCode(username); + mutationFn: async (token: string) => { + await sendEmailVerificationCode(token); return {}; } }); diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 3af283b7e..0a43e850e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -83,6 +83,7 @@ export type TProjectMembership = { export type TWorkspaceUser = { id: string; user: { + isOrgMembershipActive: boolean; email: string; username: string; firstName: string; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..5c37d4404 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -240,6 +240,13 @@ export const Navbar = () => { return; } + if (org.googleSsoAuthEnforced) { + await logout.mutateAsync(); + window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); + window.close(); + return; + } + handleOrgChange(org?.id); }} variant="plain" 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/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index f331e4bda..9e6850f82 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -82,25 +82,40 @@ export const SelectOrganizationSection = () => { } } - if (organization.authEnforced && !canBypassOrgAuth) { + if ((organization.authEnforced || organization.googleSsoAuthEnforced) && !canBypassOrgAuth) { + const authToken = jwtDecode(getAuthToken()) as { authMethod: AuthMethod }; + // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO - await logout.mutateAsync(); let url = ""; if (organization.orgAuthMethod === AuthMethod.OIDC) { url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ callbackPort ? `&callbackPort=${callbackPort}` : "" }`; - } else { + } else if (organization.orgAuthMethod === AuthMethod.SAML) { url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; if (callbackPort) { url += `?callback_port=${callbackPort}`; } + } else if ( + organization.googleSsoAuthEnforced && + authToken.authMethod !== AuthMethod.GOOGLE + ) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } } - window.location.href = url; - return; + // we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO + // see line 103-106 + if (url) { + await logout.mutateAsync(); + window.location.href = url; + return; + } } const { token, isMfaEnabled, mfaMethod } = await selectOrg diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx index a200d3f7c..983b1ec61 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({ const resendCode = async () => { try { - await sendEmailVerificationCode(username); + const queryParams = new URLSearchParams(window.location.search); + const token = queryParams.get("token"); + if (!token) { + createNotification({ + text: "Failed to resend code, no token found", + type: "error" + }); + return; + } + await sendEmailVerificationCode(token); createNotification({ text: "Successfully resent code", type: "success" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index ec104d391..7f751fc7d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { RoleOption } from "@app/components/roles"; import { Button, FilterableSelect, @@ -45,7 +46,11 @@ const addMemberFormSchema = z.object({ ) .default([]), projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), - organizationRole: z.object({ name: z.string(), slug: z.string() }) + organizationRole: z.object({ + name: z.string(), + slug: z.string(), + description: z.string().optional() + }) }); type TAddMemberForm = z.infer; @@ -238,6 +243,7 @@ export const AddOrgMemberModal = ({ getOptionLabel={(option) => option.name} value={value} onChange={onChange} + components={{ Option: RoleOption }} /> )} diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 06de07e76..2798a7121 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -36,7 +36,8 @@ type GithubFormData = BaseFormData & type GithubRadarFormData = BaseFormData & Pick; -type GitLabFormData = BaseFormData & Pick; +type GitLabFormData = BaseFormData & + Pick; type AzureKeyVaultFormData = BaseFormData & Pick & @@ -147,7 +148,7 @@ export const OAuthCallbackPage = () => { clearState(AppConnection.GitLab); - const { connectionId, name, description, returnUrl, isUpdate } = formData; + const { connectionId, name, description, returnUrl, isUpdate, credentials } = formData; try { if (isUpdate && connectionId) { @@ -155,7 +156,8 @@ export const OAuthCallbackPage = () => { app: AppConnection.GitLab, connectionId, credentials: { - code: code as string + code: code as string, + instanceUrl: credentials.instanceUrl as string } }); } else { @@ -165,7 +167,8 @@ export const OAuthCallbackPage = () => { description, method: GitLabConnectionMethod.OAuth, credentials: { - code: code as string + code: code as string, + instanceUrl: credentials.instanceUrl as string } }); } diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index ac8685192..53df91974 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -1,5 +1,6 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -14,7 +15,21 @@ import { import { useLogoutUser, useUpdateOrg } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -export const OrgGeneralAuthSection = () => { +enum EnforceAuthType { + SAML = "saml", + GOOGLE = "google", + OIDC = "oidc" +} + +export const OrgGeneralAuthSection = ({ + isSamlConfigured, + isOidcConfigured, + isGoogleConfigured +}: { + isSamlConfigured: boolean; + isOidcConfigured: boolean; + isGoogleConfigured: boolean; +}) => { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); @@ -23,27 +38,61 @@ export const OrgGeneralAuthSection = () => { const logout = useLogoutUser(); - const handleEnforceOrgAuthToggle = async (value: boolean) => { + const handleEnforceOrgAuthToggle = async (value: boolean, type: EnforceAuthType) => { try { if (!currentOrg?.id) return; - if (!subscription?.samlSSO) { - handlePopUpOpen("upgradePlan"); - return; + + if (type === EnforceAuthType.SAML) { + if (!subscription?.samlSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else if (type === EnforceAuthType.GOOGLE) { + if (!subscription?.enforceGoogleSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + googleSsoAuthEnforced: value + }); + } else if (type === EnforceAuthType.OIDC) { + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else { + createNotification({ + text: `Invalid auth enforcement type ${type}`, + type: "error" + }); } - await mutateAsync({ - orgId: currentOrg?.id, - authEnforced: value - }); - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, + text: `Successfully ${value ? "enabled" : "disabled"} org-level auth`, type: "success" }); if (value) { await logout.mutateAsync(); - window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + + if (type === EnforceAuthType.SAML) { + window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + } else if (type === EnforceAuthType.GOOGLE) { + window.open(`/api/v1/sso/redirect/google?org_slug=${currentOrg.slug}`); + } + window.close(); } } catch (err) { @@ -78,45 +127,91 @@ export const OrgGeneralAuthSection = () => { }; return ( - <> - {/*
-
-

Allow users to send invites

- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

Allow members to invite new users to this organization

-
*/} -
-
-
- Enforce SAML SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via SAML to access this organization +

+
+

SSO Enforcement

+

+ Manage strict enforcement of specific authentication methods for your organization.

- {currentOrg?.authEnforced && ( -
+
+
+
+
+ Enforce SAML SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.SAML) + } + isChecked={currentOrg?.authEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.googleSsoAuthEnforced} + /> + )} + +
+

+ Enforce users to authenticate via SAML to access this organization. +
+ When this is enabled your organization members will only be able to login with SAML. +

+
+ +
+
+
+ Enforce OIDC SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.OIDC) + } + isDisabled={!isAllowed} + /> + )} + +
+

+ Enforce users to authenticate via OIDC to access this organization. +
+ When this is enabled your organization members will only be able to login with OIDC. +

+
+ +
+
+
+ Enforce Google SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE) + } + isChecked={currentOrg?.googleSsoAuthEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.authEnforced} + /> + )} + +
+

+ Enforce users to authenticate via Google to access this organization. +
+ When this is enabled your organization members will only be able to login with Google. +

+
+
+ {(currentOrg?.authEnforced || currentOrg?.googleSsoAuthEnforced) && ( +
Enable Admin SSO Bypass @@ -125,8 +220,8 @@ export const OrgGeneralAuthSection = () => { content={
- When this is enabled, we strongly recommend enforcing MFA at the organization - level. + When enabling admin SSO bypass, we highly recommend enabling MFA enforcement + at the organization-level for security reasons.

In case of a lockout, admins can use the{" "} @@ -182,6 +277,6 @@ export const OrgGeneralAuthSection = () => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can enforce SAML SSO if you switch to Infisical's Pro plan." /> - +

); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index 9c7293210..e66987bae 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -95,43 +95,25 @@ export const OrgLDAPSection = (): JSX.Element => { }; return ( -
+
-
-

LDAP

-
- - {(isAllowed) => ( - - )} - +
+
+

LDAP

+

Manage LDAP authentication configuration

-
-

Manage LDAP authentication configuration

-
-
-
-

LDAP Group Mappings

{(isAllowed) => ( - )}
-

- Manage how LDAP groups are mapped to internal groups in Infisical -

+ {data && ( -
+

Enable LDAP

@@ -152,6 +134,27 @@ export const OrgLDAPSection = (): JSX.Element => {

)} + +
+
+

LDAP Group Mappings

+ + {(isAllowed) => ( + + )} + +
+

+ Manage how LDAP groups are mapped to internal groups in Infisical +

+
+ { const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); - const { mutateAsync: updateOrg } = useUpdateOrg(); - const logout = useLogoutUser(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addOIDC", "upgradePlan" @@ -54,56 +52,6 @@ export const OrgOIDCSection = (): JSX.Element => { } }; - const handleEnforceOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - authEnforced: value - }); - - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, - type: "success" - }); - - if (value) { - await logout.mutateAsync(); - window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`); - window.close(); - } - } catch (err) { - console.error(err); - } - }; - - const handleEnableBypassOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - bypassOrgAuthEnabled: value - }); - - createNotification({ - text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, - type: "success" - }); - } catch (err) { - console.error(err); - } - }; - const handleOIDCGroupManagement = async (value: boolean) => { try { if (!currentOrg?.id) return; @@ -136,25 +84,22 @@ export const OrgOIDCSection = (): JSX.Element => { }; return ( -
-
-
-

OIDC

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

OIDC

+

Manage OIDC authentication configuration

-

Manage OIDC authentication configuration

+ + {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
{data && (
@@ -178,88 +123,6 @@ export const OrgOIDCSection = (): JSX.Element => {

)} -
-
-
- Enforce OIDC SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via OIDC to access this organization. -

-
- {currentOrg?.authEnforced && ( -
-
-
- Enable Admin SSO Bypass - - - When this is enabled, we strongly recommend enforcing MFA at the organization - level. - -

- In case of a lockout, admins can use the{" "} - - Admin Login Portal - {" "} - at{" "} - - {window.location.origin}/login/admin - -

-
- } - > - - -
- - {(isAllowed) => ( - handleEnableBypassOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- - Allow organization admins to bypass OIDC enforcement when SSO is unavailable, - misconfigured, or inaccessible. - -

-
- )}
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 3ffd8b3c2..33843f50f 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -79,25 +79,24 @@ export const OrgSSOSection = (): JSX.Element => { }; return ( - <> -
-
-
-

SAML

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

SAML

+

Manage SAML authentication configuration

-

Manage SAML authentication configuration

+ {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
-
-
+
+

Enable SAML

{!isPending && ( @@ -126,6 +125,6 @@ export const OrgSSOSection = (): JSX.Element => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can use SAML SSO if you switch to Infisical's Pro plan." /> - +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx index 9b11c1302..9964fdf4d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -49,13 +49,19 @@ export const OrgSsoTab = withPermission( ); const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig; - const shouldDisplaySection = (method: LoginMethod) => - !enabledLoginMethods || enabledLoginMethods.includes(method); + const shouldDisplaySection = (method: LoginMethod[] | LoginMethod) => { + if (Array.isArray(method)) { + return method.some((m) => !enabledLoginMethods || enabledLoginMethods.includes(m)); + } - const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer); + return !enabledLoginMethods || enabledLoginMethods.includes(method); + }; + + const isOidcConfigured = Boolean(oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer)); const isSamlConfigured = samlConfig && (samlConfig.entryPoint || samlConfig.issuer || samlConfig.cert); const isLdapConfigured = ldapConfig && ldapConfig.url; + const isGoogleConfigured = shouldDisplaySection(LoginMethod.GOOGLE); const shouldShowCreateIdentityProviderView = !isOidcConfigured && !isSamlConfigured && !isLdapConfigured; @@ -65,11 +71,14 @@ export const OrgSsoTab = withPermission( shouldDisplaySection(LoginMethod.OIDC) || shouldDisplaySection(LoginMethod.LDAP) ? ( <> -
-

Connect an Identity Provider

-

- Connect your identity provider to simplify user management -

+
+
+

Connect an Identity Provider

+

+ Connect your identity provider to simplify user management with options like SAML, + OIDC, and LDAP. +

+
{shouldDisplaySection(LoginMethod.SAML) && (
- {shouldShowCreateIdentityProviderView ? ( - createIdentityProviderView - ) : ( - <> - {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && ( -
- - +
+ {shouldDisplaySection([LoginMethod.SAML, LoginMethod.GOOGLE]) && ( + + )} + + {shouldShowCreateIdentityProviderView ? ( + createIdentityProviderView + ) : ( +
+
+ {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && } + {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } + {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && }
- )} - {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } - {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && } - - )} +
+ )} +
handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index 811bf57e7..d9c9a8718 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -6,6 +6,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { RoleOption } from "@app/components/roles"; import { Alert, AlertDescription, @@ -320,6 +321,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { > { 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/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx index 8c0687d61..b4ecd1e0e 100644 --- a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx @@ -24,7 +24,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {

Integration Logs

- Displaying audit logs from the last {auditLogsRetentionDays} days + Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days

{ showFilters={false} presets={{ eventMetadata: { integrationId: integration.id }, - startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)), + startDate: new Date( + new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60)) + ), eventType: INTEGRATION_EVENTS }} /> diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx index 00fcfe264..ab717f9b0 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx @@ -1,5 +1,8 @@ -import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; -import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; +import { + useRenderConnectionListEnvironmentGroups, + useRenderConnectionListServices +} from "@app/hooks/api/appConnections/render"; +import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; import { getSecretSyncDestinationColValues } from "../helpers"; import { SecretSyncTableCell } from "../SecretSyncTableCell"; @@ -9,21 +12,59 @@ type Props = { }; export const RenderSyncDestinationCol = ({ secretSync }: Props) => { + const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service; + const { data: services = [], isPending } = useRenderConnectionListServices( - secretSync.connectionId + secretSync.connectionId, + { + enabled: isServiceScope + } ); - const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ - ...secretSync, - destinationConfig: { - ...secretSync.destinationConfig, - serviceName: services.find((s) => s.id === secretSync.destinationConfig.serviceId)?.name + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope }); + + switch (secretSync.destinationConfig.scope) { + case RenderSyncScope.Service: { + const id = secretSync.destinationConfig.serviceId; + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + serviceName: services.find((s) => s.id === id)?.name + } + }); + + if (isPending) { + return ( + + ); + } + + return ; } - }); + case RenderSyncScope.EnvironmentGroup: { + const id = secretSync.destinationConfig.environmentGroupId; + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + environmentGroupName: groups.find((s) => s.id === id)?.name + } + }); - if (isPending) { - return ; + if (isGroupsPending) { + return ( + + ); + } + + return ; + } + default: + throw new Error("Unknown render sync destination scope"); } - - return ; }; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 9b88ba447..01c4024d7 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -8,6 +8,7 @@ import { } from "@app/hooks/api/secretSyncs/types/github-sync"; import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; // This functional ensures parity across what is displayed in the destination column // and the values used when search filtering @@ -125,8 +126,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { secondaryText = destinationConfig.app; break; case SecretSync.Render: - primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; - secondaryText = "Service"; + if (destinationConfig.scope === RenderSyncScope.Service) { + primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; + secondaryText = "Service"; + } else { + primaryText = + destinationConfig.environmentGroupName ?? destinationConfig.environmentGroupId; + secondaryText = "Environment Group"; + } + break; case SecretSync.Flyio: primaryText = destinationConfig.appId; 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"}`} + > + + + )} + /> + ( + +