From 2cbf33ac140327c4e03d35263d3189e91ea60e2d Mon Sep 17 00:00:00 2001 From: = Date: Fri, 24 Jan 2025 18:32:44 +0530 Subject: [PATCH 001/111] feat: added new permission check --- .../services/permission/permission-types.ts | 16 - backend/src/lib/casl/index.ts | 281 ++++++++++++++++-- 2 files changed, 252 insertions(+), 45 deletions(-) diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index 1ad0b205b..1708404f6 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -5,22 +5,6 @@ import { PermissionConditionOperators } from "@app/lib/casl"; export const PermissionConditionSchema = { [PermissionConditionOperators.$IN]: z.string().trim().min(1).array(), - [PermissionConditionOperators.$ALL]: z.string().trim().min(1).array(), - [PermissionConditionOperators.$REGEX]: z - .string() - .min(1) - .refine( - (el) => { - try { - // eslint-disable-next-line no-new - new RegExp(el); - return true; - } catch { - return false; - } - }, - { message: "Invalid regex pattern" } - ), [PermissionConditionOperators.$EQ]: z.string().min(1), [PermissionConditionOperators.$NEQ]: z.string().min(1), [PermissionConditionOperators.$GLOB]: z diff --git a/backend/src/lib/casl/index.ts b/backend/src/lib/casl/index.ts index ad4bf028f..1bc3f4f59 100644 --- a/backend/src/lib/casl/index.ts +++ b/backend/src/lib/casl/index.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { buildMongoQueryMatcher, MongoAbility } from "@casl/ability"; +import { buildMongoQueryMatcher, createMongoAbility, MongoAbility } from "@casl/ability"; import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; import picomatch from "picomatch"; @@ -20,21 +20,193 @@ const glob: JsInterpreter> = (node, object, context) => { export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); -/** - * Extracts and formats permissions from a CASL Ability object or a raw permission set. - */ -const extractPermissions = (ability: MongoAbility) => { - const permissions: string[] = []; - ability.rules.forEach((permission) => { - if (typeof permission.action === "string") { - permissions.push(`${permission.action}_${permission.subject as string}`); - } else { - permission.action.forEach((permissionAction) => { - permissions.push(`${permissionAction}_${permission.subject as string}`); - }); +export enum PermissionConditionOperators { + $IN = "$in", + $EQ = "$eq", + $NEQ = "$ne", + $GLOB = "$glob" +} + +type TPermissionConditionShape = { + [PermissionConditionOperators.$EQ]: string; + [PermissionConditionOperators.$NEQ]: string; + [PermissionConditionOperators.$GLOB]: string; + [PermissionConditionOperators.$IN]: string[]; +}; + +const getPermissionSetContainerID = (action: string, subject: string) => `${action}:${subject}`; +const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation); +const formatConditionOperator = (condition: TPermissionConditionShape | string) => { + return ( + typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition + ) as TPermissionConditionShape; +}; + +const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => { + if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]; + const isInverted = Boolean(subset[PermissionConditionOperators.$NEQ]); + if ( + parentSet[PermissionConditionOperators.$EQ] && + invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue) + ) { + return false; } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$IN] && + invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + invertTheOperation( + isInverted, + !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false }) + ) + ) { + return false; + } + } + if (subset[PermissionConditionOperators.$IN]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$IN]; + if ( + parentSet[PermissionConditionOperators.$EQ] && + (subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ]) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + !subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ]) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$IN] && + !subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el)) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !subsetOperatorValue.every((el) => + picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], { + strictSlashes: false + }) + ) + ) { + return false; + } + } + if (subset[PermissionConditionOperators.$GLOB]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB]; + const { isGlob } = picomatch.scan(subsetOperatorValue); + // if it's glob, all other fixed operators would make this superset because glob is powerful. like eq + // example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger + if ( + isGlob && + Object.keys(parentSet).some( + (el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ + ) + ) { + return false; + } + + if ( + parentSet[PermissionConditionOperators.$EQ] && + parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, { + strictSlashes: false + }) + ) { + return false; + } + // if parent set is IN, glob cannot be used for children - It's a bigger scope + if ( + parentSet[PermissionConditionOperators.$IN] && + !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { + strictSlashes: false + }) + ) { + return false; + } + } + return true; +}; + +const isSubsetForSamePermissionSubjectAction = ( + // [{action, subject,conditions{env: dev}}] + parentSetRules: ReturnType, + // [{action, subject,conditions{env: prod}}, {action, subject,conditions{env: dev,secretPath: "/"}] + subsetRules: ReturnType +) => { + const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions); + if (isMissingConditionInParent) return true; + + // all subset rules must pass in comparison to parent rul + return subsetRules.every((subsetRule) => { + const subsetRuleConditions = subsetRule.conditions as Record; + + // compare subset rule with all parent rules + const isSubsetOfNonInvertedParentSet = parentSetRules + .filter((el) => !el.inverted) + .some((parentSetRule) => { + // get conditions and iterate + const parentSetRuleConditions = parentSetRule?.conditions as Record; + if (!parentSetRuleConditions) return true; + return Object.keys(parentSetRuleConditions).every((parentConditionField) => { + // if parent condition is missing then it's never a subset + if (!subsetRuleConditions?.[parentConditionField]) return false; + + // standardize the conditions plain string operator => $eq function + const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); + const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; + const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); + return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); + }); + }); + + const invertedParentSetRules = parentSetRules.filter((el) => el.inverted); + const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length + ? !invertedParentSetRules.some((parentSetRule) => { + // get conditions and iterate + const parentSetRuleConditions = parentSetRule?.conditions as Record< + string, + TPermissionConditionShape | string + >; + if (!parentSetRuleConditions) return true; + return Object.keys(parentSetRuleConditions).every((parentConditionField) => { + // if parent condition is missing then it's never a subset + if (!subsetRuleConditions?.[parentConditionField]) return false; + + // standardize the conditions plain string operator => $eq function + const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); + const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; + const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); + return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); + }); + }) + : true; + return isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet; }); - return permissions; }; /** @@ -42,24 +214,75 @@ const extractPermissions = (ability: MongoAbility) => { * The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions. * */ -export const isAtLeastAsPrivileged = (permissions1: MongoAbility, permissions2: MongoAbility) => { - const set1 = new Set(extractPermissions(permissions1)); - const set2 = new Set(extractPermissions(permissions2)); +export const isAtLeastAsPrivileged = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => { + const checkedPermissionRules = new Set(); + for (const subsetPermissionRules of subsetPermissions.rules) { + const subsetPermissionSubject = subsetPermissionRules.subject.toString(); + let subsetPermissionActions: string[] = []; - for (const perm of set2) { - if (!set1.has(perm)) { - return false; + if (typeof subsetPermissionRules.action === "string") { + subsetPermissionActions.push(subsetPermissionRules.action); + } else { + subsetPermissionRules.action.forEach((subsetPermissionAction) => { + subsetPermissionActions.push(subsetPermissionAction); + }); } + subsetPermissionActions = subsetPermissionActions.filter( + (el) => !checkedPermissionRules.has(getPermissionSetContainerID(el, subsetPermissionSubject)) + ); + + // eslint-disable-next-line no-continue + if (!subsetPermissionActions.length) continue; + // eslint-disable-next-line no-unreachable-loop + for (const subsetPermissionAction of subsetPermissionActions) { + const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor( + subsetPermissionAction, + subsetPermissionSubject + ); + const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted); + if (!nonInveretedOnes.length) return false; + + const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject); + const isSubset = isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules); + if (!isSubset) return false; + } + + subsetPermissionActions.forEach((el) => + checkedPermissionRules.add(getPermissionSetContainerID(el, subsetPermissionSubject)) + ); } - return set1.size >= set2.size; + return true; }; -export enum PermissionConditionOperators { - $IN = "$in", - $ALL = "$all", - $REGEX = "$regex", - $EQ = "$eq", - $NEQ = "$ne", - $GLOB = "$glob" -} +const superset = createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + }, + { + action: "read", + subject: "secrets", + inverted: true, + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello" } + } + } +]); + +const subset = createMongoAbility([ + { + action: "edit", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/hello" } + } + } +]); + +console.log(isAtLeastAsPrivileged(superset, subset)); From c993b1bbe3c2b7e8549f86f7e0c7443abbcc5c49 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 28 Jan 2025 19:50:55 +0530 Subject: [PATCH 002/111] feat: completed new permission boundary check --- backend/package.json | 1 + .../src/ee/services/group/group-service.ts | 43 +- ...project-additional-privilege-v2-service.ts | 32 +- ...ty-project-additional-privilege-service.ts | 32 +- ...oject-user-additional-privilege-service.ts | 22 +- backend/src/lib/casl/boundary.test.ts | 665 ++++++++++++++++++ backend/src/lib/casl/boundary.ts | 249 +++++++ backend/src/lib/casl/index.ts | 262 +------ backend/src/lib/errors/index.ts | 10 +- backend/src/server/plugins/error-handler.ts | 3 +- .../group-project/group-project-service.ts | 27 +- .../identity-aws-auth-service.ts | 9 +- .../identity-azure-auth-service.ts | 9 +- .../identity-gcp-auth-service.ts | 9 +- .../identity-jwt-auth-service.ts | 10 +- .../identity-kubernetes-auth-service.ts | 9 +- .../identity-oidc-auth-service.ts | 10 +- .../identity-project-service.ts | 33 +- .../identity-token-auth-service.ts | 26 +- .../identity-ua/identity-ua-service.ts | 39 +- .../src/services/identity/identity-service.ts | 42 +- .../project-membership-service.ts | 12 +- backend/vitest.unit.config.ts | 17 + 23 files changed, 1183 insertions(+), 388 deletions(-) create mode 100644 backend/src/lib/casl/boundary.test.ts create mode 100644 backend/src/lib/casl/boundary.ts create mode 100644 backend/vitest.unit.config.ts diff --git a/backend/package.json b/backend/package.json index 43409e619..c0dc33bf0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -40,6 +40,7 @@ "type:check": "tsc --noEmit", "lint:fix": "eslint --fix --ext js,ts ./src", "lint": "eslint 'src/**/*.ts'", + "test:unit": "vitest run -c vitest.unit.config.ts", "test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1", "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts", diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7de3f8f92..7f1bca5d8 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -3,7 +3,7 @@ import slugify from "@sindresorhus/slugify"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; @@ -87,9 +87,14 @@ export const groupServiceFactory = ({ actorOrgId ); const isCustomRole = Boolean(customRole); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" }); + + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const group = await groupDAL.transaction(async (tx) => { const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); @@ -156,9 +161,13 @@ export const groupServiceFactory = ({ ); const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" }); + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); if (isCustomRole) customRole = customOrgRole; } @@ -329,9 +338,13 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPrivileges) - throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); + const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to add user to more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const user = await userDAL.findOne({ username }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); @@ -396,9 +409,13 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPrivileges) - throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); + const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete user from more privileged group", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const user = await userDAL.findOne({ username }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts index 3a38c0d65..08e3a7727 100644 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts @@ -3,7 +3,7 @@ import { packRules } from "@casl/ability/extra"; import ms from "ms"; import { ActionProjectType, TableName } from "@app/db/schemas"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { unpackPermissions } from "@app/server/routes/santizedSchemas/permission"; import { ActorType } from "@app/services/auth/auth-type"; @@ -79,9 +79,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -161,9 +165,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); if (data?.slug) { const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ @@ -239,9 +247,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); return { diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 16c0cc212..4ac60a659 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import ms from "ms"; import { ActionProjectType } from "@app/db/schemas"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { ActorType } from "@app/services/auth/auth-type"; @@ -88,9 +88,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -172,9 +176,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -268,9 +276,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to edit more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index 14586d5e2..7cf255bcc 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import ms from "ms"; import { ActionProjectType, TableName } from "@app/db/schemas"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { ActorType } from "@app/services/auth/auth-type"; @@ -76,9 +76,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetUserPermission.update(targetUserPermission.rules.concat(customPermission)); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged user", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, @@ -163,9 +167,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || [])); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to update more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); if (dto?.slug) { const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ diff --git a/backend/src/lib/casl/boundary.test.ts b/backend/src/lib/casl/boundary.test.ts new file mode 100644 index 000000000..c5e284a2e --- /dev/null +++ b/backend/src/lib/casl/boundary.test.ts @@ -0,0 +1,665 @@ +import { createMongoAbility } from "@casl/ability"; + +import { PermissionConditionOperators } from "."; +import { validatePermissionBoundary } from "./boundary"; + +describe("Validate Permission Boundary Function", () => { + test.each([ + { + title: "child with equal privilege", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets" + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "child with less privilege", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit"], + subject: "secrets" + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "child with more privilege", + parentPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit"], + subject: "secrets" + } + ]), + expectValid: false, + missingPermissions: [{ action: "edit", subject: "secrets" }] + }, + { + title: "parent with multiple and child with multiple", + parentPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets" + }, + { + action: ["create", "edit"], + subject: "members" + } + ]), + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "members" + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "Child with no access", + parentPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets" + }, + { + action: ["create", "edit"], + subject: "members" + } + ]), + childPermission: createMongoAbility([]), + expectValid: true, + missingPermissions: [] + }, + { + title: "Parent and child disjoint set", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]), + childPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]), + expectValid: false, + missingPermissions: ["create", "edit", "delete", "read"].map((el) => ({ + action: el, + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "dev" } + } + })) + }, + { + title: "Parent with inverted rules", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + }, + { + action: "read", + subject: "secrets", + inverted: true, + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" } + } + } + ]), + childPermission: createMongoAbility([ + { + action: "read", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/" } + } + } + ]), + expectValid: true, + missingPermissions: [] + }, + { + title: "Parent with inverted rules - child accessing invalid one", + parentPermission: createMongoAbility([ + { + action: ["create", "edit", "delete", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + }, + { + action: "read", + subject: "secrets", + inverted: true, + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" } + } + } + ]), + childPermission: createMongoAbility([ + { + action: "read", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" } + } + } + ]), + expectValid: false, + missingPermissions: [ + { + action: "read", + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" }, + secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" } + } + } + ] + } + ])("Check permission: $title", ({ parentPermission, childPermission, expectValid, missingPermissions }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + if (expectValid) { + expect(permissionBoundary.isValid).toBeTruthy(); + } else { + expect(permissionBoundary.isValid).toBeFalsy(); + expect(permissionBoundary.missingPermissions).toEqual(expect.arrayContaining(missingPermissions)); + } + }); +}); + +describe("Validate Permission Boundary: Checking Parent $eq operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["create", "read"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "prod" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev**" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "staging" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); + +describe("Validate Permission Boundary: Checking Parent $neq operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["create", "read"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/", "/staging"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/dev**" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/hello" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); + +describe("Validate Permission Boundary: Checking Parent $IN operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "dev" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev"] } + } + } + ]) + }, + { + operator: `${PermissionConditionOperators.$IN} - 2`, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$EQ]: "prod" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$NEQ]: "dev" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["edit"], + subject: "secrets", + conditions: { + environment: { [PermissionConditionOperators.$GLOB]: "dev**" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); + +describe("Validate Permission Boundary: Checking Parent $GLOB operator", () => { + const parentPermission = createMongoAbility([ + { + action: ["create", "read"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" } + } + } + ]); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/hello/world", "/hello/world2"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**/world" } + } + } + ]) + } + ])("Child $operator truthy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeTruthy(); + }); + + test.each([ + { + operator: PermissionConditionOperators.$EQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$EQ]: "/print" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$NEQ, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$NEQ]: "/hello/world" } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$IN, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] } + } + } + ]) + }, + { + operator: PermissionConditionOperators.$GLOB, + childPermission: createMongoAbility([ + { + action: ["create"], + subject: "secrets", + conditions: { + secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" } + } + } + ]) + } + ])("Child $operator falsy cases", ({ childPermission }) => { + const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission); + expect(permissionBoundary.isValid).toBeFalsy(); + }); +}); diff --git a/backend/src/lib/casl/boundary.ts b/backend/src/lib/casl/boundary.ts new file mode 100644 index 000000000..dee006f25 --- /dev/null +++ b/backend/src/lib/casl/boundary.ts @@ -0,0 +1,249 @@ +import { MongoAbility } from "@casl/ability"; +import { MongoQuery } from "@ucast/mongo2js"; +import picomatch from "picomatch"; + +import { PermissionConditionOperators } from "./index"; + +type TMissingPermission = { + action: string; + subject: string; + conditions?: MongoQuery; +}; + +type TPermissionConditionShape = { + [PermissionConditionOperators.$EQ]: string; + [PermissionConditionOperators.$NEQ]: string; + [PermissionConditionOperators.$GLOB]: string; + [PermissionConditionOperators.$IN]: string[]; +}; + +const getPermissionSetID = (action: string, subject: string) => `${action}:${subject}`; +const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation); +const formatConditionOperator = (condition: TPermissionConditionShape | string) => { + return ( + typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition + ) as TPermissionConditionShape; +}; + +const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => { + // we compute each operator against each other in left hand side and right hand side + if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]; + const isInverted = Boolean(subset[PermissionConditionOperators.$NEQ]); + if ( + parentSet[PermissionConditionOperators.$EQ] && + invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$IN] && + invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)) + ) { + return false; + } + // ne and glob cannot match each other + if (parentSet[PermissionConditionOperators.$GLOB] && isInverted) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false }) + ) { + return false; + } + } + if (subset[PermissionConditionOperators.$IN]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$IN]; + if ( + parentSet[PermissionConditionOperators.$EQ] && + (subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ]) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ]) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$IN] && + !subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el)) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !subsetOperatorValue.every((el) => + picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], { + strictSlashes: false + }) + ) + ) { + return false; + } + } + if (subset[PermissionConditionOperators.$GLOB]) { + const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB]; + const { isGlob } = picomatch.scan(subsetOperatorValue); + // if it's glob, all other fixed operators would make this superset because glob is powerful. like eq + // example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger + if ( + isGlob && + Object.keys(parentSet).some( + (el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ + ) + ) { + return false; + } + + if ( + parentSet[PermissionConditionOperators.$EQ] && + parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$NEQ] && + picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, { + strictSlashes: false + }) + ) { + return false; + } + // if parent set is IN, glob cannot be used for children - It's a bigger scope + if ( + parentSet[PermissionConditionOperators.$IN] && + !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue) + ) { + return false; + } + if ( + parentSet[PermissionConditionOperators.$GLOB] && + !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { + strictSlashes: false + }) + ) { + return false; + } + } + return true; +}; + +const isSubsetForSamePermissionSubjectAction = ( + parentSetRules: ReturnType, + subsetRules: ReturnType, + appendToMissingPermission: (condition?: MongoQuery) => void +) => { + const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions); + if (isMissingConditionInParent) return true; + + // all subset rules must pass in comparison to parent rul + return subsetRules.every((subsetRule) => { + const subsetRuleConditions = subsetRule.conditions as Record; + // compare subset rule with all parent rules + const isSubsetOfNonInvertedParentSet = parentSetRules + .filter((el) => !el.inverted) + .some((parentSetRule) => { + // get conditions and iterate + const parentSetRuleConditions = parentSetRule?.conditions as Record; + if (!parentSetRuleConditions) return true; + return Object.keys(parentSetRuleConditions).every((parentConditionField) => { + // if parent condition is missing then it's never a subset + if (!subsetRuleConditions?.[parentConditionField]) return false; + + // standardize the conditions plain string operator => $eq function + const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); + const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; + const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); + return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); + }); + }); + + const invertedParentSetRules = parentSetRules.filter((el) => el.inverted); + const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length + ? !invertedParentSetRules.some((parentSetRule) => { + // get conditions and iterate + const parentSetRuleConditions = parentSetRule?.conditions as Record< + string, + TPermissionConditionShape | string + >; + if (!parentSetRuleConditions) return true; + return Object.keys(parentSetRuleConditions).every((parentConditionField) => { + // if parent condition is missing then it's never a subset + if (!subsetRuleConditions?.[parentConditionField]) return false; + + // standardize the conditions plain string operator => $eq function + const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); + const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; + const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); + return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); + }); + }) + : true; + const isSubset = isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet; + if (!isSubset) { + appendToMissingPermission(subsetRule.conditions); + } + return isSubset; + }); +}; + +export const validatePermissionBoundary = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => { + const checkedPermissionRules = new Set(); + const missingPermissions: TMissingPermission[] = []; + + subsetPermissions.rules.forEach((subsetPermissionRules) => { + const subsetPermissionSubject = subsetPermissionRules.subject.toString(); + let subsetPermissionActions: string[] = []; + + // actions can be string or string[] + if (typeof subsetPermissionRules.action === "string") { + subsetPermissionActions.push(subsetPermissionRules.action); + } else { + subsetPermissionRules.action.forEach((subsetPermissionAction) => { + subsetPermissionActions.push(subsetPermissionAction); + }); + } + + // if action is already processed ignore + subsetPermissionActions = subsetPermissionActions.filter( + (el) => !checkedPermissionRules.has(getPermissionSetID(el, subsetPermissionSubject)) + ); + + if (!subsetPermissionActions.length) return; + subsetPermissionActions.forEach((subsetPermissionAction) => { + const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor( + subsetPermissionAction, + subsetPermissionSubject + ); + const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted); + if (!nonInveretedOnes.length) { + missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject }); + return; + } + + const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject); + isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules, (conditions) => { + missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject, conditions }); + }); + }); + + subsetPermissionActions.forEach((el) => + checkedPermissionRules.add(getPermissionSetID(el, subsetPermissionSubject)) + ); + }); + + if (missingPermissions.length) { + return { isValid: false as const, missingPermissions }; + } + + return { isValid: true }; +}; diff --git a/backend/src/lib/casl/index.ts b/backend/src/lib/casl/index.ts index 1bc3f4f59..147d12ef7 100644 --- a/backend/src/lib/casl/index.ts +++ b/backend/src/lib/casl/index.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { buildMongoQueryMatcher, createMongoAbility, MongoAbility } from "@casl/ability"; +import { buildMongoQueryMatcher } from "@casl/ability"; import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; import picomatch from "picomatch"; @@ -26,263 +26,3 @@ export enum PermissionConditionOperators { $NEQ = "$ne", $GLOB = "$glob" } - -type TPermissionConditionShape = { - [PermissionConditionOperators.$EQ]: string; - [PermissionConditionOperators.$NEQ]: string; - [PermissionConditionOperators.$GLOB]: string; - [PermissionConditionOperators.$IN]: string[]; -}; - -const getPermissionSetContainerID = (action: string, subject: string) => `${action}:${subject}`; -const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation); -const formatConditionOperator = (condition: TPermissionConditionShape | string) => { - return ( - typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition - ) as TPermissionConditionShape; -}; - -const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => { - if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) { - const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]; - const isInverted = Boolean(subset[PermissionConditionOperators.$NEQ]); - if ( - parentSet[PermissionConditionOperators.$EQ] && - invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$NEQ] && - invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$IN] && - invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$GLOB] && - invertTheOperation( - isInverted, - !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false }) - ) - ) { - return false; - } - } - if (subset[PermissionConditionOperators.$IN]) { - const subsetOperatorValue = subset[PermissionConditionOperators.$IN]; - if ( - parentSet[PermissionConditionOperators.$EQ] && - (subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ]) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$NEQ] && - !subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ]) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$IN] && - !subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el)) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$GLOB] && - !subsetOperatorValue.every((el) => - picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], { - strictSlashes: false - }) - ) - ) { - return false; - } - } - if (subset[PermissionConditionOperators.$GLOB]) { - const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB]; - const { isGlob } = picomatch.scan(subsetOperatorValue); - // if it's glob, all other fixed operators would make this superset because glob is powerful. like eq - // example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger - if ( - isGlob && - Object.keys(parentSet).some( - (el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ - ) - ) { - return false; - } - - if ( - parentSet[PermissionConditionOperators.$EQ] && - parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$NEQ] && - picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, { - strictSlashes: false - }) - ) { - return false; - } - // if parent set is IN, glob cannot be used for children - It's a bigger scope - if ( - parentSet[PermissionConditionOperators.$IN] && - !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue) - ) { - return false; - } - if ( - parentSet[PermissionConditionOperators.$GLOB] && - !picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { - strictSlashes: false - }) - ) { - return false; - } - } - return true; -}; - -const isSubsetForSamePermissionSubjectAction = ( - // [{action, subject,conditions{env: dev}}] - parentSetRules: ReturnType, - // [{action, subject,conditions{env: prod}}, {action, subject,conditions{env: dev,secretPath: "/"}] - subsetRules: ReturnType -) => { - const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions); - if (isMissingConditionInParent) return true; - - // all subset rules must pass in comparison to parent rul - return subsetRules.every((subsetRule) => { - const subsetRuleConditions = subsetRule.conditions as Record; - - // compare subset rule with all parent rules - const isSubsetOfNonInvertedParentSet = parentSetRules - .filter((el) => !el.inverted) - .some((parentSetRule) => { - // get conditions and iterate - const parentSetRuleConditions = parentSetRule?.conditions as Record; - if (!parentSetRuleConditions) return true; - return Object.keys(parentSetRuleConditions).every((parentConditionField) => { - // if parent condition is missing then it's never a subset - if (!subsetRuleConditions?.[parentConditionField]) return false; - - // standardize the conditions plain string operator => $eq function - const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); - const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; - const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); - return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); - }); - }); - - const invertedParentSetRules = parentSetRules.filter((el) => el.inverted); - const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length - ? !invertedParentSetRules.some((parentSetRule) => { - // get conditions and iterate - const parentSetRuleConditions = parentSetRule?.conditions as Record< - string, - TPermissionConditionShape | string - >; - if (!parentSetRuleConditions) return true; - return Object.keys(parentSetRuleConditions).every((parentConditionField) => { - // if parent condition is missing then it's never a subset - if (!subsetRuleConditions?.[parentConditionField]) return false; - - // standardize the conditions plain string operator => $eq function - const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]); - const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField]; - const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition); - return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators); - }); - }) - : true; - return isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet; - }); -}; - -/** - * Compares two sets of permissions to determine if the first set is at least as privileged as the second set. - * The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions. - * - */ -export const isAtLeastAsPrivileged = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => { - const checkedPermissionRules = new Set(); - for (const subsetPermissionRules of subsetPermissions.rules) { - const subsetPermissionSubject = subsetPermissionRules.subject.toString(); - let subsetPermissionActions: string[] = []; - - if (typeof subsetPermissionRules.action === "string") { - subsetPermissionActions.push(subsetPermissionRules.action); - } else { - subsetPermissionRules.action.forEach((subsetPermissionAction) => { - subsetPermissionActions.push(subsetPermissionAction); - }); - } - subsetPermissionActions = subsetPermissionActions.filter( - (el) => !checkedPermissionRules.has(getPermissionSetContainerID(el, subsetPermissionSubject)) - ); - - // eslint-disable-next-line no-continue - if (!subsetPermissionActions.length) continue; - // eslint-disable-next-line no-unreachable-loop - for (const subsetPermissionAction of subsetPermissionActions) { - const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor( - subsetPermissionAction, - subsetPermissionSubject - ); - const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted); - if (!nonInveretedOnes.length) return false; - - const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject); - const isSubset = isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules); - if (!isSubset) return false; - } - - subsetPermissionActions.forEach((el) => - checkedPermissionRules.add(getPermissionSetContainerID(el, subsetPermissionSubject)) - ); - } - - return true; -}; - -const superset = createMongoAbility([ - { - action: ["create", "edit", "delete", "read"], - subject: "secrets", - conditions: { - environment: { [PermissionConditionOperators.$EQ]: "dev" } - } - }, - { - action: "read", - subject: "secrets", - inverted: true, - conditions: { - environment: { [PermissionConditionOperators.$EQ]: "dev" }, - secretPath: { [PermissionConditionOperators.$GLOB]: "/hello" } - } - } -]); - -const subset = createMongoAbility([ - { - action: "edit", - subject: "secrets", - conditions: { - environment: { [PermissionConditionOperators.$EQ]: "dev" }, - secretPath: { [PermissionConditionOperators.$EQ]: "/hello" } - } - } -]); - -console.log(isAtLeastAsPrivileged(superset, subset)); diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index cc1c1d66b..f3dc83f26 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -52,10 +52,18 @@ export class ForbiddenRequestError extends Error { error: unknown; - constructor({ name, error, message }: { message?: string; name?: string; error?: unknown } = {}) { + details?: unknown; + + constructor({ + name, + error, + message, + details + }: { message?: string; name?: string; error?: unknown; details?: unknown } = {}) { super(message ?? "You are not allowed to access this resource"); this.name = name || "ForbiddenError"; this.error = error; + this.details = details; } } diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 7f9e16197..0fd18bc29 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -122,7 +122,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider reqId: req.id, statusCode: HttpStatusCodes.Forbidden, message: error.message, - error: error.name + error: error.name, + details: error?.details }); } else if (error instanceof RateLimitError) { void res.status(HttpStatusCodes.TooManyRequests).send({ diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 067ff17b0..b408e2e95 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -4,7 +4,7 @@ import ms from "ms"; import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -102,11 +102,13 @@ export const groupProjectServiceFactory = ({ project.id ); - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPrivileges) { - throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to assign group to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input @@ -267,12 +269,13 @@ export const groupProjectServiceFactory = ({ requestedRoleChange, project.id ); - - const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPrivileges) { - throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to assign group to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index ff202f225..ddd9e0278 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -7,7 +7,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -339,9 +339,12 @@ export const identityAwsAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke aws auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke aws auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 01d013734..01878dbb3 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -312,9 +312,12 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke azure auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke azure auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 5e404ca20..7b0dd4390 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -358,9 +358,12 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke gcp auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke gcp auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { 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 6757b0b84..2c9b6306e 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 @@ -7,7 +7,7 @@ import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -508,11 +508,13 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke JWT auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke jwt auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 4508a255d..bd83885dc 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -7,7 +7,7 @@ import { IdentityAuthMethod, SecretKeyEncoding, TIdentityKubernetesAuthsUpdate } import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric, @@ -616,9 +616,12 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke kubernetes auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke kubernetes auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { 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 a1dbed46b..08c4e116f 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 @@ -8,7 +8,7 @@ import { IdentityAuthMethod, SecretKeyEncoding, TIdentityOidcAuthsUpdate } from import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { @@ -531,11 +531,13 @@ export const identityOidcAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke OIDC auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke oidc auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx); diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 36b9b0562..0b71165e2 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -4,7 +4,7 @@ import ms from "ms"; import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -91,11 +91,13 @@ export const identityProjectServiceFactory = ({ projectId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPriviledges) { - throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to change to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input @@ -185,9 +187,13 @@ export const identityProjectServiceFactory = ({ projectId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { - throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); - } + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to change to a more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); } // validate custom roles input @@ -277,8 +283,13 @@ export const identityProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - if (!isAtLeastAsPrivileged(permission, identityRolePermission)) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); return deletedIdentity; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index bf38c5fa1..d9e2d66fa 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -5,7 +5,7 @@ import { IdentityAuthMethod, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; @@ -245,11 +245,13 @@ export const identityTokenAuthServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke Token Auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke token auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx); @@ -295,10 +297,12 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to create token for identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to create token for identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); @@ -415,10 +419,12 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to update token for identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to update token for identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const [token] = await identityAccessTokenDAL.update( diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index b9837265a..650f0511b 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -8,7 +8,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; @@ -367,9 +367,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke universal auth of identity with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke universal auth of identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { @@ -414,10 +417,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to add identity to project with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const appCfg = getConfig(); @@ -475,9 +480,12 @@ export const identityUaServiceFactory = ({ actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to get identity with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const identityUniversalAuth = await identityUaDAL.findOne({ @@ -524,9 +532,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to read identity client secret of project with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to read identity client secret of project with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId); @@ -566,10 +577,12 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - - if (!isAtLeastAsPrivileged(permission, rolePermission)) + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: "Failed to revoke identity client secret with more privileged role" + name: "PermissionBoundaryError", + message: "Failed to revoke identity client secret with more privileged role", + details: { missingPermissions: permissionBoundary.missingPermissions } }); const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index fffcbacc2..68ec75287 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -4,7 +4,7 @@ import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -58,9 +58,13 @@ export const identityServiceFactory = ({ orgId ); const isCustomRole = Boolean(customRole); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const plan = await licenseService.getPlan(orgId); @@ -129,9 +133,13 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete a more privileged identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); let customRole: TOrgRoles | undefined; if (role) { @@ -141,9 +149,13 @@ export const identityServiceFactory = ({ ); const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" }); + const appliedRolePermissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!appliedRolePermissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to create a more privileged identity", + details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions } + }); if (isCustomRole) customRole = customOrgRole; } @@ -216,9 +228,13 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + if (!permissionBoundary.isValid) + throw new ForbiddenRequestError({ + name: "PermissionBoundaryError", + message: "Failed to delete more privileged user", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const deletedIdentity = await identityDAL.deleteById(id); diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index fd1382dcf..f47a37222 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; -import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -274,13 +274,13 @@ export const projectMembershipServiceFactory = ({ projectId ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); - - if (!hasRequiredPriviledges) { + const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ - message: `Failed to change to a more privileged role ${requestedRoleChange}` + name: "PermissionBoundaryError", + message: `Failed to change to a more privileged role ${requestedRoleChange}`, + details: { missingPermissions: permissionBoundary.missingPermissions } }); - } } // validate custom roles input diff --git a/backend/vitest.unit.config.ts b/backend/vitest.unit.config.ts new file mode 100644 index 000000000..97862d288 --- /dev/null +++ b/backend/vitest.unit.config.ts @@ -0,0 +1,17 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + env: { + NODE_ENV: "test" + }, + include: ["./src/**/*.test.ts"] + }, + resolve: { + alias: { + "@app": path.resolve(__dirname, "./src") + } + } +}); From 8061066e2755fa71bdc09edade6d52fc802dfe43 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 28 Jan 2025 19:59:20 +0530 Subject: [PATCH 003/111] feat: added detail description in ui notification --- frontend/src/hooks/api/reactQuery.tsx | 101 ++++++++++++++++++++++++++ frontend/src/hooks/api/types.ts | 14 ++++ 2 files changed, 115 insertions(+) diff --git a/frontend/src/hooks/api/reactQuery.tsx b/frontend/src/hooks/api/reactQuery.tsx index f6ae0ca76..be9dadbec 100644 --- a/frontend/src/hooks/api/reactQuery.tsx +++ b/frontend/src/hooks/api/reactQuery.tsx @@ -74,6 +74,107 @@ export const queryClient = new QueryClient({ ); return; } + if (serverResponse?.error === ApiErrorTypes.PermissionBoundaryError) { + createNotification( + { + title: "Forbidden Access", + type: "error", + text: `${serverResponse.message}.`, + callToAction: serverResponse?.details?.missingPermissions?.length ? ( + + + + + +
+ {serverResponse.details?.missingPermissions?.map((el, index) => { + const hasConditions = Boolean(Object.keys(el.conditions || {}).length); + return ( +
+
+ You are not authorized to perform the {el.action} action on the{" "} + {el.subject} resource.{" "} + {hasConditions && + "Your permission does not allow access to the following conditions:"} +
+ {hasConditions && ( +
    + {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { + const operators = ( + el.conditions as Record< + string, + | string + | { [K in PermissionConditionOperators]: string | string[] } + > + )[field]; + + const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); + if (typeof operators === "string") { + return ( +
  • + + {formattedFieldName} + {" "} + equal to{" "} + {operators} +
  • + ); + } + + return Object.keys(operators).map((operator, operatorIndex) => ( +
  • + + {formattedFieldName} + {" "} + + { + formatedConditionsOperatorNames[ + operator as PermissionConditionOperators + ] + } + {" "} + + {operators[ + operator as PermissionConditionOperators + ].toString()} + +
  • + )); + })} +
+ )} +
+ ); + })} +
+
+
+ ) : undefined, + copyActions: [ + { + value: serverResponse.reqId, + name: "Request ID", + label: `Request ID: ${serverResponse.reqId}` + } + ] + }, + { closeOnClick: false } + ); + return; + } if (serverResponse?.error === ApiErrorTypes.ForbiddenError) { createNotification( { diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index c03358b42..cda34ad60 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -44,6 +44,7 @@ export type { export enum ApiErrorTypes { ValidationError = "ValidationFailure", + PermissionBoundaryError = "PermissionBoundaryError", BadRequestError = "BadRequest", UnauthorizedError = "UnauthorizedError", ForbiddenError = "PermissionDenied" @@ -74,4 +75,17 @@ export type TApiErrors = statusCode: 400; message: string; error: ApiErrorTypes.BadRequestError; + } + | { + reqId: string; + statusCode: 403; + message: string; + error: ApiErrorTypes.PermissionBoundaryError; + details: { + missingPermissions: { + action: string; + subject: string; + conditions: Record>; + }[]; + }; }; From 1d57629036f0371d1e3963c2b732e433053e1a43 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 28 Jan 2025 20:00:19 +0530 Subject: [PATCH 004/111] feat: added unit test in github action --- .github/workflows/run-backend-tests.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-backend-tests.yml b/.github/workflows/run-backend-tests.yml index 1fc9deff6..f2ba04e76 100644 --- a/.github/workflows/run-backend-tests.yml +++ b/.github/workflows/run-backend-tests.yml @@ -34,7 +34,10 @@ jobs: working-directory: backend - name: Start postgres and redis run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis - - name: Start integration test + - name: Run unit test + run: npm run test:unit + working-directory: backend + - name: Run integration test run: npm run test:e2e working-directory: backend env: @@ -44,4 +47,5 @@ jobs: ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 - name: cleanup run: | - docker compose -f "docker-compose.dev.yml" down \ No newline at end of file + docker compose -f "docker-compose.dev.yml" down + From 757942aefcc944e709870791813dc73583ab0676 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 31 Jan 2025 15:16:27 +0530 Subject: [PATCH 005/111] feat: resolved nits --- backend/src/ee/services/group/group-service.ts | 2 +- backend/src/lib/casl/boundary.ts | 2 +- .../services/identity-project/identity-project-service.ts | 4 ++-- backend/src/services/identity-ua/identity-ua-service.ts | 6 +++--- backend/src/services/identity/identity-service.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7f1bca5d8..27e847896 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -165,7 +165,7 @@ export const groupServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to create a more privileged group", + message: "Failed to update a more privileged group", details: { missingPermissions: permissionBoundary.missingPermissions } }); if (isCustomRole) customRole = customOrgRole; diff --git a/backend/src/lib/casl/boundary.ts b/backend/src/lib/casl/boundary.ts index dee006f25..15592a7bd 100644 --- a/backend/src/lib/casl/boundary.ts +++ b/backend/src/lib/casl/boundary.ts @@ -29,7 +29,7 @@ const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermi // we compute each operator against each other in left hand side and right hand side if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) { const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]; - const isInverted = Boolean(subset[PermissionConditionOperators.$NEQ]); + const isInverted = !subset[PermissionConditionOperators.$EQ]; if ( parentSet[PermissionConditionOperators.$EQ] && invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue) diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 0b71165e2..e16ffb3d4 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -95,7 +95,7 @@ export const identityProjectServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to change to a more privileged role", + message: "Failed to assign to a more privileged role", details: { missingPermissions: permissionBoundary.missingPermissions } }); } @@ -287,7 +287,7 @@ export const identityProjectServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to delete more privileged identity", + message: "Failed to remove more privileged identity", details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 650f0511b..078b50c08 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -421,7 +421,7 @@ export const identityUaServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to add identity to project with more privileged role", + message: "Failed to create client secret for a more privileged identity.", details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -484,7 +484,7 @@ export const identityUaServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to get identity with more privileged role", + message: "Failed to get identity client secret with more privileged role", details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -536,7 +536,7 @@ export const identityUaServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to read identity client secret of project with more privileged role", + message: "Failed to read identity client secret of identity with more privileged role", details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 68ec75287..8ada2a5d1 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -137,7 +137,7 @@ export const identityServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to delete a more privileged identity", + message: "Failed to update a more privileged identity", details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -232,7 +232,7 @@ export const identityServiceFactory = ({ if (!permissionBoundary.isValid) throw new ForbiddenRequestError({ name: "PermissionBoundaryError", - message: "Failed to delete more privileged user", + message: "Failed to delete more privileged identity", details: { missingPermissions: permissionBoundary.missingPermissions } }); From c54eafc128e246ade7fb90721092033851f1faf0 Mon Sep 17 00:00:00 2001 From: = Date: Sat, 1 Feb 2025 01:54:56 +0530 Subject: [PATCH 006/111] fix: resolved typo --- backend/src/lib/casl/boundary.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/lib/casl/boundary.test.ts b/backend/src/lib/casl/boundary.test.ts index c5e284a2e..05c2b9ecf 100644 --- a/backend/src/lib/casl/boundary.test.ts +++ b/backend/src/lib/casl/boundary.test.ts @@ -72,6 +72,10 @@ describe("Validate Permission Boundary Function", () => { { action: ["create"], subject: "members" + }, + { + action: ["create"], + subject: "secrets" } ]), expectValid: true, From 30ccfbfc8e8a269c437c80e50366ea21468503bb Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 5 Mar 2025 17:20:57 -0300 Subject: [PATCH 007/111] Add actor to secret version history --- ...1152_add-actor-id-to-secret-versions-v2.ts | 52 +++++ backend/src/db/schemas/secret-versions-v2.ts | 5 +- .../secret-approval-request-service.ts | 12 +- .../secret-replication-service.ts | 8 + .../secret-snapshot-service.ts | 15 +- backend/src/server/routes/index.ts | 4 +- backend/src/server/routes/sanitizedSchemas.ts | 10 +- .../external-migration-fns.ts | 4 + .../secret-v2-bridge/secret-v2-bridge-fns.ts | 25 ++- .../secret-v2-bridge-service.ts | 70 +++++- .../secret-v2-bridge-types.ts | 8 + backend/src/services/secret/secret-service.ts | 34 ++- backend/src/services/secret/secret-types.ts | 8 + frontend/src/hooks/api/secrets/types.ts | 5 + .../SecretListView/SecretDetailSidebar.tsx | 204 +++++++++++------- 15 files changed, 376 insertions(+), 88 deletions(-) create mode 100644 backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts diff --git a/backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts b/backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts new file mode 100644 index 000000000..e4340a436 --- /dev/null +++ b/backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts @@ -0,0 +1,52 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + const hasSecretVersionV2UserActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "userActorId"); + const hasSecretVersionV2IdentityActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "identityActorId"); + const hasSecretVersionV2ActorType = await knex.schema.hasColumn(TableName.SecretVersionV2, "actorType"); + + if (!hasSecretVersionV2UserActorId) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.uuid("userActorId"); + t.foreign("userActorId").references("id").inTable(TableName.Users); + }); + } + + if (!hasSecretVersionV2IdentityActorId) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.uuid("identityActorId"); + t.foreign("identityActorId").references("id").inTable(TableName.Identity); + }); + } + if (!hasSecretVersionV2ActorType) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.string("actorType"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasSecretVersionV2UserActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "userActorId"); + const hasSecretVersionV2IdentityActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "identityActorId"); + const hasSecretVersionV2ActorType = await knex.schema.hasColumn(TableName.SecretVersionV2, "actorType"); + + if (hasSecretVersionV2UserActorId) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.dropColumn("userActorId"); + }); + } + + if (!hasSecretVersionV2IdentityActorId) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.dropColumn("identityActorId"); + }); + } + + if (!hasSecretVersionV2ActorType) { + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + t.dropColumn("actorType"); + }); + } +} diff --git a/backend/src/db/schemas/secret-versions-v2.ts b/backend/src/db/schemas/secret-versions-v2.ts index 160ed1c14..593a46b06 100644 --- a/backend/src/db/schemas/secret-versions-v2.ts +++ b/backend/src/db/schemas/secret-versions-v2.ts @@ -25,7 +25,10 @@ export const SecretVersionsV2Schema = z.object({ folderId: z.string().uuid(), userId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + userActorId: z.string().uuid().nullable().optional(), + identityActorId: z.string().uuid().nullable().optional(), + actorType: z.string().nullable().optional() }); export type TSecretVersionsV2 = z.infer; 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 17eecf508..7f8d266c9 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 @@ -757,7 +757,11 @@ export const secretApprovalRequestServiceFactory = ({ secretDAL, secretVersionDAL, secretTagDAL, - secretVersionTagDAL + secretVersionTagDAL, + actor: { + type: actor, + actorId + } }) : []; const updatedSecrets = secretUpdationCommits.length @@ -803,7 +807,11 @@ export const secretApprovalRequestServiceFactory = ({ secretDAL, secretVersionDAL, secretTagDAL, - secretVersionTagDAL + secretVersionTagDAL, + actor: { + type: actor, + actorId + } }) : []; const deletedSecret = secretDeletionCommits.length diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 3c25db98c..c9b0a3532 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -710,6 +710,10 @@ export const secretReplicationServiceFactory = ({ tx, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyCreatedSecrets.map((doc) => { return { keyEncoding: doc.keyEncoding, @@ -741,6 +745,10 @@ export const secretReplicationServiceFactory = ({ tx, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyUpdatedSecrets.map((doc) => { return { filter: { diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 1c34f6b3d..06ad0cba5 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -370,7 +370,20 @@ export const secretSnapshotServiceFactory = ({ const secrets = await secretV2BridgeDAL.insertMany( rollbackSnaps.flatMap(({ secretVersions, folderId }) => secretVersions.map( - ({ latestSecretVersion, version, updatedAt, createdAt, secretId, envId, id, tags, ...el }) => ({ + ({ + latestSecretVersion, + version, + updatedAt, + createdAt, + secretId, + envId, + id, + tags, + userActorId, + identityActorId, + actorType, + ...el + }) => ({ ...el, id: secretId, version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index efc1cb865..f4b1bcc35 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1039,7 +1039,9 @@ export const registerRoutes = async ( secretApprovalRequestSecretDAL, kmsService, snapshotService, - resourceMetadataDAL + resourceMetadataDAL, + userDAL, + identityDAL }); const secretApprovalRequestService = secretApprovalRequestServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 4d645ac4b..16a7396cd 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -111,7 +111,15 @@ export const secretRawSchema = z.object({ secretReminderRepeatDays: z.number().nullable().optional(), skipMultilineEncoding: z.boolean().default(false).nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + actor: z + .object({ + actorId: z.string().nullable(), + actorType: z.string().nullable(), + name: z.string().nullable().optional() + }) + .optional() + .nullable() }); export const ProjectPermissionSchema = z.object({ diff --git a/backend/src/services/external-migration/external-migration-fns.ts b/backend/src/services/external-migration/external-migration-fns.ts index 744678792..f4a54f0db 100644 --- a/backend/src/services/external-migration/external-migration-fns.ts +++ b/backend/src/services/external-migration/external-migration-fns.ts @@ -772,6 +772,10 @@ export const importDataIntoInfisicalFn = async ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }); } diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index cc40b0f26..046b23dec 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -10,6 +10,7 @@ import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-sche import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; +import { ActorType } from "../auth/auth-type"; const INTERPOLATION_SYNTAX_REG = /\${([a-zA-Z0-9-_.]+)}/g; // akhilmhdh: JS regex with global save state in .test @@ -62,6 +63,7 @@ export const fnSecretBulkInsert = async ({ resourceMetadataDAL, secretTagDAL, secretVersionTagDAL, + actor, tx }: TFnSecretBulkInsert) => { const sanitizedInputSecrets = inputSecrets.map( @@ -90,6 +92,9 @@ export const fnSecretBulkInsert = async ({ }) ); + const userActorId = actor && actor.type === ActorType.USER ? actor.actorId : undefined; + const identityActorId = actor && actor.type !== ActorType.USER ? actor.actorId : undefined; + const newSecrets = await secretDAL.insertMany( sanitizedInputSecrets.map((el) => ({ ...el, folderId })), tx @@ -106,6 +111,9 @@ export const fnSecretBulkInsert = async ({ sanitizedInputSecrets.map((el) => ({ ...el, folderId, + userActorId, + identityActorId, + actorType: actor?.type, secretId: newSecretGroupedByKeyName[el.key][0].id })), tx @@ -157,8 +165,12 @@ export const fnSecretBulkUpdate = async ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, - resourceMetadataDAL + resourceMetadataDAL, + actor }: TFnSecretBulkUpdate) => { + const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined; + const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined; + const sanitizedInputSecrets = inputSecrets.map( ({ filter, @@ -216,7 +228,10 @@ export const fnSecretBulkUpdate = async ({ encryptedValue, reminderRepeatDays, folderId, - secretId + secretId, + userActorId, + identityActorId, + actorType: actor?.type }) ), tx @@ -616,6 +631,11 @@ export const reshapeBridgeSecret = ( secret: Omit & { value: string; comment: string; + actor?: { + actorType?: string; + actorId?: string; + name?: string; + }; tags?: { id: string; slug: string; @@ -636,6 +656,7 @@ export const reshapeBridgeSecret = ( _id: secret.id, id: secret.id, user: secret.userId, + actor: secret.actor, tags: secret.tags, skipMultilineEncoding: secret.skipMultilineEncoding, secretReminderRepeatDays: secret.reminderRepeatDays, 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 0ffb0ea4c..dd0b2d9ec 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 @@ -62,6 +62,8 @@ import { } from "./secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "./secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "./secret-version-tag-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; type TSecretV2BridgeServiceFactoryDep = { secretDAL: TSecretV2BridgeDALFactory; @@ -85,6 +87,8 @@ type TSecretV2BridgeServiceFactoryDep = { >; snapshotService: Pick; resourceMetadataDAL: Pick; + userDAL: Pick; + identityDAL: Pick; }; export type TSecretV2BridgeServiceFactory = ReturnType; @@ -107,7 +111,9 @@ export const secretV2BridgeServiceFactory = ({ secretApprovalRequestDAL, secretApprovalRequestSecretDAL, kmsService, - resourceMetadataDAL + resourceMetadataDAL, + userDAL, + identityDAL }: TSecretV2BridgeServiceFactoryDep) => { const $validateSecretReferences = async ( projectId: string, @@ -301,6 +307,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -483,6 +493,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -1230,6 +1244,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -1490,6 +1508,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, resourceMetadataDAL }); updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); @@ -1522,6 +1544,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }); updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); @@ -1690,13 +1716,39 @@ export const secretV2BridgeServiceFactory = ({ projectId: folder.projectId }); const secretVersions = await secretVersionDAL.find({ secretId }, { offset, limit, sort: [["createdAt", "desc"]] }); - return secretVersions.map((el) => - reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { + + const userIds = Array.from( + new Set(secretVersions.map((version) => version.userActorId).filter(Boolean)) + ) as string[]; + + const users = userIds.length > 0 ? await userDAL.find({ $in: { id: userIds } }) : []; + const usersById = groupBy(users, (user) => user.id); + + const identitiesIds = Array.from( + new Set(secretVersions.map((version) => version.identityActorId).filter(Boolean)) + ) as string[]; + const identities = identitiesIds.length > 0 ? await identityDAL.find({ $in: { id: identitiesIds } }) : []; + const identitiesById = groupBy(identities, (identity) => identity.id); + + return secretVersions.map((el) => { + let entityId; + let actorName; + if (el.userActorId) { + actorName = usersById[el.userActorId]?.[0]?.username; + entityId = el.userActorId; + } else if (el.identityActorId) { + actorName = identitiesById[el.identityActorId]?.[0]?.name; + entityId = el.identityActorId; + } + const actorEntity = el.actorType ? { actorType: el.actorType, actorId: entityId, name: actorName } : undefined; + + return reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { ...el, + actor: actorEntity, value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }) - ); + }); + }); }; // this is a backfilling API for secret references @@ -1956,6 +2008,10 @@ export const secretV2BridgeServiceFactory = ({ secretTagDAL, resourceMetadataDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyCreatedSecrets.map((doc) => { return { type: doc.type, @@ -1982,6 +2038,10 @@ export const secretV2BridgeServiceFactory = ({ tx, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyUpdatedSecrets.map((doc) => { return { filter: { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index ad8264e81..22956463d 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -168,6 +168,10 @@ export type TFnSecretBulkInsert = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + actor?: { + type: string; + actorId: string; + }; }; type TRequireReferenceIfValue = @@ -192,6 +196,10 @@ export type TFnSecretBulkUpdate = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + actor?: { + type: string; + actorId: string; + }; tx?: Knex; }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 93f68e813..49640ac2a 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -284,6 +284,10 @@ export const secretServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -429,6 +433,10 @@ export const secretServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -822,6 +830,10 @@ export const secretServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -931,7 +943,11 @@ export const secretServiceFactory = ({ secretDAL, secretVersionDAL, secretTagDAL, - secretVersionTagDAL + secretVersionTagDAL, + actor: { + type: actor, + actorId + } }) ); @@ -2404,6 +2420,10 @@ export const secretServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -2514,6 +2534,10 @@ export const secretServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -2848,6 +2872,10 @@ export const secretServiceFactory = ({ secretDAL, tx, secretTagDAL, + actor: { + type: actor, + actorId + }, secretVersionTagDAL, inputSecrets: locallyCreatedSecrets.map((doc) => { return { @@ -2879,6 +2907,10 @@ export const secretServiceFactory = ({ tx, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyUpdatedSecrets.map((doc) => { return { filter: { diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 158605276..242296c50 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -322,6 +322,10 @@ export type TFnSecretBulkInsert = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + actor?: { + type?: string; + actorId: string; + }; }; export type TFnSecretBulkUpdate = { @@ -336,6 +340,10 @@ export type TFnSecretBulkUpdate = { secretTagDAL: Pick; secretVersionTagDAL: Pick; tx?: Knex; + actor?: { + type?: string; + actorId: string; + }; }; export type TAttachSecretTagsDTO = { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 92dc220b8..92e671c8b 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -101,6 +101,11 @@ export type SecretVersions = { skipMultilineEncoding?: boolean; createdAt: string; updatedAt: string; + actor?: { + actorId?: string, + actorType: string, + name?: string + }; }; // dto diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 168cd6f43..04f343427 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -9,11 +9,14 @@ import { faPlus, faShare, faTag, - faTrash + faTrash, + faUser, + faDesktop, + faServer } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -49,6 +52,8 @@ import { useGetSecretVersion } from "@app/hooks/api"; import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -120,7 +125,9 @@ export const SecretDetailSidebar = ({ {} ); const selectTagSlugs = selectedTags.map((i) => i.slug); - + const navigate = useNavigate(); + const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace.id); + const cannotEditSecret = permission.cannot( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { @@ -201,6 +208,41 @@ export const SecretDetailSidebar = ({ const secretReminderRepeatDays = watch("reminderRepeatDays"); const secretReminderNote = watch("reminderNote"); + const getModifiedByIcon = (userType: string) => { + switch (userType) { + case ActorType.USER: + return faUser; + case ActorType.IDENTITY: + return faDesktop; + default: + return faServer; + } + } + + const getUserMembershipId = (actorId: string) => { + return members.filter((member) => member.user?.id === actorId)?.[0].id || null; + } + + const getLinkToModifyHistoryEntity = (actorId: string, actorType: string) => { + switch(actorType) { + case ActorType.USER: + return `/${ProjectType.SecretManager}/${currentWorkspace.id}/members/${getUserMembershipId(actorId)}`; + case ActorType.IDENTITY: + return `/${ProjectType.SecretManager}/${currentWorkspace.id}/identities/${actorId}`; + default: + return null; + } + } + + const onModifyHistoryClick = (actorId: string | undefined, actorType: string) => { + if (actorId && actorType !== ActorType.PLATFORM) { + const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType); + if (redirectLink) { + navigate({ to: redirectLink }); + } + } + } + return ( <>
Version History
- {secretVersion?.map(({ createdAt, secretValue, version, id }) => ( + {secretVersion?.map(({ createdAt, secretValue, version, id, actor }) => (
@@ -633,36 +675,29 @@ export const SecretDetailSidebar = ({
-
-
- Value: -
-
-
- - + + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + e.currentTarget + .closest(".group") + ?.classList.remove("show-value"); + } + }} + > + + +
+ + {secretValue?.replace(/./g, "*")} + +
- - {secretValue?.replace(/./g, "*")} - -
From 2dda7180a9b53ef611887f721b6fda4a0fbabbe4 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 5 Mar 2025 17:36:00 -0300 Subject: [PATCH 008/111] Fix linter issue --- .../components/SecretListView/SecretDetailSidebar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 04f343427..161f9cbf1 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -681,6 +681,7 @@ export const SecretDetailSidebar = ({
Modified by: + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
onModifyHistoryClick(actor.actorId, actor.actorType)} className="cursor-pointer">
From 30bcf1f20411a739e0dfa4a6d9a7bfefc92c3a60 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Thu, 6 Mar 2025 09:10:13 -0300 Subject: [PATCH 009/111] Fix linter and type issues, made a small fix for secret rotation platform events --- .../secret-rotation-queue.ts | 2 + .../secret-snapshot-service.ts | 13 +++- backend/src/server/routes/sanitizedSchemas.ts | 4 +- .../secret-v2-bridge/secret-v2-bridge-fns.ts | 6 +- backend/src/services/secret/secret-fns.ts | 19 +++++- backend/src/services/secret/secret-types.ts | 4 +- frontend/src/hooks/api/secrets/types.ts | 8 +-- .../SecretListView/SecretDetailSidebar.tsx | 63 +++++++++++++------ .../SecretListView/SecretListView.tsx | 6 +- 9 files changed, 91 insertions(+), 34 deletions(-) diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index fdc493b9f..48af65d29 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -39,6 +39,7 @@ import { secretRotationPreSetFn } from "./secret-rotation-queue-fn"; import { TSecretRotationData, TSecretRotationDbFn, TSecretRotationEncData } from "./secret-rotation-queue-types"; +import { ActorType } from "@app/services/auth/auth-type"; export type TSecretRotationQueueFactory = ReturnType; @@ -332,6 +333,7 @@ export const secretRotationQueueFactory = ({ await secretVersionV2BridgeDAL.insertMany( updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, + actorType: ActorType.PLATFORM, secretId: id })), tx diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 06ad0cba5..40ac4493f 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -34,6 +34,7 @@ import { TSnapshotFolderDALFactory } from "./snapshot-folder-dal"; import { TSnapshotSecretDALFactory } from "./snapshot-secret-dal"; import { TSnapshotSecretV2DALFactory } from "./snapshot-secret-v2-dal"; import { getFullFolderPath } from "./snapshot-service-fns"; +import { ActorType } from "@app/services/auth/auth-type"; type TSecretSnapshotServiceFactoryDep = { snapshotDAL: TSnapshotDALFactory; @@ -414,8 +415,18 @@ export const secretSnapshotServiceFactory = ({ })), tx ); + const userActorId = actor === ActorType.USER ? actorId : undefined; + const identityActorId = actor !== ActorType.USER ? actorId : undefined; + const actorType = actor || ActorType.PLATFORM; + const secretVersions = await secretVersionV2BridgeDAL.insertMany( - secrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, secretId: id })), + secrets.map(({ id, updatedAt, createdAt, ...el }) => ({ + ...el, + secretId: id, + userActorId, + identityActorId, + actorType + })), tx ); await secretVersionV2TagBridgeDAL.insertMany( diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 16a7396cd..3009993c0 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -114,8 +114,8 @@ export const secretRawSchema = z.object({ updatedAt: z.date(), actor: z .object({ - actorId: z.string().nullable(), - actorType: z.string().nullable(), + actorId: z.string().nullable().optional(), + actorType: z.string().nullable().optional(), name: z.string().nullable().optional() }) .optional() diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 046b23dec..007df0872 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -94,6 +94,7 @@ export const fnSecretBulkInsert = async ({ const userActorId = actor && actor.type === ActorType.USER ? actor.actorId : undefined; const identityActorId = actor && actor.type !== ActorType.USER ? actor.actorId : undefined; + const actorType = actor?.type || ActorType.PLATFORM; const newSecrets = await secretDAL.insertMany( sanitizedInputSecrets.map((el) => ({ ...el, folderId })), @@ -113,7 +114,7 @@ export const fnSecretBulkInsert = async ({ folderId, userActorId, identityActorId, - actorType: actor?.type, + actorType, secretId: newSecretGroupedByKeyName[el.key][0].id })), tx @@ -170,6 +171,7 @@ export const fnSecretBulkUpdate = async ({ }: TFnSecretBulkUpdate) => { const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined; const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined; + const actorType = actor?.type || ActorType.PLATFORM; const sanitizedInputSecrets = inputSecrets.map( ({ @@ -231,7 +233,7 @@ export const fnSecretBulkUpdate = async ({ secretId, userActorId, identityActorId, - actorType: actor?.type + actorType }) ), tx diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 1775d1f44..3ecdcbd08 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -525,6 +525,7 @@ export const fnSecretBulkInsert = async ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor, tx }: TFnSecretBulkInsert) => { const sanitizedInputSecrets = inputSecrets.map( @@ -579,9 +580,17 @@ export const fnSecretBulkInsert = async ({ [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id })) ); + + const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined; + const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined; + const actorType = actor?.type || ActorType.PLATFORM; + const secretVersions = await secretVersionDAL.insertMany( sanitizedInputSecrets.map((el) => ({ ...el, + userActorId, + identityActorId, + actorType, secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id })), tx @@ -614,7 +623,8 @@ export const fnSecretBulkUpdate = async ({ secretDAL, secretVersionDAL, secretTagDAL, - secretVersionTagDAL + secretVersionTagDAL, + actor }: TFnSecretBulkUpdate) => { const sanitizedInputSecrets = inputSecrets.map( ({ @@ -664,10 +674,17 @@ export const fnSecretBulkUpdate = async ({ }) ); + const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined; + const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined; + const actorType = actor?.type || ActorType.PLATFORM; + const newSecrets = await secretDAL.bulkUpdate(sanitizedInputSecrets, tx); const secretVersions = await secretVersionDAL.insertMany( newSecrets.map(({ id, createdAt, updatedAt, ...el }) => ({ ...el, + userActorId, + identityActorId, + actorType, secretId: id })), tx diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 242296c50..4b671b02d 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -324,7 +324,7 @@ export type TFnSecretBulkInsert = { secretVersionTagDAL: Pick; actor?: { type?: string; - actorId: string; + actorId?: string; }; }; @@ -342,7 +342,7 @@ export type TFnSecretBulkUpdate = { tx?: Knex; actor?: { type?: string; - actorId: string; + actorId?: string; }; }; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 92e671c8b..9fbef1488 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -102,10 +102,10 @@ export type SecretVersions = { createdAt: string; updatedAt: string; actor?: { - actorId?: string, - actorType: string, - name?: string - }; + actorId?: string | null; + actorType?: string | null; + name?: string | null; + } | null; }; // dto diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 161f9cbf1..2f1a178b9 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -48,12 +48,11 @@ import { useWorkspace } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; -import { useGetSecretVersion } from "@app/hooks/api"; +import { useGetSecretVersion, useGetWorkspaceUsers } from "@app/hooks/api"; import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { ProjectType } from "@app/hooks/api/workspace/types"; import { ActorType } from "@app/hooks/api/auditLogs/enums"; -import { useGetWorkspaceUsers } from "@app/hooks/api"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -127,7 +126,7 @@ export const SecretDetailSidebar = ({ const selectTagSlugs = selectedTags.map((i) => i.slug); const navigate = useNavigate(); const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace.id); - + const cannotEditSecret = permission.cannot( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { @@ -199,9 +198,16 @@ export const SecretDetailSidebar = ({ await onSaveSecret(secret, { ...secret, ...data }, () => reset()); }; - const handleReminderSubmit = async (reminderRepeatDays: number | null | undefined, reminderNote: string | null | undefined) => { - await onSaveSecret(secret, { ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true }, () => { }); - } + const handleReminderSubmit = async ( + reminderRepeatDays: number | null | undefined, + reminderNote: string | null | undefined + ) => { + await onSaveSecret( + secret, + { ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true }, + () => {} + ); + }; const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false); @@ -217,14 +223,23 @@ export const SecretDetailSidebar = ({ default: return faServer; } - } + }; + + const getModifiedByName = (userType: string, userName: string | undefined) => { + switch (userType) { + case ActorType.PLATFORM: + return "System-generated"; + default: + return userName; + } + }; const getUserMembershipId = (actorId: string) => { return members.filter((member) => member.user?.id === actorId)?.[0].id || null; - } + }; const getLinkToModifyHistoryEntity = (actorId: string, actorType: string) => { - switch(actorType) { + switch (actorType) { case ActorType.USER: return `/${ProjectType.SecretManager}/${currentWorkspace.id}/members/${getUserMembershipId(actorId)}`; case ActorType.IDENTITY: @@ -232,16 +247,16 @@ export const SecretDetailSidebar = ({ default: return null; } - } + }; const onModifyHistoryClick = (actorId: string | undefined, actorType: string) => { - if (actorId && actorType !== ActorType.PLATFORM) { + if (actorId && actorType !== ActorType.PLATFORM) { const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType); if (redirectLink) { navigate({ to: redirectLink }); } } - } + }; return ( <> @@ -255,7 +270,7 @@ export const SecretDetailSidebar = ({ if (data) { setValue("reminderRepeatDays", data.days, { shouldDirty: false }); setValue("reminderNote", data.note, { shouldDirty: false }); - handleReminderSubmit(data.days, data.note) + handleReminderSubmit(data.days, data.note); } }} /> @@ -675,15 +690,23 @@ export const SecretDetailSidebar = ({
-
+
{actor && (
-
- Modified by: - +
+ Modified by: + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
onModifyHistoryClick(actor.actorId, actor.actorType)} className="cursor-pointer"> - +
+ onModifyHistoryClick(actor.actorId, actor.actorType) + } + className="cursor-pointer" + > +
@@ -697,7 +720,7 @@ export const SecretDetailSidebar = ({
-
-
-
-
- ); -} diff --git a/frontend/src/components/utilities/checks/password/PasswordCheck.ts b/frontend/src/components/utilities/checks/password/PasswordCheck.ts index e37abd475..fb5186220 100644 --- a/frontend/src/components/utilities/checks/password/PasswordCheck.ts +++ b/frontend/src/components/utilities/checks/password/PasswordCheck.ts @@ -34,12 +34,12 @@ const passwordCheck = async ({ const tests = [ { name: "tooShort", - validator: (pwd: string) => pwd.length >= 14, + validator: (pwd: string) => pwd?.length >= 14, setError: setPasswordErrorTooShort }, { name: "tooLong", - validator: (pwd: string) => pwd.length < 101, + validator: (pwd: string) => pwd?.length < 101, setError: setPasswordErrorTooLong }, { diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 66688cbdc..392f7b1f5 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -2,6 +2,8 @@ export { useGetAuthToken, useOauthTokenExchange, useResetPassword, + useResetPasswordV2, + useResetUserPasswordV2, useSelectOrganization, useSendMfaToken, useSendPasswordResetEmail, diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 9b8afbac7..796fd3152 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -22,12 +22,15 @@ import { LoginLDAPRes, MfaMethod, ResetPasswordDTO, + ResetPasswordV2DTO, + ResetUserPasswordV2DTO, SendMfaTokenDTO, SetupPasswordDTO, SRP1DTO, SRPR1Res, TOauthTokenExchangeDTO, UserAgentType, + UserEncryptionVersion, VerifyMfaTokenDTO, VerifyMfaTokenRes, VerifySignupInviteDTO @@ -247,7 +250,10 @@ export const useSendPasswordResetEmail = () => { export const useVerifyPasswordResetCode = () => { return useMutation({ mutationFn: async ({ email, code }: { email: string; code: string }) => { - const { data } = await apiRequest.post("/api/v1/password/email/password-reset-verify", { + const { data } = await apiRequest.post<{ + token: string; + userEncryptionVersion: UserEncryptionVersion; + }>("/api/v1/password/email/password-reset-verify", { email, code }); @@ -302,6 +308,26 @@ export const useResetPassword = () => { }); }; +export const useResetPasswordV2 = () => { + return useMutation({ + mutationFn: async (details: ResetPasswordV2DTO) => { + await apiRequest.post("/api/v2/password/password-reset", details, { + headers: { + Authorization: `Bearer ${details.verificationToken}` + } + }); + } + }); +}; + +export const useResetUserPasswordV2 = () => { + return useMutation({ + mutationFn: async (details: ResetUserPasswordV2DTO) => { + await apiRequest.post("/api/v2/password/user/password-reset", details); + } + }); +}; + export const changePassword = async (details: ChangePasswordDTO) => { const { data } = await apiRequest.post("/api/v1/password/change-password", details); return data; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 036897fed..32610c28d 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -3,6 +3,11 @@ export type GetAuthTokenAPI = { organizationId?: string; }; +export enum UserEncryptionVersion { + V1 = 1, + V2 = 2 +} + export type SendMfaTokenDTO = { email: string; }; @@ -136,6 +141,16 @@ export type ResetPasswordDTO = { password: string; }; +export type ResetPasswordV2DTO = { + newPassword: string; + verificationToken: string; +}; + +export type ResetUserPasswordV2DTO = { + oldPassword: string; + newPassword: string; +}; + export type SetupPasswordDTO = { protectedKey: string; protectedKeyIV: string; diff --git a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx index f1c70d028..97b363558 100644 --- a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { Helmet } from "react-helmet"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -8,16 +7,13 @@ import { AnimatePresence, motion } from "framer-motion"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF"; // TODO(akhilmhdh): rewrite this into module functions in lib import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; 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 { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto"; - -import { DownloadBackupKeys } from "./components/DownloadBackupKeys"; +import { generateUserPassKey } from "@app/lib/crypto"; const formSchema = z .object({ @@ -34,25 +30,17 @@ const formSchema = z type TFormSchema = z.infer; -enum SignupSteps { - DetailsForm = "details-form", - BackupKey = "backup-key" -} - export const SignUpPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { control, handleSubmit, - getValues, formState: { isSubmitting } } = useForm({ resolver: zodResolver(formSchema) }); - const [step, setStep] = useState(SignupSteps.DetailsForm); - const { config } = useServerConfig(); const { mutateAsync: createAdminUser } = useCreateAdminUser(); const { mutateAsync: selectOrganization } = useSelectOrganization(); @@ -84,7 +72,7 @@ export const SignUpPage = () => { // Will be refactored in next iteration to make it url based rather than local storage ones // Part of migration to nextjs 14 localStorage.setItem("orgData.id", res.organization.id); - setStep(SignupSteps.BackupKey); + navigate({ to: "/admin" }); } catch (err) { console.log(err); createNotification({ @@ -94,27 +82,7 @@ export const SignUpPage = () => { } }; - const handleBackupKeyGenerate = async () => { - try { - const { email, password, firstName, lastName } = getValues(); - const generatedKey = await generateUserBackupKey(email, password); - await generateBackupPDFAsync({ - generatedKey, - personalEmail: email, - personalName: `${firstName} ${lastName}` - }); - navigate({ to: "/admin" }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to generate backup" - }); - } - }; - - if (config?.initialized && step === SignupSteps.DetailsForm) - return ; + if (config?.initialized) return ; return (
@@ -127,56 +95,28 @@ export const SignUpPage = () => {
- {step === SignupSteps.DetailsForm && ( - -
- Infisical logo -
Welcome to Infisical
-
Create your first Super Admin Account
-
-
-
-
- ( - - - - )} - /> - ( - - - - )} - /> -
+ +
+ Infisical logo +
Welcome to Infisical
+
Create your first Super Admin Account
+
+ +
+
( @@ -186,56 +126,66 @@ export const SignUpPage = () => { /> ( - - - )} - /> - ( - - + )} />
- - - - )} - {step === SignupSteps.BackupKey && ( - - - - )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
diff --git a/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx b/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx deleted file mode 100644 index 253031a91..000000000 --- a/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -type Props = { - onGenerate: () => Promise; -}; - -export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => { - const { t } = useTranslation(); - const [isLoading, setIsLoading] = useToggle(); - - return ( -
-

- - {t("signup.step4-message")} -

-
-
- - {t("signup.step4-description1")} {t("signup.step4-description3")} - -
-
-
- -
-
-
-
- ); -}; diff --git a/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/index.tsx b/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/index.tsx deleted file mode 100644 index bbbd9aad9..000000000 --- a/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { DownloadBackupKeys } from "./DownloadBackupKeys"; diff --git a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx index 3361dd961..8b6be6b9a 100644 --- a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx @@ -1,396 +1,75 @@ -import crypto from "crypto"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; -import { FormEvent, useState } from "react"; -import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate, useSearch } from "@tanstack/react-router"; -import jsrp from "jsrp"; +import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; -import InputField from "@app/components/basic/InputField"; -import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck"; -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; -import { Button } from "@app/components/v2"; -import { ROUTE_PATHS } from "@app/const/routes"; -import { useResetPassword, useVerifyPasswordResetCode } from "@app/hooks/api"; -import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries"; +import { ConfirmEmailStep } from "./components/ConfirmEmailStep"; +import { EnterPasswordStep } from "./components/EnterPasswordStep"; +import { InputBackupKeyStep } from "./components/InputBackupKeyStep"; -// eslint-disable-next-line new-cap -const client = new jsrp.client(); +enum Steps { + ConfirmEmail = 1, + InputBackupKey = 2, + EnterNewPassword = 3 +} + +const formData = z.object({ + verificationToken: z.string(), + privateKey: z.string(), + userEncryptionVersion: z.nativeEnum(UserEncryptionVersion) +}); +type TFormData = z.infer; export const PasswordResetPage = () => { - const [verificationToken, setVerificationToken] = useState(""); - const [step, setStep] = useState(1); - const [loading, setLoading] = useState(false); - const [backupKey, setBackupKey] = useState(""); - const [privateKey, setPrivateKey] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [backupKeyError, setBackupKeyError] = useState(false); - const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(false); - const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false); - const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(false); - const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(false); - const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false); - const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false); - const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false); - const [passwordErrorBreached, setPasswordErrorBreached] = useState(false); + const { watch, setValue } = useForm({ + resolver: zodResolver(formData) + }); + const verificationToken = watch("verificationToken"); + const encryptionVersion = watch("userEncryptionVersion"); + const privateKey = watch("privateKey"); + + const [step, setStep] = useState(Steps.ConfirmEmail); const navigate = useNavigate(); - const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id }); - - const { - mutateAsync: verifyPasswordResetCodeMutateAsync, - isPending: isVerifyPasswordResetLoading - } = useVerifyPasswordResetCode(); - const { mutateAsync: resetPasswordMutateAsync } = useResetPassword(); - - const parsedUrl = search; - const token = parsedUrl.token as string; - const email = (parsedUrl.to as string)?.replace(" ", "+").trim(); - - // Decrypt the private key with a backup key - const getEncryptedKeyHandler = async (e: FormEvent) => { - e.preventDefault(); - try { - const result = await getBackupEncryptedPrivateKey({ verificationToken }); - - setPrivateKey( - Aes256Gcm.decrypt({ - ciphertext: result.encryptedPrivateKey, - iv: result.iv, - tag: result.tag, - secret: backupKey - }) - ); - setStep(3); - } catch (err) { - console.error(err); - setBackupKeyError(true); - } - }; - - // If everything is correct, reset the password - const resetPasswordHandler = async (e: FormEvent) => { - e.preventDefault(); - const errorCheck = await passwordCheck({ - password: newPassword, - setPasswordErrorTooShort, - setPasswordErrorTooLong, - setPasswordErrorNoLetterChar, - setPasswordErrorNoNumOrSpecialChar, - setPasswordErrorRepeatedChar, - setPasswordErrorEscapeChar, - setPasswordErrorLowEntropy, - setPasswordErrorBreached - }); - - if (!errorCheck) { - client.init( - { - username: email, - password: newPassword - }, - async () => { - client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => { - const derivedKey = await deriveArgonKey({ - password: newPassword, - salt: result.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) - }); - - await resetPasswordMutateAsync({ - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt: result.salt, - verifier: result.verifier, - verificationToken, - password: newPassword - }); - - navigate({ to: "/login" }); - - setLoading(false); - }); - } - ); - } - }; - - // Click a button to confirm email - const stepConfirmEmail = ( -
-

- Confirm your email -

- verify email -
- -
-
- ); - - // Input backup key - const stepInputBackupKey = ( -
-

- Enter your backup key -

-
-

- You can find it in your emergency kit. You had to download the emergency kit during - signup. -

-
-
- -
-
-
- -
-
-
- ); - - // Enter new password - const stepEnterNewPassword = ( -
-

- Enter new password -

-
-

- Make sure you save it somewhere safe. -

-
-
- { - setNewPassword(password); - passwordCheck({ - password, - setPasswordErrorTooShort, - setPasswordErrorTooLong, - setPasswordErrorNoLetterChar, - setPasswordErrorNoNumOrSpecialChar, - setPasswordErrorRepeatedChar, - setPasswordErrorEscapeChar, - setPasswordErrorLowEntropy, - setPasswordErrorBreached - }); - }} - type="password" - value={newPassword} - isRequired - error={ - passwordErrorTooShort && - passwordErrorTooLong && - passwordErrorNoLetterChar && - passwordErrorNoNumOrSpecialChar && - passwordErrorRepeatedChar && - passwordErrorEscapeChar && - passwordErrorLowEntropy && - passwordErrorBreached - } - autoComplete="new-password" - id="new-password" - /> -
- {passwordErrorTooShort || - passwordErrorTooLong || - passwordErrorNoLetterChar || - passwordErrorNoNumOrSpecialChar || - passwordErrorRepeatedChar || - passwordErrorEscapeChar || - passwordErrorLowEntropy || - passwordErrorBreached ? ( -
-
Password should contain:
-
- {passwordErrorTooShort ? ( - - ) : ( - - )} -
- at least 14 characters -
-
-
- {passwordErrorTooLong ? ( - - ) : ( - - )} -
- at most 100 characters -
-
-
- {passwordErrorNoLetterChar ? ( - - ) : ( - - )} -
- at least 1 letter character -
-
-
- {passwordErrorNoNumOrSpecialChar ? ( - - ) : ( - - )} -
- at least 1 number or special character -
-
-
- {passwordErrorRepeatedChar ? ( - - ) : ( - - )} -
- at most 3 repeated, consecutive characters -
-
-
- {passwordErrorEscapeChar ? ( - - ) : ( - - )} -
- No escape characters allowed. -
-
-
- {passwordErrorLowEntropy ? ( - - ) : ( - - )} -
- Password contains personal info. -
-
-
- {passwordErrorBreached ? ( - - ) : ( - - )} -
- Password was found in a data breach. -
-
-
- ) : ( -
- )} -
-
- -
-
- - ); return (
- {step === 1 && stepConfirmEmail} - {step === 2 && stepInputBackupKey} - {step === 3 && stepEnterNewPassword} + {step === Steps.ConfirmEmail && ( + { + setValue("verificationToken", verifyToken); + setValue("userEncryptionVersion", userEncryptionVersion); + + if (userEncryptionVersion === UserEncryptionVersion.V2) { + setStep(Steps.EnterNewPassword); + } else { + setStep(Steps.InputBackupKey); + } + }} + /> + )} + {step === Steps.InputBackupKey && ( + { + setValue("privateKey", key); + setStep(Steps.EnterNewPassword); + }} + /> + )} + {step === Steps.EnterNewPassword && ( + { + navigate({ to: "/login" }); + }} + /> + )}
); }; diff --git a/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx new file mode 100644 index 000000000..1f1a79490 --- /dev/null +++ b/frontend/src/pages/auth/PasswordResetPage/components/ConfirmEmailStep.tsx @@ -0,0 +1,54 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; + +import { Button } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useVerifyPasswordResetCode } from "@app/hooks/api"; +import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; + +type Props = { + onComplete: (verificationToken: string, encryptionVersion: UserEncryptionVersion) => void; +}; + +export const ConfirmEmailStep = ({ onComplete }: Props) => { + const navigate = useNavigate(); + const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id }); + const { token, to: email } = search; + + const { + mutateAsync: verifyPasswordResetCodeMutateAsync, + isPending: isVerifyPasswordResetLoading + } = useVerifyPasswordResetCode(); + return ( +
+

+ Confirm your email +

+ verify email +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx new file mode 100644 index 000000000..de7bcd3f3 --- /dev/null +++ b/frontend/src/pages/auth/PasswordResetPage/components/EnterPasswordStep.tsx @@ -0,0 +1,325 @@ +import crypto from "crypto"; + +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useSearch } from "@tanstack/react-router"; +import jsrp from "jsrp"; +import { z } from "zod"; + +import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck"; +import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; +import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useResetPassword, useResetPasswordV2 } from "@app/hooks/api"; +import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; + +const formData = z.object({ + password: z.string(), + passwordErrorTooShort: z.boolean().optional(), + passwordErrorTooLong: z.boolean().optional(), + passwordErrorNoLetterChar: z.boolean().optional(), + passwordErrorNoNumOrSpecialChar: z.boolean().optional(), + passwordErrorRepeatedChar: z.boolean().optional(), + passwordErrorEscapeChar: z.boolean().optional(), + passwordErrorLowEntropy: z.boolean().optional(), + passwordErrorBreached: z.boolean() +}); +type TFormData = z.infer; + +type Props = { + verificationToken: string; + privateKey: string; + encryptionVersion: UserEncryptionVersion; + onComplete: () => void; +}; + +export const EnterPasswordStep = ({ + verificationToken, + encryptionVersion, + privateKey, + onComplete +}: Props) => { + const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id }); + const { to: email } = search; + + const { + control, + watch, + handleSubmit, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formData) + }); + const { mutateAsync: resetPassword, isPending: isLoading } = useResetPassword(); + const { mutateAsync: resetPasswordV2, isPending: isLoadingV2 } = useResetPasswordV2(); + + const passwordErrorTooShort = watch("passwordErrorTooShort"); + const passwordErrorTooLong = watch("passwordErrorTooLong"); + const passwordErrorNoLetterChar = watch("passwordErrorNoLetterChar"); + const passwordErrorNoNumOrSpecialChar = watch("passwordErrorNoNumOrSpecialChar"); + const passwordErrorRepeatedChar = watch("passwordErrorRepeatedChar"); + const passwordErrorEscapeChar = watch("passwordErrorEscapeChar"); + const passwordErrorLowEntropy = watch("passwordErrorLowEntropy"); + const passwordErrorBreached = watch("passwordErrorBreached"); + + const isPasswordError = + passwordErrorTooShort || + passwordErrorTooLong || + passwordErrorNoLetterChar || + passwordErrorNoNumOrSpecialChar || + passwordErrorRepeatedChar || + passwordErrorEscapeChar || + passwordErrorLowEntropy || + passwordErrorBreached; + + const handlePasswordCheck = async (checkPassword: string) => { + const errorCheck = await passwordCheck({ + password: checkPassword, + setPasswordErrorTooShort: (v) => setValue("passwordErrorTooShort", v), + setPasswordErrorTooLong: (v) => setValue("passwordErrorTooLong", v), + setPasswordErrorNoLetterChar: (v) => setValue("passwordErrorNoLetterChar", v), + setPasswordErrorNoNumOrSpecialChar: (v) => setValue("passwordErrorNoNumOrSpecialChar", v), + setPasswordErrorRepeatedChar: (v) => setValue("passwordErrorRepeatedChar", v), + setPasswordErrorEscapeChar: (v) => setValue("passwordErrorEscapeChar", v), + setPasswordErrorLowEntropy: (v) => setValue("passwordErrorLowEntropy", v), + setPasswordErrorBreached: (v) => setValue("passwordErrorBreached", v) + }); + + return errorCheck; + }; + + const resetPasswordHandler = async (data: TFormData) => { + const errorCheck = await handlePasswordCheck(data.password); + + if (errorCheck) return; + + if (encryptionVersion === UserEncryptionVersion.V2) { + await resetPasswordV2({ + newPassword: data.password, + verificationToken + }); + } else { + // eslint-disable-next-line new-cap + const client = new jsrp.client(); + client.init( + { + username: email, + password: data.password + }, + async () => { + client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => { + const derivedKey = await deriveArgonKey({ + password: data.password, + salt: result.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) + }); + + await resetPassword({ + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt: result.salt, + verifier: result.verifier, + verificationToken, + password: data.password + }); + }); + } + ); + } + onComplete(); + }; + + return ( +
+

+ Enter new password +

+
+

+ Make sure you save it somewhere safe. +

+
+
+ ( + + { + field.onChange(e); + handlePasswordCheck(e.target.value); + }} + type="password" + /> + + )} + /> +
+ {passwordErrorTooShort || + passwordErrorTooLong || + passwordErrorNoLetterChar || + passwordErrorNoNumOrSpecialChar || + passwordErrorRepeatedChar || + passwordErrorEscapeChar || + passwordErrorLowEntropy || + passwordErrorBreached ? ( +
+
Password should contain:
+
+ {passwordErrorTooShort ? ( + + ) : ( + + )} +
+ at least 14 characters +
+
+
+ {passwordErrorTooLong ? ( + + ) : ( + + )} +
+ at most 100 characters +
+
+
+ {passwordErrorNoLetterChar ? ( + + ) : ( + + )} +
+ at least 1 letter character +
+
+
+ {passwordErrorNoNumOrSpecialChar ? ( + + ) : ( + + )} +
+ at least 1 number or special character +
+
+
+ {passwordErrorRepeatedChar ? ( + + ) : ( + + )} +
+ at most 3 repeated, consecutive characters +
+
+
+ {passwordErrorEscapeChar ? ( + + ) : ( + + )} +
+ No escape characters allowed. +
+
+
+ {passwordErrorLowEntropy ? ( + + ) : ( + + )} +
+ Password contains personal info. +
+
+
+ {passwordErrorBreached ? ( + + ) : ( + + )} +
+ Password was found in a data breach. +
+
+
+ ) : ( +
+ )} +
+
+ +
+
+ + ); +}; diff --git a/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx b/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx new file mode 100644 index 000000000..54a5d5e9d --- /dev/null +++ b/frontend/src/pages/auth/PasswordResetPage/components/InputBackupKeyStep.tsx @@ -0,0 +1,87 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries"; + +type Props = { + verificationToken: string; + onComplete: (privateKey: string) => void; +}; + +const formData = z.object({ + backupKey: z.string() +}); +type TFormData = z.infer; + +export const InputBackupKeyStep = ({ verificationToken, onComplete }: Props) => { + const { control, handleSubmit, setError } = useForm({ + resolver: zodResolver(formData) + }); + + const getEncryptedKeyHandler = async (data: z.infer) => { + try { + const result = await getBackupEncryptedPrivateKey({ verificationToken }); + + const privateKey = Aes256Gcm.decrypt({ + ciphertext: result.encryptedPrivateKey, + iv: result.iv, + tag: result.tag, + secret: data.backupKey + }); + + onComplete(privateKey); + // setStep(3); + } catch (err) { + console.error(err); + setError("backupKey", { message: "Failed to decrypt private key" }); + } + }; + + return ( +
+

+ Enter your backup key +

+
+

+ You can find it in your emergency kit. You had to download the emergency kit during + signup. +

+
+
+ ( + + + + )} + /> +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index d3106e341..7cf4ad572 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -4,7 +4,7 @@ import crypto from "crypto"; import { useState } from "react"; import { Helmet } from "react-helmet"; -import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import jsrp from "jsrp"; @@ -16,7 +16,6 @@ import InputField from "@app/components/basic/InputField"; import checkPassword from "@app/components/utilities/checks/password/checkPassword"; import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; -import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey"; import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button } from "@app/components/v2"; @@ -54,8 +53,6 @@ export const SignupInvitePage = () => { const [lastNameError, setLastNameError] = useState(false); const [isLoading, setIsLoading] = useState(false); const [step, setStep] = useState(1); - const [, setBackupKeyError] = useState(false); - const [, setBackupKeyIssued] = useState(false); const [errors, setErrors] = useState({}); const [shouldShowMfa, toggleShowMfa] = useToggle(false); @@ -205,7 +202,9 @@ export const SignupInvitePage = () => { localStorage.setItem("orgData.id", orgId); - setStep(3); + navigate({ + to: `/organization/${ProjectType.SecretManager}/overview` as const + }); }; await completeSignupFlow(); @@ -367,44 +366,6 @@ export const SignupInvitePage = () => {
); - // Step 4 of the sign up process (download the emergency kit pdf) - const step4 = ( -
-

- Save your Emergency Kit -

-
-
- If you get locked out of your account, your Emergency Kit is the only way to sign in. -
-
We recommend you download it and keep it somewhere safe.
-
-
- - It contains your Secret Key which we cannot access or recover for you if you lose it. -
-
- -
-
- ); - return (
@@ -425,7 +386,8 @@ export const SignupInvitePage = () => { Infisical Logo
- {step === 1 ? stepConfirmEmail : step === 2 ? main : step4} + {step === 1 && stepConfirmEmail} + {step === 2 && main} )}
diff --git a/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx b/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx index c1fcc30c4..75af4ff48 100644 --- a/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/auth/SignUpPage/SignUpPage.tsx @@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "@tanstack/react-router"; import CodeInputStep from "@app/components/auth/CodeInputStep"; -import DownloadBackupPDF from "@app/components/auth/DonwloadBackupPDFStep"; import EnterEmailStep from "@app/components/auth/EnterEmailStep"; import InitialSignupStep from "@app/components/auth/InitialSignupStep"; import TeamInviteStep from "@app/components/auth/TeamInviteStep"; @@ -72,7 +71,7 @@ export const SignUpPage = () => { incrementStep(); } - if (!serverDetails?.emailConfigured && step === 5) { + if (!serverDetails?.emailConfigured && step === 4) { navigate({ to: `/organization/${ProjectType.SecretManager}/overview` as const }); @@ -119,17 +118,6 @@ export const SignUpPage = () => { ); } - if (registerStep === 4) { - return ( - - ); - } - if (serverDetails?.emailConfigured) { return ; } diff --git a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx index a13cfcfc6..40075ae54 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx @@ -6,7 +6,6 @@ import { jwtDecode } from "jwt-decode"; import { ROUTE_PATHS } from "@app/const/routes"; -import { BackupPDFStep } from "./components/BackupPDFStep"; import { EmailConfirmationStep } from "./components/EmailConfirmationStep"; import { UserInfoSSOStep } from "./components/UserInfoSSOStep"; @@ -57,14 +56,9 @@ export const SignupSsoPage = () => { providerOrganizationName={organizationName} password={password} setPassword={setPassword} - setStep={setStep} providerAuthToken={token} /> ); - case 2: - return ( - - ); default: return
; } diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx deleted file mode 100644 index 9a2369170..000000000 --- a/frontend/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/BackupPDFStep.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate } from "@tanstack/react-router"; - -import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey"; -import { Button } from "@app/components/v2"; -import { ProjectType } from "@app/hooks/api/workspace/types"; - -interface DownloadBackupPDFStepProps { - email: string; - password: string; - name: string; -} - -/** - * This is the step of the signup flow where the user downloads the backup pdf - * @param {object} obj - * @param {function} obj.incrementStep - function that moves the user on to the next stage of signup - * @param {string} obj.email - user's email - * @param {string} obj.password - user's password - * @param {string} obj.name - user's name - * @returns - */ -export const BackupPDFStep = ({ email, password, name }: DownloadBackupPDFStepProps) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - - return ( -
-

- - {t("signup.step4-message")} -

-
-
- - {t("signup.step4-description1")} {t("signup.step4-description3")} - -
-
-
- -
-
-
-
- ); -}; diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx deleted file mode 100644 index 01f7745cd..000000000 --- a/frontend/src/pages/auth/SignUpSsoPage/components/BackupPDFStep/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { BackupPDFStep } from "./BackupPDFStep"; diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx index c3d131708..a6ab0623b 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -2,6 +2,7 @@ import crypto from "crypto"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "@tanstack/react-router"; import jsrp from "jsrp"; import nacl from "tweetnacl"; import { encodeBase64 } from "tweetnacl-util"; @@ -17,12 +18,12 @@ import { useToggle } from "@app/hooks"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; +import { ProjectType } from "@app/hooks/api/workspace/types"; // eslint-disable-next-line new-cap const client = new jsrp.client(); type Props = { - setStep: (step: number) => void; username: string; password: string; setPassword: (value: string) => void; @@ -50,7 +51,6 @@ export const UserInfoSSOStep = ({ providerOrganizationName, password, setPassword, - setStep, providerAuthToken }: Props) => { const [nameError, setNameError] = useState(false); @@ -63,6 +63,7 @@ export const UserInfoSSOStep = ({ const { t } = useTranslation(); const { mutateAsync: selectOrganization } = useSelectOrganization(); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); + const navigate = useNavigate(); useEffect(() => { const randomPassword = crypto.randomBytes(32).toString("hex"); @@ -202,7 +203,9 @@ export const UserInfoSSOStep = ({ } localStorage.setItem("orgData.id", orgId); - setStep(2); + navigate({ + to: `/organization/${ProjectType.SecretManager}/overview` as const + }); } catch (error) { setIsLoading(false); console.error(error); diff --git a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx index 418529bdc..bc0bdf1ff 100644 --- a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx +++ b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx @@ -72,8 +72,9 @@ export const VerifyEmailPage = () => { Forgot your password?

-

- You will need your emergency kit. Enter your email to start account recovery. +

+ Enter your email to start the password reset process. You will receive an email with + instructions.

diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx index 9c34693df..8a2fd7010 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx @@ -11,7 +11,8 @@ import attemptChangePassword from "@app/components/utilities/attemptChangePasswo import checkPassword from "@app/components/utilities/checks/password/checkPassword"; import { Button, FormControl, Input } from "@app/components/v2"; import { useUser } from "@app/context"; -import { useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries"; +import { useResetUserPasswordV2, useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries"; +import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; type Errors = { tooShort?: string; @@ -47,6 +48,7 @@ export const ChangePasswordSection = () => { const [errors, setErrors] = useState({}); const [isLoading, setIsLoading] = useState(false); const sendSetupPasswordEmail = useSendPasswordSetupEmail(); + const { mutateAsync: resetPasswordV2 } = useResetUserPasswordV2(); const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => { try { @@ -56,13 +58,20 @@ export const ChangePasswordSection = () => { }); if (errorCheck) return; - setIsLoading(true); - await attemptChangePassword({ - email: user.username, - currentPassword: oldPassword, - newPassword - }); + + if (user.encryptionVersion === UserEncryptionVersion.V2) { + await resetPasswordV2({ + oldPassword, + newPassword + }); + } else { + await attemptChangePassword({ + email: user.username, + currentPassword: oldPassword, + newPassword + }); + } setIsLoading(false); createNotification({ diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx index ac4c5bea2..dcf35e672 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/PersonalGeneralTab/PersonalGeneralTab.tsx @@ -1,14 +1,20 @@ +import { useUser } from "@app/context"; +import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; + import { DeleteAccountSection } from "../DeleteAccountSection"; import { EmergencyKitSection } from "../EmergencyKitSection"; import { SessionsSection } from "../SessionsSection"; import { UserNameSection } from "../UserNameSection"; export const PersonalGeneralTab = () => { + const { user } = useUser(); + const encryptionVersion = user?.encryptionVersion ?? UserEncryptionVersion.V2; + return (
- + {encryptionVersion === UserEncryptionVersion.V1 && }
); From 7003ad608a1fa1bf54d65aefc744e104d5a136c5 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Fri, 7 Mar 2025 04:37:08 +0400 Subject: [PATCH 022/111] Update user-service.ts --- backend/src/services/user/user-service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index d272845c4..5da5d493c 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -3,9 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { SecretKeyEncoding } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; - import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; - import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; @@ -33,7 +31,6 @@ type TUserServiceFactoryDep = { | "createUserAction" | "findUserEncKeyByUserId" | "delete" - | "updateUserEncryptionByUserId" >; userAliasDAL: Pick; groupProjectDAL: Pick; From c48c9ae628ab64c407cb29bb8716b8a664f0c2e6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Fri, 7 Mar 2025 04:55:18 +0400 Subject: [PATCH 023/111] cleanup --- .../services/auth/auth-password-service.ts | 38 +++++++++---------- .../ChangePasswordSection.tsx | 4 +- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 349bcb3d0..4d01870f6 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -243,31 +243,27 @@ export const authPaswordServiceFactory = ({ const { tag, iv, ciphertext, encoding } = infisicalSymmetricEncypt(privateKey); - await userDAL.transaction(async (tx) => { - await userDAL.updateUserEncryptionByUserId( - userId, - { - hashedPassword: newHashedPassword, + await userDAL.updateUserEncryptionByUserId(userId, { + hashedPassword: newHashedPassword, - // srp params - salt: encKeys.salt, - verifier: encKeys.verifier, + // srp params + salt: encKeys.salt, + verifier: encKeys.verifier, - protectedKey: encKeys.protectedKey, - protectedKeyIV: encKeys.protectedKeyIV, - protectedKeyTag: encKeys.protectedKeyTag, - encryptedPrivateKey: encKeys.encryptedPrivateKey, - iv: encKeys.encryptedPrivateKeyIV, - tag: encKeys.encryptedPrivateKeyTag, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, - serverEncryptedPrivateKey: ciphertext, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyEncoding: encoding - }, - tx - ); + serverEncryptedPrivateKey: ciphertext, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyEncoding: encoding }); + + await tokenService.revokeAllMySessions(userId); }; /* diff --git a/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx b/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx index 8a2fd7010..d6842c755 100644 --- a/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx +++ b/frontend/src/pages/user/PersonalSettingsPage/components/ChangePasswordSection/ChangePasswordSection.tsx @@ -13,6 +13,7 @@ import { Button, FormControl, Input } from "@app/components/v2"; import { useUser } from "@app/context"; import { useResetUserPasswordV2, useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries"; import { UserEncryptionVersion } from "@app/hooks/api/auth/types"; +import { useNavigate } from "@tanstack/react-router"; type Errors = { tooShort?: string; @@ -36,6 +37,7 @@ export type FormData = z.infer; export const ChangePasswordSection = () => { const { t } = useTranslation(); + const navigate = useNavigate(); const { user } = useUser(); const { reset, control, handleSubmit } = useForm({ @@ -80,7 +82,7 @@ export const ChangePasswordSection = () => { }); reset(); - window.location.href = "/login"; + navigate({ to: "/login" }); } catch (err) { console.error(err); setIsLoading(false); From 6cd448b8a5d7a1d5558ff0cc88bad3d80496b65b Mon Sep 17 00:00:00 2001 From: = Date: Fri, 7 Mar 2025 15:01:14 +0530 Subject: [PATCH 024/111] feat: webhook on secret reminder trigger --- .../secret-approval-request-service.ts | 3 +- backend/src/queue/queue-service.ts | 3 +- backend/src/services/secret/secret-queue.ts | 34 +++++- backend/src/services/webhook/webhook-fns.ts | 113 +++++++++++++----- .../src/services/webhook/webhook-service.ts | 18 +-- backend/src/services/webhook/webhook-types.ts | 33 +++++ 6 files changed, 158 insertions(+), 46 deletions(-) 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 8569ef2a9..491579fc2 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 @@ -503,7 +503,7 @@ export const secretApprovalRequestServiceFactory = ({ if (!hasMinApproval && !isSoftEnforcement) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); - const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); let mergeStatus; if (shouldUseSecretV2Bridge) { // this cycle if for bridged secrets @@ -861,7 +861,6 @@ export const secretApprovalRequestServiceFactory = ({ if (isSoftEnforcement) { const cfg = getConfig(); - const project = await projectDAL.findProjectById(projectId); const env = await projectEnvDAL.findOne({ id: policy.envId }); const requestedByUser = await userDAL.findOne({ id: actorId }); const approverUsers = await userDAL.find({ diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f9aec5881..5d6b8b60b 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -21,6 +21,7 @@ import { TQueueSecretSyncSyncSecretsByIdDTO, TQueueSendSecretSyncActionFailedNotificationsDTO } from "@app/services/secret-sync/secret-sync-types"; +import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; export enum QueueName { SecretRotation = "secret-rotation", @@ -107,7 +108,7 @@ export type TQueueJobTypes = { }; [QueueName.SecretWebhook]: { name: QueueJobs.SecWebhook; - payload: { projectId: string; environment: string; secretPath: string; depth?: number }; + payload: TWebhookPayloads; }; [QueueName.AccessTokenStatusUpdate]: diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 00b0e7da8..c84fe5ae0 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -61,6 +61,7 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; +import { WebhookEvents } from "../webhook/webhook-types"; import { TSecretDALFactory } from "./secret-dal"; import { interpolateSecrets } from "./secret-fns"; import { @@ -623,7 +624,14 @@ export const secretQueueFactory = ({ await queueService.queue( QueueName.SecretWebhook, QueueJobs.SecWebhook, - { environment, projectId, secretPath }, + { + type: WebhookEvents.SecretModified, + payload: { + environment, + projectId, + secretPath + } + }, { jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`, removeOnFail: { count: 5 }, @@ -1055,6 +1063,8 @@ export const secretQueueFactory = ({ const organization = await orgDAL.findOrgByProjectId(projectId); const project = await projectDAL.findById(projectId); + const secret = await secretV2BridgeDAL.findById(data.secretId); + const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); if (!organization) { logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); @@ -1083,6 +1093,19 @@ export const secretQueueFactory = ({ organizationName: organization.name } }); + + await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, { + type: WebhookEvents.SecretReminderExpired, + payload: { + projectName: project.name, + projectId: project.id, + secretPath: folder?.path, + environment: folder?.environmentSlug || "", + reminderNote: data.note, + secretName: secret?.key, + secretId: data.secretId + } + }); }); const startSecretV2Migration = async (projectId: string) => { @@ -1490,14 +1513,17 @@ export const secretQueueFactory = ({ queueService.start(QueueName.SecretWebhook, async (job) => { const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, - projectId: job.data.projectId + projectId: job.data.payload.projectId }); await fnTriggerWebhook({ - ...job.data, + projectId: job.data.payload.projectId, + environment: job.data.payload.environment, + secretPath: job.data.payload.secretPath || "/", projectEnvDAL, - webhookDAL, projectDAL, + webhookDAL, + event: job.data, secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString() }); }); diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index e46f9db2a..a16158e14 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -11,7 +11,7 @@ import { logger } from "@app/lib/logger"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TWebhookDALFactory } from "./webhook-dal"; -import { WebhookType } from "./webhook-types"; +import { TWebhookPayloads, WebhookEvents, WebhookType } from "./webhook-types"; const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000; @@ -54,29 +54,64 @@ export const triggerWebhookRequest = async ( return req; }; -export const getWebhookPayload = ( - eventName: string, - details: { - workspaceName: string; - workspaceId: string; - environment: string; - secretPath?: string; - type?: string | null; +export const getWebhookPayload = (event: TWebhookPayloads) => { + if (event.type === WebhookEvents.SecretModified) { + const { projectName, projectId, environment, secretPath, type } = event.payload; + + switch (type) { + case WebhookType.SLACK: + return { + text: "A secret value has been added or modified.", + attachments: [ + { + color: "#E7F256", + fields: [ + { + title: "Project", + value: projectName, + short: false + }, + { + title: "Environment", + value: environment, + short: false + }, + { + title: "Secret Path", + value: secretPath, + short: false + } + ] + } + ] + }; + case WebhookType.GENERAL: + default: + return { + event: event.type, + project: { + workspaceId: projectId, + projectName, + environment, + secretPath + } + }; + } } -) => { - const { workspaceName, workspaceId, environment, secretPath, type } = details; + + const { projectName, projectId, environment, secretPath, type, reminderNote, secretName } = event.payload; switch (type) { case WebhookType.SLACK: return { - text: "A secret value has been added or modified.", + text: "You have a secret reminder", attachments: [ { color: "#E7F256", fields: [ { title: "Project", - value: workspaceName, + value: projectName, short: false }, { @@ -88,6 +123,16 @@ export const getWebhookPayload = ( title: "Secret Path", value: secretPath, short: false + }, + { + title: "Secret Name", + value: secretName, + short: false + }, + { + title: "Reminder Note", + value: reminderNote, + short: false } ] } @@ -96,11 +141,14 @@ export const getWebhookPayload = ( case WebhookType.GENERAL: default: return { - event: eventName, + event: event.type, project: { - workspaceId, + workspaceId: projectId, + projectName, environment, - secretPath + secretPath, + secretName, + reminderNote } }; } @@ -110,6 +158,7 @@ export type TFnTriggerWebhookDTO = { projectId: string; secretPath: string; environment: string; + event: TWebhookPayloads; webhookDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick; @@ -124,8 +173,9 @@ export const fnTriggerWebhook = async ({ projectId, webhookDAL, projectEnvDAL, - projectDAL, - secretManagerDecryptor + event, + secretManagerDecryptor, + projectDAL }: TFnTriggerWebhookDTO) => { const webhooks = await webhookDAL.findAllWebhooks(projectId, environment); const toBeTriggeredHooks = webhooks.filter( @@ -134,21 +184,20 @@ export const fnTriggerWebhook = async ({ ); if (!toBeTriggeredHooks.length) return; logger.info({ environment, secretPath, projectId }, "Secret webhook job started"); - const project = await projectDAL.findById(projectId); + let { projectName } = event.payload; + if (!projectName) { + const project = await projectDAL.findById(event.payload.projectId); + projectName = project.name; + } + const webhooksTriggered = await Promise.allSettled( - toBeTriggeredHooks.map((hook) => - triggerWebhookRequest( - hook, - secretManagerDecryptor, - getWebhookPayload("secrets.modified", { - workspaceName: project.name, - workspaceId: projectId, - environment, - secretPath, - type: hook.type - }) - ) - ) + toBeTriggeredHooks.map((hook) => { + const formattedEvent = { + type: event.type, + payload: { ...event.payload, type: hook.type, projectName } + } as TWebhookPayloads; + return triggerWebhookRequest(hook, secretManagerDecryptor, getWebhookPayload(formattedEvent)); + }) ); // filter hooks by status diff --git a/backend/src/services/webhook/webhook-service.ts b/backend/src/services/webhook/webhook-service.ts index bb078e0f1..c555dc8d1 100644 --- a/backend/src/services/webhook/webhook-service.ts +++ b/backend/src/services/webhook/webhook-service.ts @@ -16,7 +16,8 @@ import { TDeleteWebhookDTO, TListWebhookDTO, TTestWebhookDTO, - TUpdateWebhookDTO + TUpdateWebhookDTO, + WebhookEvents } from "./webhook-types"; type TWebhookServiceFactoryDep = { @@ -144,12 +145,15 @@ export const webhookServiceFactory = ({ await triggerWebhookRequest( webhook, (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(), - getWebhookPayload("test", { - workspaceName: project.name, - workspaceId: webhook.projectId, - environment: webhook.environment.slug, - secretPath: webhook.secretPath, - type: webhook.type + getWebhookPayload({ + type: "test" as WebhookEvents.SecretModified, + payload: { + projectName: project.name, + projectId: webhook.projectId, + environment: webhook.environment.slug, + secretPath: webhook.secretPath, + type: webhook.type + } }) ); } catch (err) { diff --git a/backend/src/services/webhook/webhook-types.ts b/backend/src/services/webhook/webhook-types.ts index 40dacb42a..8ce2c8d8e 100644 --- a/backend/src/services/webhook/webhook-types.ts +++ b/backend/src/services/webhook/webhook-types.ts @@ -30,3 +30,36 @@ export enum WebhookType { GENERAL = "general", SLACK = "slack" } + +export enum WebhookEvents { + SecretModified = "secrets.modified", + SecretReminderExpired = "secrets.reminder-expired", + TestEvent = "test" +} + +type TWebhookSecretModifiedEventPayload = { + type: WebhookEvents.SecretModified; + payload: { + projectName?: string; + projectId: string; + environment: string; + secretPath?: string; + type?: string | null; + }; +}; + +type TWebhookSecretReminderEventPayload = { + type: WebhookEvents.SecretReminderExpired; + payload: { + projectName?: string; + projectId: string; + environment: string; + secretPath?: string; + type?: string | null; + secretName: string; + secretId: string; + reminderNote?: string | null; + }; +}; + +export type TWebhookPayloads = TWebhookSecretModifiedEventPayload | TWebhookSecretReminderEventPayload; From a55b26164a6d9179ed747aac9937fe01d7d8e527 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 7 Mar 2025 15:14:09 +0530 Subject: [PATCH 025/111] feat: updated doc --- docs/documentation/platform/webhooks.mdx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/documentation/platform/webhooks.mdx b/docs/documentation/platform/webhooks.mdx index dc3a71b27..92d3ff8b8 100644 --- a/docs/documentation/platform/webhooks.mdx +++ b/docs/documentation/platform/webhooks.mdx @@ -36,3 +36,18 @@ If the signature in the header matches the signature that you generated, then yo "timestamp": "" } ``` + +```json +{ + "event": "secrets.reminder-expired", + "project": { + "workspaceId": "the workspace id", + "environment": "project environment", + "secretPath": "project folder path", + "secretName": "name of the secret", + "secretId": "id of the secret", + "reminderNote": "reminder note of the secret" + }, + "timestamp": "" +} +``` From 65ddddb6de88bc2b61976a17af7dfb08f485655f Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 7 Mar 2025 08:03:02 -0300 Subject: [PATCH 026/111] Change slack notification label from key to secret key --- backend/src/services/slack/slack-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 62432b749..f92f96a24 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -50,7 +50,7 @@ const buildSlackPayload = (notification: TSlackNotification) => { const messageBody = `A secret approval request has been opened by ${payload.userEmail}. *Environment*: ${payload.environment} *Secret path*: ${payload.secretPath || "/"} -*Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")} +*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")} View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${ payload.requestId From 214f837041057d33eef5f31a63b38e8ed3432d26 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 7 Mar 2025 11:42:15 -0300 Subject: [PATCH 027/111] Add is-admin filter to Server Admin Console and add a component to show the server admins on side panel --- backend/src/server/routes/v1/admin-router.ts | 7 +- .../super-admin/super-admin-service.ts | 5 +- .../services/super-admin/super-admin-types.ts | 1 + backend/src/services/user/user-dal.ts | 9 +- frontend/src/hooks/api/admin/types.ts | 1 + .../MinimizedOrgSidebar.tsx | 27 ++++++ .../ServerAdminsPanel/ServerAdminsPanel.tsx | 85 +++++++++++++++++++ .../OverviewPage/components/UserPanel.tsx | 79 ++++++++++++++--- 8 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 076b33e54..b63550d2c 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -118,7 +118,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { querystring: z.object({ searchTerm: z.string().default(""), offset: z.coerce.number().default(0), - limit: z.coerce.number().max(100).default(20) + limit: z.coerce.number().max(100).default(20), + // TODO: remove this once z.coerce.boolean() is supported + adminsOnly: z + .string() + .transform((val) => val === "true") + .default("false") }), response: { 200: z.object({ diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 166f73317..8efdfd7f9 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -271,12 +271,13 @@ export const superAdminServiceFactory = ({ return { token, user: userInfo, organization }; }; - const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => { + const getUsers = ({ offset, limit, searchTerm, adminsOnly }: TAdminGetUsersDTO) => { return userDAL.getUsersByFilter({ limit, offset, searchTerm, - sortBy: "username" + sortBy: "username", + adminsOnly }); }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 2d10941b4..d6de67e59 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -20,6 +20,7 @@ export type TAdminGetUsersDTO = { offset: number; limit: number; searchTerm: string; + adminsOnly: boolean; }; export enum LoginMethod { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 99f403e84..eba497f0f 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -23,15 +23,18 @@ export const userDALFactory = (db: TDbClient) => { limit, offset, searchTerm, - sortBy + sortBy, + adminsOnly }: { limit: number; offset: number; searchTerm: string; sortBy?: keyof TUsers; + adminsOnly: boolean; }) => { try { let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); + if (searchTerm) { query = query.where((qb) => { void qb @@ -42,6 +45,10 @@ export const userDALFactory = (db: TDbClient) => { }); } + if (adminsOnly) { + query = query.where("superAdmin", true); + } + if (sortBy) { query = query.orderBy(sortBy); } diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index e5d281fc4..80e3edc92 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -50,6 +50,7 @@ export type TUpdateAdminSlackConfigDTO = { export type AdminGetUsersFilters = { limit: number; searchTerm: string; + adminsOnly: boolean; }; export type AdminSlackConfig = { diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index 4a255acad..c1c4297ca 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -50,6 +50,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { MenuIconButton } from "../MenuIconButton"; +import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.dynamicSecret) return "Enterprise Plan"; @@ -89,6 +90,7 @@ export const MinimizedOrgSidebar = () => { const [openSupport, setOpenSupport] = useState(false); const [openUser, setOpenUser] = useState(false); const [openOrg, setOpenOrg] = useState(false); + const [openAdmins, setOpenAdmins] = useState(false); const { user } = useUser(); const { mutateAsync } = useGetOrgTrialUrl(); @@ -392,6 +394,31 @@ export const MinimizedOrgSidebar = () => { : "mb-4" } flex w-full cursor-default flex-col items-center px-1 text-sm text-mineshaft-400`} > + + setOpenAdmins(true)} + onMouseLeave={() => setOpenAdmins(false)} + asChild + > +
+ + Admins + +
+
+ setOpenAdmins(true)} + onMouseLeave={() => setOpenAdmins(false)} + align="start" + side="right" + className="mb-2 w-[60vh] p-1" + > + Server Administrators +
+ +
+
+
setOpenSupport(true)} diff --git a/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx b/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx new file mode 100644 index 000000000..69eecec40 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useGetOrgUsers } from "@app/hooks/api"; + +export const ServerAdminsPanel = () => { + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); + const { currentOrg } = useOrganization(); + + const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || ""); + + const adminUsers = orgUsers?.filter((orgUser) => { + const isSuperAdmin = orgUser.user.superAdmin; + const matchesSearch = debounedSearchTerm + ? orgUser.user.email?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || + orgUser.user.firstName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || + orgUser.user.lastName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) + : true; + return isSuperAdmin && matchesSearch; + }); + + const isEmpty = !isPending && (!adminUsers || adminUsers.length === 0); + + return ( +
+
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search server admins..." + className="w-full" + /> +
+
+ + + + + + + + + + {isPending && } + {!isPending && + adminUsers?.map(({ user }) => { + const name = + user.firstName || user.lastName + ? `${user.firstName} ${user.lastName}` + : user.username; + return ( + + + + + ); + })} + +
NameEmail
{name}{user.email}
+ {isEmpty && ( +
+ No server administrators found +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx index df51216a6..9f6524cf7 100644 --- a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx @@ -1,6 +1,13 @@ import { useState } from "react"; -import { faMagnifyingGlass, faUsers, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { + faCheckCircle, + faEllipsis, + faFilter, + faMagnifyingGlass, + faUsers +} 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"; @@ -8,8 +15,15 @@ import { Badge, Button, DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, EmptyState, + IconButton, Input, + Switch, Table, TableContainer, TableSkeleton, @@ -17,11 +31,7 @@ import { Td, Th, THead, - Tr, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger + Tr } from "@app/components/v2"; import { useSubscription, useUser } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; @@ -48,6 +58,7 @@ const UserPanelTable = ({ ) => void; }) => { const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); const { user } = useUser(); const userId = user?.id || ""; const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); @@ -55,18 +66,60 @@ const UserPanelTable = ({ const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ limit: 20, - searchTerm: debounedSearchTerm + searchTerm: debounedSearchTerm, + adminsOnly }); const isEmpty = !isPending && !data?.pages?.[0].length; + const isTableFiltered = Boolean(adminsOnly); + return ( <> - setSearchUserFilter(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> +
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search users..." + className="flex-1" + /> + + + + + + + + Filter Users + { + e.preventDefault(); + setAdminsOnly(!adminsOnly); + }} + icon={adminsOnly && } + iconPos="right" + > +
+ Admins Only + setAdminsOnly(checked)} + className="data-[state=checked]:bg-primary-400" + /> +
+
+
+
+
From 9711e73a062e09b8a9e89c25da5e26109e2731ca Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 7 Mar 2025 23:05:47 +0800 Subject: [PATCH 028/111] fix: address unhandled promise rejects causing 502s --- .../integration-auth-service.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 1d9fedde7..77f7d96b5 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -114,20 +114,27 @@ export const integrationAuthServiceFactory = ({ const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); - return Promise.all( - authorizations.filter(async (auth) => { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: auth.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.SecretManager - }); + const filteredAuthorizations = await Promise.all( + authorizations.map(async (auth) => { + try { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: auth.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); - return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations) ? auth : null; + } catch (error) { + // user does not belong to the project that the integration auth belongs to + return null; + } }) ); + + return filteredAuthorizations.filter((auth) => auth !== null); }; const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { From 57f54440d6d67ccb6b3339a01c9424d34e8214af Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 7 Mar 2025 23:15:05 +0800 Subject: [PATCH 029/111] misc: added support for type --- .../src/services/integration-auth/integration-auth-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 77f7d96b5..eb17c05bb 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -134,7 +134,7 @@ export const integrationAuthServiceFactory = ({ }) ); - return filteredAuthorizations.filter((auth) => auth !== null); + return filteredAuthorizations.filter((auth): auth is NonNullable => auth !== null); }; const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { From 50610945bec32b1c29bb48853d85a1e92e81e405 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 5 Mar 2025 05:40:57 +0400 Subject: [PATCH 030/111] feat: get secret by ID --- backend/src/server/routes/index.ts | 1 + backend/src/server/routes/v3/secret-router.ts | 42 ++++++++++ .../secret-v2-bridge/secret-v2-bridge-dal.ts | 8 +- .../secret-v2-bridge-service.ts | 76 ++++++++++++++++++- backend/src/services/secret/secret-service.ts | 42 +++++++++- backend/src/services/secret/secret-types.ts | 17 ++++- 6 files changed, 178 insertions(+), 8 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index efc1cb865..118b2d753 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1073,6 +1073,7 @@ export const registerRoutes = async ( const secretService = secretServiceFactory({ folderDAL, secretVersionDAL, + secretV2BridgeDAL, secretVersionTagDAL, secretBlindIndexDAL, permissionService, diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index a5dc39485..4935345dc 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -380,6 +380,48 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/raw/id/:secretId", + config: { + rateLimit: secretsLimit + }, + schema: { + params: z.object({ + secretId: z.string() + }), + response: { + 200: z.object({ + secret: secretRawSchema.extend({ + secretPath: z.string(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional(), + secretMetadata: ResourceMetadataSchema.optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { secretId } = req.params; + const secret = await server.services.secret.getSecretByIdRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretId + }); + + return { secret }; + } + }); + server.route({ method: "GET", url: "/raw/:secretName", 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 99980fba7..b4619abd3 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 @@ -613,6 +613,9 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + + .leftJoin(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`) + .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) @@ -622,12 +625,13 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { 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(db.ref("projectId").withSchema(TableName.Environment).as("projectId")); const docs = sqlNestRelationships({ data: rawDocs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ _id: el.id, projectId: el.projectId, ...SecretsV2Schema.parse(el) }), childrenMapper: [ { key: "tagId", 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 9063da5c4..ef1ff9788 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 @@ -28,6 +28,7 @@ import { KmsDataKey } from "../kms/kms-types"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TSecretQueueFactory } from "../secret/secret-queue"; +import { TGetASecretByIdDTO } from "../secret/secret-types"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; @@ -73,7 +74,13 @@ type TSecretV2BridgeServiceFactoryDep = { projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv" + | "findBySecretPath" + | "updateById" + | "findById" + | "findByManySecretPath" + | "find" + | "findBySecretPathMultiEnv" + | "findSecretPathByFolderIds" >; secretImportDAL: Pick; secretQueueService: Pick; @@ -955,6 +962,70 @@ export const secretV2BridgeServiceFactory = ({ }; }; + const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secret }: TGetASecretByIdDTO) => { + const folder = await folderDAL.findById(secret.folderId); + if (!folder) { + throw new NotFoundError({ + message: `Folder with id '${secret.folderId}' not found`, + name: "GetSecretById" + }); + } + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(folder.projectId, [folder.id]); + + if (!folderWithPath) { + throw new NotFoundError({ + message: `Folder with id '${folder.id}' not found`, + name: "GetSecretById" + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: folder.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: folder.environment.envSlug, + secretPath: folderWithPath.path, + secretName: secret.key, + secretTags: secret.tags.map((i) => i.slug) + }) + ); + + if (secret.type === SecretType.Personal && secret.userId !== actorId) { + throw new ForbiddenRequestError({ + message: "You are not allowed to access this secret", + name: "GetSecretById" + }); + } + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: folder.projectId + }); + + const secretValue = secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : ""; + + const secretComment = secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : ""; + + return reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, folderWithPath.path, { + ...secret, + value: secretValue, + comment: secretComment + }); + }; + const getSecretByName = async ({ actorId, actor, @@ -2237,6 +2308,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsCountMultiEnv, getSecretsMultiEnv, getSecretReferenceTree, - getSecretsByFolderMappings + getSecretsByFolderMappings, + getSecretById }; }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 93f68e813..96e0ab351 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -9,7 +9,8 @@ import { SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, - SecretType + SecretType, + TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -44,6 +45,7 @@ import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service"; import { TGetSecretReferencesTreeDTO } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretDALFactory } from "./secret-dal"; @@ -71,6 +73,7 @@ import { TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, + TGetASecretByIdRawDTO, TGetASecretDTO, TGetASecretRawDTO, TGetSecretAccessListDTO, @@ -89,13 +92,14 @@ import { TSecretVersionTagDALFactory } from "./secret-version-tag-dal"; type TSecretServiceFactoryDep = { secretDAL: TSecretDALFactory; + secretV2BridgeDAL: Pick; secretTagDAL: TSecretTagDALFactory; secretVersionDAL: TSecretVersionDALFactory; projectDAL: Pick; projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findSecretPathByFolderIds" >; secretV2BridgeService: TSecretV2BridgeServiceFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; @@ -124,6 +128,7 @@ type TSecretServiceFactoryDep = { export type TSecretServiceFactory = ReturnType; export const secretServiceFactory = ({ secretDAL, + secretV2BridgeDAL, projectEnvDAL, secretTagDAL, secretVersionDAL, @@ -1382,6 +1387,36 @@ export const secretServiceFactory = ({ }; }; + const getSecretByIdRaw = async ({ secretId, actorId, actor, actorOrgId, actorAuthMethod }: TGetASecretByIdRawDTO) => { + const sec = await secretV2BridgeDAL.findOneWithTags({ + [`${TableName.SecretV2}.id` as "id"]: secretId + }); + + if (!sec) { + throw new NotFoundError({ + message: `Secret with id '${secretId}' not found`, + name: "GetSecretById" + }); + } + + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(sec.projectId); + + if (shouldUseSecretV2Bridge) { + const secret = await secretV2BridgeService.getSecretById({ + secret: sec, + actorId, + actor, + actorOrgId, + actorAuthMethod + }); + + return secret; + } + throw new BadRequestError({ + message: "Project version not supported. Please upgrade your project." + }); + }; + const getSecretByNameRaw = async ({ type, path, @@ -3088,6 +3123,7 @@ export const secretServiceFactory = ({ getSecretsRawMultiEnv, getSecretReferenceTree, getSecretsRawByFolderMappings, - getSecretAccessList + getSecretAccessList, + getSecretByIdRaw }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 158605276..371752594 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { z } from "zod"; -import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; +import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate, TSecretsV2 } from "@app/db/schemas"; import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -121,6 +121,17 @@ export type TGetASecretDTO = { version?: number; } & TProjectPermission; +export type TGetASecretByIdDTO = { + secret: TSecretsV2 & { + tags: { + id: string; + color?: string | null; + slug: string; + name: string; + }[]; + }; +} & Omit; + export type TCreateBulkSecretDTO = { path: string; environment: string; @@ -213,6 +224,10 @@ export type TGetASecretRawDTO = { projectId?: string; } & Omit; +export type TGetASecretByIdRawDTO = { + secretId: string; +} & Omit; + export type TCreateSecretRawDTO = TProjectPermission & { secretName: string; secretPath: string; From 77431b47197e77e1bc372defdaa84ff9bf0359c5 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 6 Mar 2025 20:00:11 +0400 Subject: [PATCH 031/111] requested changes --- backend/src/server/routes/index.ts | 1 - .../secret-v2-bridge-service.ts | 23 ++++--- backend/src/services/secret/secret-service.ts | 38 +++--------- backend/src/services/secret/secret-types.ts | 11 +--- .../SecretListView/SecretDetailSidebar.tsx | 61 +++++++++++++------ 5 files changed, 64 insertions(+), 70 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 118b2d753..efc1cb865 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1073,7 +1073,6 @@ export const registerRoutes = async ( const secretService = secretServiceFactory({ folderDAL, secretVersionDAL, - secretV2BridgeDAL, secretVersionTagDAL, secretBlindIndexDAL, permissionService, 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 ef1ff9788..1891b3eac 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 @@ -962,20 +962,23 @@ export const secretV2BridgeServiceFactory = ({ }; }; - const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secret }: TGetASecretByIdDTO) => { - const folder = await folderDAL.findById(secret.folderId); - if (!folder) { + const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secretId }: TGetASecretByIdDTO) => { + const secret = await secretDAL.findOneWithTags({ + id: secretId + }); + + if (!secret) { throw new NotFoundError({ - message: `Folder with id '${secret.folderId}' not found`, + message: `Secret with ID '${secretId}' not found`, name: "GetSecretById" }); } - const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(folder.projectId, [folder.id]); + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secret.projectId, [secret.folderId]); if (!folderWithPath) { throw new NotFoundError({ - message: `Folder with id '${folder.id}' not found`, + message: `Folder with id '${secret.folderId}' not found`, name: "GetSecretById" }); } @@ -983,7 +986,7 @@ export const secretV2BridgeServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: folder.projectId, + projectId: secret.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.SecretManager @@ -992,7 +995,7 @@ export const secretV2BridgeServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { - environment: folder.environment.envSlug, + environment: folderWithPath.environmentSlug, secretPath: folderWithPath.path, secretName: secret.key, secretTags: secret.tags.map((i) => i.slug) @@ -1008,7 +1011,7 @@ export const secretV2BridgeServiceFactory = ({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, - projectId: folder.projectId + projectId: secret.projectId }); const secretValue = secret.encryptedValue @@ -1019,7 +1022,7 @@ export const secretV2BridgeServiceFactory = ({ ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() : ""; - return reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, folderWithPath.path, { + return reshapeBridgeSecret(secret.projectId, folderWithPath.environmentSlug, folderWithPath.path, { ...secret, value: secretValue, comment: secretComment diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 96e0ab351..cfb47d1dd 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -9,8 +9,7 @@ import { SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, - SecretType, - TableName + SecretType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -45,7 +44,6 @@ import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; -import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service"; import { TGetSecretReferencesTreeDTO } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretDALFactory } from "./secret-dal"; @@ -92,7 +90,6 @@ import { TSecretVersionTagDALFactory } from "./secret-version-tag-dal"; type TSecretServiceFactoryDep = { secretDAL: TSecretDALFactory; - secretV2BridgeDAL: Pick; secretTagDAL: TSecretTagDALFactory; secretVersionDAL: TSecretVersionDALFactory; projectDAL: Pick; @@ -128,7 +125,6 @@ type TSecretServiceFactoryDep = { export type TSecretServiceFactory = ReturnType; export const secretServiceFactory = ({ secretDAL, - secretV2BridgeDAL, projectEnvDAL, secretTagDAL, secretVersionDAL, @@ -1388,33 +1384,15 @@ export const secretServiceFactory = ({ }; const getSecretByIdRaw = async ({ secretId, actorId, actor, actorOrgId, actorAuthMethod }: TGetASecretByIdRawDTO) => { - const sec = await secretV2BridgeDAL.findOneWithTags({ - [`${TableName.SecretV2}.id` as "id"]: secretId + const secret = await secretV2BridgeService.getSecretById({ + secretId, + actorId, + actor, + actorOrgId, + actorAuthMethod }); - if (!sec) { - throw new NotFoundError({ - message: `Secret with id '${secretId}' not found`, - name: "GetSecretById" - }); - } - - const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(sec.projectId); - - if (shouldUseSecretV2Bridge) { - const secret = await secretV2BridgeService.getSecretById({ - secret: sec, - actorId, - actor, - actorOrgId, - actorAuthMethod - }); - - return secret; - } - throw new BadRequestError({ - message: "Project version not supported. Please upgrade your project." - }); + return secret; }; const getSecretByNameRaw = async ({ diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 371752594..46aedc2ee 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { z } from "zod"; -import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate, TSecretsV2 } from "@app/db/schemas"; +import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -122,14 +122,7 @@ export type TGetASecretDTO = { } & TProjectPermission; export type TGetASecretByIdDTO = { - secret: TSecretsV2 & { - tags: { - id: string; - color?: string | null; - slug: string; - name: string; - }[]; - }; + secretId: string; } & Omit; export type TCreateBulkSecretDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index d78815222..5e7af286d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -5,6 +5,7 @@ import { faArrowRotateRight, faCheckCircle, faClock, + faCopy, faDesktop, faEyeSlash, faPlus, @@ -990,29 +991,49 @@ export const SecretDetailSidebar = ({ )} - - {(isAllowed) => ( +
+ { + await navigator.clipboard.writeText(secret.id); + + createNotification({ + title: "Secret ID Copied", + text: "The secret ID has been copied to your clipboard.", + type: "success" + }); + }} > - - - + - )} - + + + {(isAllowed) => ( + + + + + + )} + +
From 07b93c5cec7ab6f1438f2bb36ee640a320f87f41 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 6 Mar 2025 20:36:45 +0400 Subject: [PATCH 032/111] Update secret-v2-bridge-service.ts --- .../src/services/secret-v2-bridge/secret-v2-bridge-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1891b3eac..d6ba02856 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 @@ -964,7 +964,7 @@ export const secretV2BridgeServiceFactory = ({ const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secretId }: TGetASecretByIdDTO) => { const secret = await secretDAL.findOneWithTags({ - id: secretId + [`${TableName.SecretV2}.id` as "id"]: secretId }); if (!secret) { From 67f2e4671a61a759af0a4b77b6cf8423b5b49822 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Fri, 7 Mar 2025 19:59:29 +0400 Subject: [PATCH 033/111] requested changes --- .../services/auth/auth-password-service.ts | 48 +++++++++---------- .../auth/VerifyEmailPage/VerifyEmailPage.tsx | 6 ++- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 4d01870f6..27d3bdf2d 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -119,35 +119,31 @@ export const authPaswordServiceFactory = ({ * Email password reset flow via email. Step 1 send email */ const sendPasswordResetEmail = async (email: string) => { - const startTime = Date.now(); + const sendEmail = async () => { + const user = await userDAL.findUserByUsername(email); - const user = await userDAL.findUserByUsername(email); + if (user && user.isAccepted) { + const cfg = getConfig(); + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, + userId: user.id + }); - if (user && user.isAccepted) { - const cfg = getConfig(); - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, - userId: user.id - }); + await smtpService.sendMail({ + template: SmtpTemplates.ResetPassword, + recipients: [email], + subjectLine: "Infisical password reset", + substitutions: { + email, + token, + callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" + } + }); + } + }; - await smtpService.sendMail({ - template: SmtpTemplates.ResetPassword, - recipients: [email], - subjectLine: "Infisical password reset", - substitutions: { - email, - token, - callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" - } - }); - } - - const elapsedTime = Date.now() - startTime; - // daniel: ensure each request takes 8 seconds to prevent timing attacks - if (elapsedTime < 8_000) { - // eslint-disable-next-line no-promise-executor-return - await new Promise((resolve) => setTimeout(resolve, 8_000 - elapsedTime)); - } + // note(daniel): run in background to prevent timing attacks + void sendEmail(); }; /* diff --git a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx index bc0bdf1ff..47012e2cb 100644 --- a/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx +++ b/frontend/src/pages/auth/VerifyEmailPage/VerifyEmailPage.tsx @@ -103,8 +103,10 @@ export const VerifyEmailPage = () => { Look for an email in your inbox.

-

- An email with instructions has been sent to {email}. +

+ If the email is in our system, you will receive an email at{" "} + {email} with instructions on how to reset your + password.

From 795d9e44139348e11cca7ce171752d66f00c784c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Fri, 7 Mar 2025 20:15:30 +0400 Subject: [PATCH 034/111] Update auth-password-service.ts --- backend/src/services/auth/auth-password-service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 27d3bdf2d..f5f42a236 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -25,6 +25,7 @@ import { TSetupPasswordViaBackupKeyDTO } from "./auth-password-type"; import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; +import { logger } from "@app/lib/logger"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; @@ -143,7 +144,7 @@ export const authPaswordServiceFactory = ({ }; // note(daniel): run in background to prevent timing attacks - void sendEmail(); + void sendEmail().catch((err) => logger.error(err, "Failed to send password reset email")); }; /* From 3fb8ad2fac5cda53cc4d83a536611df7cf55dea1 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 8 Mar 2025 00:22:27 +0800 Subject: [PATCH 035/111] misc: add uncaught exception handler --- backend/src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/main.ts b/backend/src/main.ts index 461601fc0..f06218bc3 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -83,6 +83,10 @@ const run = async () => { process.exit(0); }); + process.on("uncaughtException", (error) => { + logger.error(error, "CRITICAL ERROR: Uncaught Exception"); + }); + await server.listen({ port: envConfig.PORT, host: envConfig.HOST, From 045debeaf370a82976c5e6aff7804a71490f7f28 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 8 Mar 2025 00:29:23 +0800 Subject: [PATCH 036/111] misc: added unhandled rejection handler --- backend/src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/main.ts b/backend/src/main.ts index f06218bc3..d5c54991b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -87,6 +87,10 @@ const run = async () => { logger.error(error, "CRITICAL ERROR: Uncaught Exception"); }); + process.on("unhandledRejection", (error) => { + logger.error(error, "CRITICAL ERROR: Unhandled Promise Rejection"); + }); + await server.listen({ port: envConfig.PORT, host: envConfig.HOST, From ada04ed4fca6de197546ae31fe08df458a18c16e Mon Sep 17 00:00:00 2001 From: akoullick1 Date: Fri, 7 Mar 2025 10:19:54 -0800 Subject: [PATCH 037/111] Update meetings.mdx Added daily standup --- company/handbook/meetings.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/company/handbook/meetings.mdx b/company/handbook/meetings.mdx index af6a3b54e..ff8db24e8 100644 --- a/company/handbook/meetings.mdx +++ b/company/handbook/meetings.mdx @@ -10,6 +10,10 @@ Being a remote-first company, we try to be as async as possible. When an issue a In other words, we have almost no (recurring) meetings and prefer written communication or quick Slack huddles. +## Daily Standup + +Towards the end of each day, everyone on the Engineering and GTM teams should document their progress in the respective Slack standup channels, ensuring the team stays informed of important updates. + ## Weekly All-hands -All-hands is the single recurring meeting that we run every Monday at 8:30am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too). +All-hands is the single recurring meeting that we run every Monday at 8:00am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too). From 0f42fcd6881c538b6adc969eec8aa3ef0f653bc9 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 7 Mar 2025 16:59:12 -0300 Subject: [PATCH 038/111] Remove addAllMembers option from project creation modal --- .../components/projects/NewProjectModal.tsx | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index 5ef17ec84..dcd3040d8 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -14,7 +14,6 @@ import { AccordionItem, AccordionTrigger, Button, - Checkbox, FormControl, Input, Modal, @@ -33,13 +32,7 @@ import { useUser } from "@app/context"; import { getProjectHomePage } from "@app/helpers/project"; -import { - fetchOrgUsers, - useAddUserToWsNonE2EE, - useCreateWorkspace, - useGetExternalKmsList, - useGetUserWorkspaces -} from "@app/hooks/api"; +import { useCreateWorkspace, useGetExternalKmsList, useGetUserWorkspaces } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; import { ProjectType } from "@app/hooks/api/workspace/types"; @@ -51,7 +44,6 @@ const formSchema = z.object({ .trim() .max(256, "Description too long, max length is 256 characters") .optional(), - addMembers: z.boolean(), kmsKeyId: z.string(), template: z.string() }); @@ -73,7 +65,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => { const { user } = useUser(); const createWs = useCreateWorkspace(); const { refetch: refetchWorkspaces } = useGetUserWorkspaces(); - const addUsersToProject = useAddUserToWsNonE2EE(); const { subscription } = useSubscription(); const canReadProjectTemplates = permission.can( @@ -111,7 +102,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => { const onCreateProject = async ({ name, description, - addMembers, kmsKeyId, template }: TAddProjectFormData) => { @@ -128,21 +118,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => { template, type: projectType }); - const { id: newProjectId } = project; - - if (addMembers) { - const orgUsers = await fetchOrgUsers(currentOrg.id); - await addUsersToProject.mutateAsync({ - usernames: orgUsers - .filter( - (member) => member.user.username !== user.username && member.status === "accepted" - ) - .map((member) => member.user.username), - projectId: newProjectId, - orgId: currentOrg.id - }); - } - await refetchWorkspaces(); createNotification({ text: "Project created", type: "success" }); @@ -246,31 +221,7 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => { )} /> -
- ( - - {(isAllowed) => ( -
- - Add all members of my organization to this project - -
- )} -
- )} - /> -
-
+
From 699e03c1a9169055fcd3c54cdb3e6de1e4e83473 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 7 Mar 2025 17:49:30 -0300 Subject: [PATCH 039/111] Allow project slug edition and refactor frontend components to reduce duplicated code --- backend/src/lib/api-docs/constants.ts | 3 +- .../src/server/routes/v1/project-router.ts | 11 +- .../src/services/project/project-service.ts | 15 +- backend/src/services/project/project-types.ts | 1 + .../project}/ProjectOverviewChangeSection.tsx | 114 +++++++++--- frontend/src/hooks/api/workspace/queries.tsx | 5 +- frontend/src/hooks/api/workspace/types.ts | 1 + .../ProjectGeneralTab/ProjectGeneralTab.tsx | 5 +- .../CopyButton.tsx | 51 ------ .../ProjectOverviewChangeSection.tsx | 168 ------------------ .../ProjectOverviewChangeSection/index.tsx | 1 - .../SettingsPage/components/index.tsx | 1 - .../ProjectGeneralTab/ProjectGeneralTab.tsx | 5 +- .../CopyButton.tsx | 51 ------ .../ProjectOverviewChangeSection/index.tsx | 1 - .../kms/SettingsPage/components/index.tsx | 1 - .../ProjectGeneralTab/ProjectGeneralTab.tsx | 4 +- .../CopyButton.tsx | 51 ------ .../ProjectOverviewChangeSection.tsx | 168 ------------------ .../ProjectOverviewChangeSection/index.tsx | 1 - .../SettingsPage/components/index.tsx | 1 - .../ProjectGeneralTab/ProjectGeneralTab.tsx | 5 +- .../CopyButton.tsx | 51 ------ .../ProjectOverviewChangeSection.tsx | 168 ------------------ .../ProjectOverviewChangeSection/index.tsx | 1 - .../ssh/SettingsPage/components/index.tsx | 1 - 26 files changed, 133 insertions(+), 752 deletions(-) rename frontend/src/{pages/kms/SettingsPage/components/ProjectOverviewChangeSection => components/project}/ProjectOverviewChangeSection.tsx (58%) delete mode 100644 frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx delete mode 100644 frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx delete mode 100644 frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/index.tsx delete mode 100644 frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx delete mode 100644 frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/index.tsx delete mode 100644 frontend/src/pages/secret-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx delete mode 100644 frontend/src/pages/secret-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx delete mode 100644 frontend/src/pages/secret-manager/SettingsPage/components/ProjectOverviewChangeSection/index.tsx delete mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx delete mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx delete mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectOverviewChangeSection/index.tsx diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1b458175d..821f91ddc 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -459,7 +459,8 @@ export const PROJECTS = { workspaceId: "The ID of the project to update.", name: "The new name of the project.", projectDescription: "An optional description label for the project.", - autoCapitalization: "Disable or enable auto-capitalization for the project." + autoCapitalization: "Disable or enable auto-capitalization for the project.", + slug: "An optional slug for the project. (must be unique within the server)" }, GET_KEY: { workspaceId: "The ID of the project to get the key from." diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 68d13842c..5209dadcf 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -307,7 +307,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .max(256, { message: "Description must be 256 or fewer characters" }) .optional() .describe(PROJECTS.UPDATE.projectDescription), - autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + slug: z + .string() + .trim() + .max(64, { message: "Slug must be 64 characters or fewer" }) + .optional() + .describe(PROJECTS.UPDATE.slug) }), response: { 200: z.object({ @@ -325,7 +331,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { update: { name: req.body.name, description: req.body.description, - autoCapitalization: req.body.autoCapitalization + autoCapitalization: req.body.autoCapitalization, + slug: req.body.slug }, actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e1653d371..4b110ebd2 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -563,11 +563,24 @@ export const projectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + if (update.slug) { + const existingProject = await projectDAL.findOne({ + slug: update.slug, + orgId: actorOrgId + }); + if (existingProject && existingProject.id !== project.id) { + throw new BadRequestError({ + message: `Failed to update project slug. The project "${existingProject.name}" with the slug "${existingProject.slug}" already exists in your organization. Please choose a unique slug for your project.` + }); + } + } + const updatedProject = await projectDAL.updateById(project.id, { name: update.name, description: update.description, autoCapitalization: update.autoCapitalization, - enforceCapitalization: update.autoCapitalization + enforceCapitalization: update.autoCapitalization, + slug: update.slug }); return updatedProject; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 83a59b6af..5ccf33d23 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -82,6 +82,7 @@ export type TUpdateProjectDTO = { name?: string; description?: string; autoCapitalization?: boolean; + slug?: string; }; } & Omit; diff --git a/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx b/frontend/src/components/project/ProjectOverviewChangeSection.tsx similarity index 58% rename from frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx rename to frontend/src/components/project/ProjectOverviewChangeSection.tsx index d82415c0d..767f17334 100644 --- a/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx +++ b/frontend/src/components/project/ProjectOverviewChangeSection.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -9,9 +9,7 @@ import { Button, FormControl, Input, TextArea } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useUpdateProject } from "@app/hooks/api"; -import { CopyButton } from "./CopyButton"; - -const formSchema = z.object({ +const baseFormSchema = z.object({ name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), description: z .string() @@ -20,33 +18,59 @@ const formSchema = z.object({ .optional() }); -type FormData = z.infer; +const formSchemaWithSlug = baseFormSchema.extend({ + slug: z + .string() + .min(1, "Required") + .max(64, "Too long, maximum length is 64 characters") + .regex(/^[a-zA-Z0-9-]+$/, "Only letters, numbers and hyphens are allowed") +}); -export const ProjectOverviewChangeSection = () => { +type BaseFormData = z.infer; +type FormDataWithSlug = z.infer; + +type Props = { + showSlugField?: boolean; +}; + +export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) => { const { currentWorkspace } = useWorkspace(); + const [currentSlug, setCurrentSlug] = useState(currentWorkspace?.slug); const { mutateAsync, isPending } = useUpdateProject(); - const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); + const { handleSubmit, control, reset } = useForm({ + resolver: zodResolver(showSlugField ? formSchemaWithSlug : baseFormSchema) + }); useEffect(() => { if (currentWorkspace) { reset({ name: currentWorkspace.name, - description: currentWorkspace.description ?? "" + description: currentWorkspace.description ?? "", + ...(showSlugField && { slug: currentWorkspace.slug }) }); + setCurrentSlug(currentWorkspace.slug); } - }, [currentWorkspace]); + }, [currentWorkspace, showSlugField]); - const onFormSubmit = async ({ name, description }: FormData) => { + const onFormSubmit = async (data: BaseFormData | FormDataWithSlug) => { try { if (!currentWorkspace?.id) return; await mutateAsync({ projectID: currentWorkspace.id, - newProjectName: name, - newProjectDescription: description + newProjectName: data.name, + newProjectDescription: data.description, + ...(showSlugField && + "slug" in data && { + newSlug: data.slug !== currentWorkspace.slug ? data.slug : undefined + }) }); + if (showSlugField && "slug" in data) { + setCurrentSlug(data.slug); + } + createNotification({ text: "Successfully updated project overview", type: "success" @@ -65,20 +89,34 @@ export const ProjectOverviewChangeSection = () => {

Project Overview

- { + navigator.clipboard.writeText(currentSlug || ""); + createNotification({ + text: "Copied project slug to clipboard", + type: "success" + }); + }} + title="Click to copy project slug" > Copy Project Slug - - +
@@ -113,6 +151,38 @@ export const ProjectOverviewChangeSection = () => {
+ {showSlugField && ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + control={control} + name="slug" + /> + )} + +
+
+ )}
{ const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ projectID, newProjectName, newProjectDescription }) => { + mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => { const { data } = await apiRequest.patch<{ workspace: Workspace }>( `/api/v1/workspace/${projectID}`, { name: newProjectName, - description: newProjectDescription + description: newProjectDescription, + slug: newSlug } ); return data.workspace; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 0510bdbe7..0980bc715 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -74,6 +74,7 @@ export type UpdateProjectDTO = { projectID: string; newProjectName: string; newProjectDescription?: string; + newSlug?: string; }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index ef684a21b..41e8107e0 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -1,11 +1,12 @@ +import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection"; + import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { DeleteProjectSection } from "../DeleteProjectSection"; -import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection"; export const ProjectGeneralTab = () => { return (
- +
diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx deleted file mode 100644 index 34fe365ec..000000000 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useCallback } from "react"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { Button } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -type Props = { - value: string; - hoverText: string; - notificationText: string; - children: React.ReactNode; -}; - -export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => { - const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false); - - const copyToClipboard = useCallback(() => { - if (isProjectIdCopied) { - return; - } - - setIsProjectIdCopied.on(); - navigator.clipboard.writeText(value); - - createNotification({ - text: notificationText, - type: "success" - }); - - const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000); - - // eslint-disable-next-line consistent-return - return () => clearTimeout(timer); - }, [isProjectIdCopied]); - - return ( - - ); -}; diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx deleted file mode 100644 index d82415c0d..000000000 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, FormControl, Input, TextArea } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useUpdateProject } from "@app/hooks/api"; - -import { CopyButton } from "./CopyButton"; - -const formSchema = z.object({ - name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), - description: z - .string() - .trim() - .max(256, "Description too long, max length is 256 characters") - .optional() -}); - -type FormData = z.infer; - -export const ProjectOverviewChangeSection = () => { - const { currentWorkspace } = useWorkspace(); - const { mutateAsync, isPending } = useUpdateProject(); - - const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); - - useEffect(() => { - if (currentWorkspace) { - reset({ - name: currentWorkspace.name, - description: currentWorkspace.description ?? "" - }); - } - }, [currentWorkspace]); - - const onFormSubmit = async ({ name, description }: FormData) => { - try { - if (!currentWorkspace?.id) return; - - await mutateAsync({ - projectID: currentWorkspace.id, - newProjectName: name, - newProjectDescription: description - }); - - createNotification({ - text: "Successfully updated project overview", - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update project overview", - type: "error" - }); - } - }; - - return ( -
-
-

Project Overview

-
- - Copy Project Slug - - - Copy Project ID - -
-
-
-
-
-
- - {(isAllowed) => ( - ( - - - - )} - control={control} - name="name" - /> - )} - -
-
-
-
- - {(isAllowed) => ( - ( - -