diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index a4bdeb29e..3f85326f4 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -92,7 +92,7 @@ jobs: exit 1 fi - name: Install openapi-diff - run: go install github.com/tufin/oasdiff@latest + run: go install github.com/oasdiff/oasdiff@latest - name: Running OpenAPI Spec diff action run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR - name: cleanup 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 + diff --git a/backend/package.json b/backend/package.json index 31b01d1c5..5c2c805bc 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..27e847896 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 update 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 eb9c66c1c..3c984585c 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/sanitizedSchema/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 d74f9c504..22e4c1984 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/sanitizedSchema/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/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/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 6f87663b2..e406d9c88 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/sanitizedSchema/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/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..d296d7975 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({ @@ -1156,7 +1155,8 @@ export const secretApprovalRequestServiceFactory = ({ environment: env.name, secretPath, projectId, - requestId: secretApprovalRequest.id + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] } } }); @@ -1456,7 +1456,8 @@ export const secretApprovalRequestServiceFactory = ({ environment: env.name, secretPath, projectId, - requestId: secretApprovalRequest.id + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] } } }); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1b458175d..94076cf25 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 organization)" }, GET_KEY: { workspaceId: "The ID of the project to get the key from." diff --git a/backend/src/lib/casl/boundary.test.ts b/backend/src/lib/casl/boundary.test.ts new file mode 100644 index 000000000..05c2b9ecf --- /dev/null +++ b/backend/src/lib/casl/boundary.test.ts @@ -0,0 +1,669 @@ +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" + }, + { + action: ["create"], + subject: "secrets" + } + ]), + 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..15592a7bd --- /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 = !subset[PermissionConditionOperators.$EQ]; + 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 ad4bf028f..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, MongoAbility } from "@casl/ability"; +import { buildMongoQueryMatcher } from "@casl/ability"; import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; import picomatch from "picomatch"; @@ -20,45 +20,8 @@ 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}`); - }); - } - }); - return permissions; -}; - -/** - * 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 = (permissions1: MongoAbility, permissions2: MongoAbility) => { - const set1 = new Set(extractPermissions(permissions1)); - const set2 = new Set(extractPermissions(permissions2)); - - for (const perm of set2) { - if (!set1.has(perm)) { - return false; - } - } - - return set1.size >= set2.size; -}; - export enum PermissionConditionOperators { $IN = "$in", - $ALL = "$all", - $REGEX = "$regex", $EQ = "$eq", $NEQ = "$ne", $GLOB = "$glob" 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/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 8d25c2af6..118283f64 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -96,6 +96,7 @@ export const pingGatewayAndVerify = async ({ error: err as Error }); }); + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { try { const stream = quicClient.connection.newStream("bidi"); @@ -108,17 +109,13 @@ export const pingGatewayAndVerify = async ({ const { value, done } = await reader.read(); if (done) { - throw new BadRequestError({ - message: "Gateway closed before receiving PONG" - }); + throw new Error("Gateway closed before receiving PONG"); } const response = Buffer.from(value).toString(); if (response !== "PONG\n" && response !== "PONG") { - throw new BadRequestError({ - message: `Failed to Ping. Unexpected response: ${response}` - }); + throw new Error(`Failed to Ping. Unexpected response: ${response}`); } reader.releaseLock(); @@ -146,6 +143,7 @@ interface TProxyServer { server: net.Server; port: number; cleanup: () => Promise; + getProxyError: () => string; } const setupProxyServer = async ({ @@ -170,6 +168,7 @@ const setupProxyServer = async ({ error: err as Error }); }); + const proxyErrorMsg = [""]; return new Promise((resolve, reject) => { const server = net.createServer(); @@ -185,31 +184,33 @@ const setupProxyServer = async ({ const forwardWriter = stream.writable.getWriter(); await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`)); forwardWriter.releaseLock(); - /* eslint-disable @typescript-eslint/no-misused-promises */ + // Set up bidirectional copy - const setupCopy = async () => { + const setupCopy = () => { // Client to QUIC // eslint-disable-next-line (async () => { - try { - const writer = stream.writable.getWriter(); + const writer = stream.writable.getWriter(); - // Create a handler for client data - clientConn.on("data", async (chunk) => { - await writer.write(chunk); + // Create a handler for client data + clientConn.on("data", (chunk) => { + writer.write(chunk).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); }); + }); - // Handle client connection close - clientConn.on("end", async () => { - await writer.close(); + // Handle client connection close + clientConn.on("end", () => { + writer.close().catch((err) => { + logger.error(err); }); + }); - clientConn.on("error", async (err) => { - await writer.abort(err); + clientConn.on("error", (clientConnErr) => { + writer.abort(clientConnErr?.message).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); }); - } catch (err) { - clientConn.destroy(); - } + }); })(); // QUIC to Client @@ -238,15 +239,18 @@ const setupProxyServer = async ({ } } } catch (err) { + proxyErrorMsg.push((err as Error)?.message); clientConn.destroy(); } })(); }; - await setupCopy(); - // + + setupCopy(); // Handle connection closure - clientConn.on("close", async () => { - await stream.destroy(); + clientConn.on("close", () => { + stream.destroy().catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); }); const cleanup = async () => { @@ -254,13 +258,18 @@ const setupProxyServer = async ({ await stream.destroy(); }; - clientConn.on("error", (err) => { - logger.error(err, "Client socket error"); - void cleanup(); - reject(err); + clientConn.on("error", (clientConnErr) => { + logger.error(clientConnErr, "Client socket error"); + cleanup().catch((err) => { + logger.error(err, "Client conn cleanup"); + }); }); - clientConn.on("end", cleanup); + clientConn.on("end", () => { + cleanup().catch((err) => { + logger.error(err, "Client conn end"); + }); + }); } catch (err) { logger.error(err, "Failed to establish target connection:"); clientConn.end(); @@ -272,12 +281,12 @@ const setupProxyServer = async ({ reject(err); }); - server.on("close", async () => { - await quicClient?.destroy(); + server.on("close", () => { + quicClient?.destroy().catch((err) => { + logger.error(err, "Failed to destroy quic client"); + }); }); - /* eslint-enable */ - server.listen(0, () => { const address = server.address(); if (!address || typeof address === "string") { @@ -293,7 +302,8 @@ const setupProxyServer = async ({ cleanup: async () => { server.close(); await quicClient?.destroy(); - } + }, + getProxyError: () => proxyErrorMsg.join(",") }); }); }); @@ -316,7 +326,7 @@ export const withGatewayProxy = async ( const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options; // Setup the proxy server - const { port, cleanup } = await setupProxyServer({ + const { port, cleanup, getProxyError } = await setupProxyServer({ targetHost, targetPort, relayPort, @@ -330,8 +340,12 @@ export const withGatewayProxy = async ( // Execute the callback with the allocated port await callback(port); } catch (err) { - logger.error(err, "Failed to proxy"); - throw new BadRequestError({ message: (err as Error)?.message }); + const proxyErrorMessage = getProxyError(); + if (proxyErrorMessage) { + logger.error(new Error(proxyErrorMessage), "Failed to proxy"); + } + logger.error(err, "Failed to do gateway"); + throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message }); } finally { // Ensure cleanup happens regardless of success or failure await cleanup(); diff --git a/backend/src/lib/turn/credentials.ts b/backend/src/lib/turn/credentials.ts index 817148c30..37dcaa78b 100644 --- a/backend/src/lib/turn/credentials.ts +++ b/backend/src/lib/turn/credentials.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; -const TURN_TOKEN_TTL = 60 * 60 * 1000; // 24 hours in milliseconds +const TURN_TOKEN_TTL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds export const getTurnCredentials = (id: string, authSecret: string, ttl = TURN_TOKEN_TTL) => { const timestamp = Math.floor((Date.now() + ttl) / 1000); const username = `${timestamp}:${id}`; diff --git a/backend/src/main.ts b/backend/src/main.ts index 461601fc0..d5c54991b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -83,6 +83,14 @@ const run = async () => { process.exit(0); }); + process.on("uncaughtException", (error) => { + 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, 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/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/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 68d13842c..dcedf5fa5 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -307,7 +307,17 @@ 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() + .regex( + /^[a-z0-9]+(?:[_-][a-z0-9]+)*$/, + "Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore." + ) + .max(64, { message: "Slug must be 64 characters or fewer" }) + .optional() + .describe(PROJECTS.UPDATE.slug) }), response: { 200: z.object({ @@ -325,7 +335,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/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/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 a5677894d..c7b2c5c8d 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, TIdentityKubernetesAuthsUpdate } from "@app/db/sche 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"; @@ -487,9 +487,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 ff7256a9c..3c947f504 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, TIdentityOidcAuthsUpdate } 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"; @@ -428,11 +428,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..e16ffb3d4 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 assign 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 remove 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..078b50c08 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 create client secret for a more privileged identity.", + 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 client secret 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 identity 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..8ada2a5d1 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 update 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 identity", + details: { missingPermissions: permissionBoundary.missingPermissions } + }); const deletedIdentity = await identityDAL.deleteById(id); diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 1d9fedde7..eb17c05bb 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 is NonNullable => auth !== null); }; const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { 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/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/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..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 @@ -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,73 @@ export const secretV2BridgeServiceFactory = ({ }; }; + const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secretId }: TGetASecretByIdDTO) => { + const secret = await secretDAL.findOneWithTags({ + [`${TableName.SecretV2}.id` as "id"]: secretId + }); + + if (!secret) { + throw new NotFoundError({ + message: `Secret with ID '${secretId}' not found`, + name: "GetSecretById" + }); + } + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secret.projectId, [secret.folderId]); + + if (!folderWithPath) { + throw new NotFoundError({ + message: `Folder with id '${secret.folderId}' not found`, + name: "GetSecretById" + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: secret.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: folderWithPath.environmentSlug, + 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: secret.projectId + }); + + const secretValue = secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : ""; + + const secretComment = secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : ""; + + return reshapeBridgeSecret(secret.projectId, folderWithPath.environmentSlug, folderWithPath.path, { + ...secret, + value: secretValue, + comment: secretComment + }); + }; + const getSecretByName = async ({ actorId, actor, @@ -2237,6 +2311,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsCountMultiEnv, getSecretsMultiEnv, getSecretReferenceTree, - getSecretsByFolderMappings + getSecretsByFolderMappings, + getSecretById }; }; 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/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 93f68e813..cfb47d1dd 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -71,6 +71,7 @@ import { TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, + TGetASecretByIdRawDTO, TGetASecretDTO, TGetASecretRawDTO, TGetSecretAccessListDTO, @@ -95,7 +96,7 @@ type TSecretServiceFactoryDep = { projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findSecretPathByFolderIds" >; secretV2BridgeService: TSecretV2BridgeServiceFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; @@ -1382,6 +1383,18 @@ export const secretServiceFactory = ({ }; }; + const getSecretByIdRaw = async ({ secretId, actorId, actor, actorOrgId, actorAuthMethod }: TGetASecretByIdRawDTO) => { + const secret = await secretV2BridgeService.getSecretById({ + secretId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }); + + return secret; + }; + const getSecretByNameRaw = async ({ type, path, @@ -3088,6 +3101,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..46aedc2ee 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -121,6 +121,10 @@ export type TGetASecretDTO = { version?: number; } & TProjectPermission; +export type TGetASecretByIdDTO = { + secretId: string; +} & Omit; + export type TCreateBulkSecretDTO = { path: string; environment: string; @@ -213,6 +217,10 @@ export type TGetASecretRawDTO = { projectId?: string; } & Omit; +export type TGetASecretByIdRawDTO = { + secretId: string; +} & Omit; + export type TCreateSecretRawDTO = TProjectPermission & { secretName: string; secretPath: string; diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 14ab6a94c..f92f96a24 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -50,6 +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 || "/"} +*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 diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index a1914eee2..a92ba4e8b 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -62,6 +62,7 @@ export type TSlackNotification = secretPath: string; requestId: string; projectId: string; + secretKeys: string[]; }; } | { 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; 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") + } + } +}); diff --git a/cli/go.mod b/cli/go.mod index 53f348807..bdb8839d0 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -29,9 +29,9 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.35.0 - golang.org/x/sys v0.30.0 - golang.org/x/term v0.29.0 + golang.org/x/crypto v0.36.0 + golang.org/x/sys v0.31.0 + golang.org/x/term v0.30.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -115,8 +115,8 @@ require ( golang.org/x/mod v0.23.0 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.6.0 // indirect golang.org/x/tools v0.30.0 // indirect google.golang.org/api v0.188.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index d87cc825a..ab46476d7 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -486,6 +486,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -592,6 +594,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -642,9 +646,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -656,6 +664,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index 248a9dc7b..bcf977d6d 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -14,6 +14,7 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/systemd" "github.com/go-resty/resty/v2" + "github.com/pion/dtls/v3" "github.com/pion/logging" "github.com/pion/turn/v4" "github.com/rs/zerolog/log" @@ -54,26 +55,6 @@ func (g *Gateway) ConnectWithRelay() error { return err } relayAddress, relayPort := strings.Split(relayDetails.TurnServerAddress, ":")[0], strings.Split(relayDetails.TurnServerAddress, ":")[1] - var conn net.Conn - - // Dial TURN Server - if relayPort == "5349" { - log.Info().Msgf("Provided relay port %s. Using TLS", relayPort) - conn, err = tls.Dial("tcp", relayDetails.TurnServerAddress, &tls.Config{ - ServerName: relayAddress, - }) - } else { - log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort) - peerAddr, errPeer := net.ResolveTCPAddr("tcp", relayDetails.TurnServerAddress) - if errPeer != nil { - return fmt.Errorf("Failed to parse turn server address: %w", err) - } - conn, err = net.DialTCP("tcp", nil, peerAddr) - } - - if err != nil { - return fmt.Errorf("Failed to connect with relay server: %w", err) - } // Start a new TURN Client and wrap our net.Conn in a STUNConn // This allows us to simulate datagram based communication over a net.Conn @@ -81,17 +62,46 @@ func (g *Gateway) ConnectWithRelay() error { if os.Getenv("LOG_LEVEL") == "debug" { logger.DefaultLogLevel = logging.LogLevelDebug } - cfg := &turn.ClientConfig{ + + turnClientCfg := &turn.ClientConfig{ STUNServerAddr: relayDetails.TurnServerAddress, TURNServerAddr: relayDetails.TurnServerAddress, - Conn: turn.NewSTUNConn(conn), Username: relayDetails.TurnServerUsername, Password: relayDetails.TurnServerPassword, Realm: relayDetails.TurnServerRealm, LoggerFactory: logger, } - client, err := turn.NewClient(cfg) + turnAddr, err := net.ResolveUDPAddr("udp4", relayDetails.TurnServerAddress) + if err != nil { + return fmt.Errorf("Failed to parse turn server address: %w", err) + } + + // Dial TURN Server + if relayPort == "5349" { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(g.config.CertificateChain)) + + log.Info().Msgf("Provided relay port %s. Using TLS", relayPort) + conn, err := dtls.Dial("udp", turnAddr, &dtls.Config{ + ServerName: relayAddress, + RootCAs: caCertPool, + }) + if err != nil { + return fmt.Errorf("Failed to connect with relay server: %w", err) + } + turnClientCfg.Conn = turn.NewSTUNConn(conn) + } else { + log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort) + conn, err := net.ListenPacket("udp4", turnAddr.String()) + if err != nil { + return fmt.Errorf("Failed to connect with relay server: %w", err) + } + + turnClientCfg.Conn = conn + } + + client, err := turn.NewClient(turnClientCfg) if err != nil { return fmt.Errorf("Failed to create relay client: %w", err) } @@ -168,7 +178,6 @@ func (g *Gateway) Listen(ctx context.Context) error { ClientAuth: tls.RequireAndVerifyClientCert, NextProtos: []string{"infisical-gateway"}, } - // Setup QUIC listener on the relayConn quicConfig := &quic.Config{ EnableDatagrams: true, @@ -176,7 +185,6 @@ func (g *Gateway) Listen(ctx context.Context) error { KeepAlivePeriod: 2 * time.Second, } - g.registerRelayIsActive(ctx, errCh) quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig) if err != nil { return fmt.Errorf("Failed to listen for QUIC: %w", err) @@ -185,6 +193,8 @@ func (g *Gateway) Listen(ctx context.Context) error { log.Printf("Listener started on %s", quicListener.Addr()) + g.registerRelayIsActive(ctx, quicListener.Addr().String(), errCh) + log.Info().Msg("Gateway started successfully") var wg sync.WaitGroup @@ -320,13 +330,12 @@ func (g *Gateway) createPermissionForStaticIps(staticIps string) error { return nil } -func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) error { +func (g *Gateway) registerRelayIsActive(ctx context.Context, addr string, errCh chan error) error { ticker := time.NewTicker(15 * time.Second) maxFailures := 3 failures := 0 log.Info().Msg("Starting relay connection health check") - go func() { time.Sleep(5 * time.Second) for { @@ -335,36 +344,26 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) e log.Info().Msg("Stopping relay connection health check") return case <-ticker.C: - func() { - log.Debug().Msg("Performing relay connection health check") - - if g.client == nil { - failures++ - log.Warn().Int("failures", failures).Msg("TURN client is nil") - if failures >= maxFailures { - errCh <- fmt.Errorf("relay connection check failed: TURN client is nil") - } + log.Debug().Msg("Performing relay connection health check") + ctxTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + // Try to establish a QUIC connection + conn, err := quic.DialAddr(ctxTimeout, addr, &tls.Config{ + InsecureSkipVerify: false, // Skip certificate verification + NextProtos: []string{"infisical-gateway"}, + }, nil) + if err != nil && !strings.Contains(err.Error(), "tls:") { + failures++ + log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions") + if failures >= maxFailures { + errCh <- fmt.Errorf("relay connection check failed: %w", err) return } - - // we try to refresh permissions - this is a lightweight operation - // that will fail immediately if the UDP connection is broken. good for health check - log.Debug().Msg("Refreshing TURN permissions to verify connection") - if err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp); err != nil { - failures++ - log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions") - if failures >= maxFailures { - errCh <- fmt.Errorf("relay connection check failed: %w", err) - } - return - } - - log.Debug().Msg("Successfully refreshed TURN permissions - connection is healthy") - if failures > 0 { - log.Info().Int("previous_failures", failures).Msg("Relay connection restored") - failures = 0 - } - }() + continue + } + if conn != nil { + defer conn.CloseWithError(0, "All good") + } } } }() diff --git a/cli/packages/gateway/relay.go b/cli/packages/gateway/relay.go index bbd1332a4..08a5eb247 100644 --- a/cli/packages/gateway/relay.go +++ b/cli/packages/gateway/relay.go @@ -4,7 +4,6 @@ package gateway import ( - "context" "crypto/tls" "crypto/x509" "errors" @@ -12,12 +11,13 @@ import ( "net" "os" "os/signal" - "runtime" + + // "runtime" "strconv" "syscall" - udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener" "github.com/Infisical/infisical-merge/packages/systemd" + "github.com/pion/dtls/v3" "github.com/pion/logging" "github.com/pion/turn/v4" "github.com/rs/zerolog/log" @@ -108,7 +108,7 @@ func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) { } func (g *GatewayRelay) Run() error { - addr, err := net.ResolveTCPAddr("tcp", "0.0.0.0:"+strconv.Itoa(g.Config.Port)) + addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:"+strconv.Itoa(g.Config.Port)) if err != nil { return fmt.Errorf("Failed to parse server address: %s", err) } @@ -117,13 +117,6 @@ func (g *GatewayRelay) Run() error { // and process them yourself. logger := logging.NewDefaultLeveledLoggerForScope("lt-creds", logging.LogLevelTrace, os.Stdout) - // Create `numThreads` UDP listeners to pass into pion/turn - // pion/turn itself doesn't allocate any UDP sockets, but lets the user pass them in - // this allows us to add logging, storage or modify inbound/outbound traffic - // UDP listeners share the same local address:port with setting SO_REUSEPORT and the kernel - // will load-balance received packets per the IP 5-tuple - listenerConfig := udplistener.SetupListenerConfig() - publicIP := g.Config.PublicIP relayAddressGenerator := &turn.RelayAddressGeneratorPortRange{ RelayAddress: net.ParseIP(publicIP), // Claim that we are listening on IP passed by user @@ -132,49 +125,54 @@ func (g *GatewayRelay) Run() error { MaxPort: g.Config.RelayMaxPort, } - threadNum := runtime.NumCPU() - listenerConfigs := make([]turn.ListenerConfig, threadNum) - var connAddress string - for i := 0; i < threadNum; i++ { - conn, listErr := listenerConfig.Listen(context.Background(), addr.Network(), addr.String()) - if listErr != nil { - return fmt.Errorf("Failed to allocate TCP listener at %s:%s %s", addr.Network(), addr.String(), listErr) - } - - listenerConfigs[i] = turn.ListenerConfig{ - RelayAddressGenerator: relayAddressGenerator, - } - - if g.Config.isTlsEnabled { - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa)) - - listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{ - Certificates: []tls.Certificate{g.Config.tls}, - ClientCAs: caCertPool, - }) - } else { - listenerConfigs[i].Listener = conn - } - connAddress = conn.Addr().String() - } - loggerF := logging.NewDefaultLoggerFactory() loggerF.DefaultLogLevel = logging.LogLevelDebug + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa)) + + listenerConfigs := make([]turn.ListenerConfig, 0) + packetConfigs := make([]turn.PacketConnConfig, 0) + + if g.Config.isTlsEnabled { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa)) + dtlsServer, err := dtls.Listen("udp", addr, &dtls.Config{ + Certificates: []tls.Certificate{g.Config.tls}, + ClientCAs: caCertPool, + }) + if err != nil { + return fmt.Errorf("Failed to start dtls server: %w", err) + } + listenerConfigs = append(listenerConfigs, turn.ListenerConfig{ + RelayAddressGenerator: relayAddressGenerator, + Listener: dtlsServer, + }) + } else { + udpListener, err := net.ListenPacket("udp4", "0.0.0.0:"+strconv.Itoa(g.Config.Port)) + if err != nil { + return fmt.Errorf("Failed to relay udp listener: %w", err) + } + packetConfigs = append(packetConfigs, turn.PacketConnConfig{ + RelayAddressGenerator: relayAddressGenerator, + PacketConn: udpListener, + }) + } + server, err := turn.NewServer(turn.ServerConfig{ Realm: g.Config.Realm, AuthHandler: turn.LongTermTURNRESTAuthHandler(g.Config.AuthSecret, logger), // PacketConnConfigs is a list of UDP Listeners and the configuration around them - ListenerConfigs: listenerConfigs, - LoggerFactory: loggerF, + ListenerConfigs: listenerConfigs, + PacketConnConfigs: packetConfigs, + LoggerFactory: loggerF, }) if err != nil { return fmt.Errorf("Failed to start server: %w", err) } - log.Info().Msgf("Relay listening on %s\n", connAddress) + log.Info().Msgf("Relay listening on %d\n", g.Config.Port) // make this compatiable with systemd notify mode systemd.SdNotify(false, systemd.SdNotifyReady) diff --git a/company/handbook/meetings.mdx b/company/handbook/meetings.mdx index af6a3b54e..172c114c8 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. On the engineering side, if you are working on something that takes longer than 1-2 days, please add an estimated completion date (ECD) for that item in standup specifying when it will be pushed to production. + ## 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). diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx new file mode 100644 index 000000000..83490fd4d --- /dev/null +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -0,0 +1,110 @@ +--- +title: "Gateway Security Architecture" +sidebarTitle: "Architecture" +description: "Understand the security model and tenant isolation of Infisical's Gateway" +--- + +# Gateway Security Architecture + +The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants. +This document explains the internal security architecture and how tenant isolation is maintained. + +## Security Model Overview + +### Private PKI System +Each organization (tenant) in Infisical has its own private PKI system consisting of: + +1. **Root CA**: The ultimate trust anchor for the organization +2. **Intermediate CAs**: + - Client CA: Issues certificates for cloud components + - Gateway CA: Issues certificates for gateway instances + +This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain. + +### Certificate Hierarchy +``` +Root CA (Organization Specific) +├── Client CA +│ └── Client Certificates (Cloud Components) +└── Gateway CA + └── Gateway Certificates (Gateway Instances) +``` + +## Communication Security + +### 1. Gateway Registration +When a gateway is first deployed: + +1. Establishes initial connection using machine identity token +2. Allocates a relay address for communication +3. Exchanges certificates through a secure handshake: + - Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification + +### 2. Mutual TLS Authentication +All communication between gateway and cloud uses mutual TLS (mTLS): + +- **Gateway Authentication**: + - Presents certificate signed by organization's Gateway CA + - Certificate contains unique identifiers (Organization ID, Gateway ID) + - Cloud validates complete certificate chain + +- **Cloud Authentication**: + - Presents certificate signed by organization's Client CA + - Certificate includes required organizational unit ("gateway-client") + - Gateway validates certificate chain back to organization's root CA + +### 3. Relay Communication +The relay system provides secure tunneling: + +1. **Connection Establishment**: + - Uses QUIC protocol over UDP for efficient, secure communication + - Provides built-in encryption, congestion control, and multiplexing + - Enables faster connection establishment and reduced latency + - Each organization's traffic is isolated using separate relay sessions + +2. **Traffic Isolation**: + - Each gateway gets unique relay credentials + - Traffic is end-to-end encrypted using QUIC's TLS 1.3 + - Organization's private keys never leave their environment + +## Tenant Isolation + +### Certificate-Based Isolation +- Each organization has unique root CA and intermediate CAs +- Certificates contain organization-specific identifiers +- Cross-tenant communication is cryptographically impossible + +### Gateway-Project Mapping +- Gateways are explicitly mapped to specific projects +- Access controls enforce organization boundaries +- Project-level permissions determine resource accessibility + +### Resource Access Control +1. **Project Verification**: + - Gateway verifies project membership + - Validates organization ownership + - Enforces project-level permissions + +2. **Resource Restrictions**: + - Gateways only accept connections to approved resources + - Each connection requires explicit project authorization + - Resources remain private to their assigned organization + +## Security Measures + +### Certificate Lifecycle +- Certificates have limited validity periods +- Automatic certificate rotation +- Immediate certificate revocation capabilities + +### Monitoring and Verification +1. **Continuous Verification**: + - Regular heartbeat checks + - Certificate chain validation + - Connection state monitoring + +2. **Security Controls**: + - Automatic connection termination on verification failure + - Audit logging of all access attempts + - Machine identity based authentication + 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": "" +} +``` diff --git a/docs/mint.json b/docs/mint.json index 480337dc5..f604ff310 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -203,7 +203,7 @@ }, { "group": "Gateway", - "pages": ["documentation/platform/gateways/overview"] + "pages": ["documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-security"] }, "documentation/platform/project-templates", { 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..0f88ec2e9 100644 --- a/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx +++ b/frontend/src/components/project/ProjectOverviewChangeSection.tsx @@ -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,31 +18,55 @@ 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-z0-9]+(?:[_-][a-z0-9]+)*$/, + "Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore." + ) +}); -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 { mutateAsync, isPending } = useUpdateProject(); + const { handleSubmit, control, reset, watch } = useForm({ + resolver: zodResolver(showSlugField ? formSchemaWithSlug : baseFormSchema) + }); - const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); + const currentSlug = showSlugField ? watch("slug") : currentWorkspace?.slug; useEffect(() => { if (currentWorkspace) { reset({ name: currentWorkspace.name, - description: currentWorkspace.description ?? "" + description: currentWorkspace.description ?? "", + ...(showSlugField && { slug: 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 + }) }); createNotification({ @@ -65,20 +87,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 +149,38 @@ export const ProjectOverviewChangeSection = () => {
+ {showSlugField && ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + control={control} + name="slug" + /> + )} + +
+
+ )}
{ 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 - -
- )} -
- )} - /> -
-
+
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>; + }[]; + }; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 60867fcf4..0b154b78b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -251,12 +251,13 @@ export const useUpdateProject = () => { 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) => ( - ( - -