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/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 9dfb38aeb..46b322349 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -120,4 +120,3 @@ export default { }; } }; - diff --git a/backend/package.json b/backend/package.json index 5c2c805bc..25380f896 100644 --- a/backend/package.json +++ b/backend/package.json @@ -71,6 +71,7 @@ "migrate:org": "tsx ./scripts/migrate-organization.ts", "seed:new": "tsx ./scripts/create-seed-file.ts", "seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run", + "seed-dev": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", "db:reset": "npm run migration:rollback -- --all && npm run migration:latest" }, "keywords": [], diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 653d04d4f..1ceb6019b 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -1,16 +1,11 @@ import { z } from "zod"; -import { - SecretApprovalRequestsReviewersSchema, - SecretApprovalRequestsSchema, - SecretTagsSchema, - UsersSchema -} from "@app/db/schemas"; +import { SecretApprovalRequestsReviewersSchema, SecretApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApprovalStatus, RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { secretRawSchema } from "@app/server/routes/sanitizedSchemas"; +import { SanitizedTagSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; @@ -250,14 +245,6 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv } }); - const tagSchema = SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .array() - .optional(); - server.route({ method: "GET", url: "/:id", @@ -291,7 +278,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv .omit({ _id: true, environment: true, workspace: true, type: true, version: true }) .extend({ op: z.string(), - tags: tagSchema, + tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.nullish(), secret: z .object({ @@ -310,7 +297,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv secretKey: z.string(), secretValue: z.string().optional(), secretComment: z.string().optional(), - tags: tagSchema, + tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.nullish() }) .optional() diff --git a/backend/src/ee/routes/v1/secret-router.ts b/backend/src/ee/routes/v1/secret-router.ts index 4c249afe0..a964eb1b8 100644 --- a/backend/src/ee/routes/v1/secret-router.ts +++ b/backend/src/ee/routes/v1/secret-router.ts @@ -1,6 +1,6 @@ import z from "zod"; -import { ProjectPermissionActions } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { RAW_SECRETS } from "@app/lib/api-docs"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -9,7 +9,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; const AccessListEntrySchema = z .object({ - allowedActions: z.nativeEnum(ProjectPermissionActions).array(), + allowedActions: z.nativeEnum(ProjectPermissionSecretActions).array(), id: z.string(), membershipId: z.string(), name: z.string() diff --git a/backend/src/ee/routes/v1/secret-version-router.ts b/backend/src/ee/routes/v1/secret-version-router.ts index 11443ebfe..a09a05c91 100644 --- a/backend/src/ee/routes/v1/secret-version-router.ts +++ b/backend/src/ee/routes/v1/secret-version-router.ts @@ -22,7 +22,11 @@ export const registerSecretVersionRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - secretVersions: secretRawSchema.array() + secretVersions: secretRawSchema + .extend({ + secretValueHidden: z.boolean() + }) + .array() }) } }, @@ -37,6 +41,7 @@ export const registerSecretVersionRouter = async (server: FastifyZodProvider) => offset: req.query.offset, secretId: req.params.secretId }); + return { secretVersions }; } }); diff --git a/backend/src/ee/routes/v1/snapshot-router.ts b/backend/src/ee/routes/v1/snapshot-router.ts index fc2d25712..283b9b31e 100644 --- a/backend/src/ee/routes/v1/snapshot-router.ts +++ b/backend/src/ee/routes/v1/snapshot-router.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { SecretSnapshotsSchema, SecretTagsSchema } from "@app/db/schemas"; +import { SecretSnapshotsSchema } from "@app/db/schemas"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { secretRawSchema } from "@app/server/routes/sanitizedSchemas"; +import { SanitizedTagSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSnapshotRouter = async (server: FastifyZodProvider) => { @@ -31,12 +31,9 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { secretVersions: secretRawSchema .omit({ _id: true, environment: true, workspace: true, type: true }) .extend({ + secretValueHidden: z.boolean(), secretId: z.string(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }).array() + tags: SanitizedTagSchema.array() }) .array(), folderVersion: z.object({ id: z.string(), name: z.string() }).array(), @@ -55,6 +52,7 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, id: req.params.secretSnapshotId }); + return { secretSnapshot }; } }); diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index de37a4cde..f8b4e922c 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -2,6 +2,7 @@ import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; +import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; @@ -23,7 +24,9 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr body: z.object({ projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), slug: slugSchema({ min: 1, max: 60 }).optional().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionV2Schema.array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: ProjectPermissionV2Schema.array() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .refine(checkForInvalidPermissionCombination), type: z.discriminatedUnion("isTemporary", [ z.object({ isTemporary: z.literal(false) @@ -81,7 +84,8 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr slug: slugSchema({ min: 1, max: 60 }).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions) + .refine(checkForInvalidPermissionCombination), type: z.discriminatedUnion("isTemporary", [ z.object({ isTemporary: z.literal(false).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary) }), z.object({ diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index d9c3a05b5..7cc9155cd 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -3,6 +3,7 @@ import ms from "ms"; import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-types"; +import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -30,7 +31,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.identityId), projectId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.projectId), slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), - permissions: ProjectPermissionV2Schema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission), + permissions: ProjectPermissionV2Schema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission) + .refine(checkForInvalidPermissionCombination), type: z.discriminatedUnion("isTemporary", [ z.object({ isTemporary: z.literal(false) @@ -94,7 +97,8 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission), + .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission) + .refine(checkForInvalidPermissionCombination), type: z.discriminatedUnion("isTemporary", [ z.object({ isTemporary: z.literal(false).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.isTemporary) }), z.object({ diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index 2d3b1984d..0bb83b8d4 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -2,6 +2,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; +import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -37,7 +38,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description), - permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.CREATE.permissions) + permissions: ProjectPermissionV2Schema.array() + .describe(PROJECT_ROLE.CREATE.permissions) + .refine(checkForInvalidPermissionCombination) }), response: { 200: z.object({ @@ -92,7 +95,10 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description), - permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() + permissions: ProjectPermissionV2Schema.array() + .describe(PROJECT_ROLE.UPDATE.permissions) + .optional() + .superRefine(checkForInvalidPermissionCombination) }), response: { 200: z.object({ diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 08e7b866e..3c88fd12d 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -1,12 +1,110 @@ -import { MongoAbility } from "@casl/ability"; +/* eslint-disable no-nested-ternary */ +import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability"; +import { z } from "zod"; import { TOrganizations } from "@app/db/schemas"; import { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; import { OrgPermissionSet } from "./org-permission"; -import { ProjectPermissionSet } from "./project-permission"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSet, + ProjectPermissionSub, + ProjectPermissionV2Schema, + SecretSubjectFields +} from "./project-permission"; + +export function throwIfMissingSecretReadValueOrDescribePermission( + permission: MongoAbility | PureAbility, + action: Extract< + ProjectPermissionSecretActions, + ProjectPermissionSecretActions.ReadValue | ProjectPermissionSecretActions.DescribeSecret + >, + subjectFields?: SecretSubjectFields +) { + try { + if (subjectFields) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.DescribeAndReadValue, + subject(ProjectPermissionSub.Secrets, subjectFields) + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSub.Secrets + ); + } + } catch { + if (subjectFields) { + ForbiddenError.from(permission).throwUnlessCan(action, subject(ProjectPermissionSub.Secrets, subjectFields)); + } else { + ForbiddenError.from(permission).throwUnlessCan(action, ProjectPermissionSub.Secrets); + } + } +} + +export function hasSecretReadValueOrDescribePermission( + permission: MongoAbility, + action: Extract< + ProjectPermissionSecretActions, + ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue + >, + subjectFields?: SecretSubjectFields +) { + let canNewPermission = false; + let canOldPermission = false; + + if (subjectFields) { + canNewPermission = permission.can(action, subject(ProjectPermissionSub.Secrets, subjectFields)); + canOldPermission = permission.can( + ProjectPermissionSecretActions.DescribeAndReadValue, + subject(ProjectPermissionSub.Secrets, subjectFields) + ); + } else { + canNewPermission = permission.can(action, ProjectPermissionSub.Secrets); + canOldPermission = permission.can( + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSub.Secrets + ); + } + + return canNewPermission || canOldPermission; +} + +const OptionalArrayPermissionSchema = ProjectPermissionV2Schema.array().optional(); +export function checkForInvalidPermissionCombination(permissions: z.infer) { + if (!permissions) return; + + for (const permission of permissions) { + if (permission.subject === ProjectPermissionSub.Secrets) { + if (permission.action.includes(ProjectPermissionSecretActions.DescribeAndReadValue)) { + const hasReadValue = permission.action.includes(ProjectPermissionSecretActions.ReadValue); + const hasDescribeSecret = permission.action.includes(ProjectPermissionSecretActions.DescribeSecret); + + // eslint-disable-next-line no-continue + if (!hasReadValue && !hasDescribeSecret) continue; + + const hasBothDescribeAndReadValue = hasReadValue && hasDescribeSecret; + + throw new BadRequestError({ + message: `You have selected Read, and ${ + hasBothDescribeAndReadValue + ? "both Read Value and Describe Secret" + : hasReadValue + ? "Read Value" + : hasDescribeSecret + ? "Describe Secret" + : "" + }. You cannot select Read Value or Describe Secret if you have selected Read. The Read permission is a legacy action which has been replaced by Describe Secret and Read Value.` + }); + } + } + } + + return true; +} function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { if (!actorAuthMethod) return false; @@ -54,13 +152,13 @@ const escapeHandlebarsMissingMetadata = (obj: Record) => { // the new privilege management system is based on the actor having the appropriate permission to perform the privilege change, // regardless of the actor's privilege level. const validatePrivilegeChangeOperation = ( - action: OrgPermissionSet[0] | ProjectPermissionSet[0], - subject: OrgPermissionSet[1] | ProjectPermissionSet[1], + opAction: OrgPermissionSet[0] | ProjectPermissionSet[0], + opSubject: OrgPermissionSet[1] | ProjectPermissionSet[1], actorPermission: MongoAbility, managedPermission: MongoAbility ) => { // first we ensure if the actor has the permission to manage the privilege - if (actorPermission.can(action, subject)) { + if (actorPermission.can(opAction, opSubject)) { return { isValid: true, missingPermissions: [] diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index c5c731dc7..95d16e473 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -17,6 +17,15 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum ProjectPermissionSecretActions { + DescribeAndReadValue = "read", + DescribeSecret = "describeSecret", + ReadValue = "readValue", + Create = "create", + Edit = "edit", + Delete = "delete" +} + export enum ProjectPermissionCmekActions { Read = "read", Create = "create", @@ -139,7 +148,7 @@ export type IdentityManagementSubjectFields = { export type ProjectPermissionSet = | [ - ProjectPermissionActions, + ProjectPermissionSecretActions, ProjectPermissionSub.Secrets | (ForcedSubject & SecretSubjectFields) ] | [ @@ -453,6 +462,7 @@ const GeneralPermissionSchema = [ }) ]; +// Do not update this schema anymore, as it's kept purely for backwards compatability. Update V2 schema only. export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ z.object({ subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), @@ -484,7 +494,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ z.object({ subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretActions).describe( "Describe what action an entity can take." ), conditions: SecretConditionV2Schema.describe( @@ -541,7 +551,6 @@ const buildAdminPermissionRules = () => { // Admins get full access to everything [ - ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretApproval, @@ -571,7 +580,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionActions.Create, ProjectPermissionActions.Delete ], - el as ProjectPermissionSub + el ); }); @@ -608,6 +617,18 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Identity ); + can( + [ + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ], + ProjectPermissionSub.Secrets + ); + can( [ ProjectPermissionDynamicSecretActions.ReadRootCredential, @@ -667,10 +688,12 @@ const buildMemberPermissionRules = () => { can( [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -842,7 +865,9 @@ export const projectMemberPermissions = buildMemberPermissionRules(); const buildViewerPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSub.Secrets); + can(ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSub.Secrets); + can(ProjectPermissionSecretActions.ReadValue, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); @@ -891,7 +916,6 @@ export const buildServiceTokenProjectPermission = ( (subject) => { if (canWrite) { can(ProjectPermissionActions.Edit, subject, { - // TODO: @Akhi // @ts-expect-error type secretPath: { $glob: secretPath }, environment @@ -970,7 +994,17 @@ export const backfillPermissionV1SchemaToV2Schema = ( subject: ProjectPermissionSub.SecretImports as const })); + const secretPolicies = secretSubjects.map(({ subject, ...el }) => ({ + subject: ProjectPermissionSub.Secrets as const, + ...el, + action: + el.action.includes(ProjectPermissionActions.Read) && !el.action.includes(ProjectPermissionSecretActions.ReadValue) + ? el.action.concat(ProjectPermissionSecretActions.ReadValue) + : el.action + })); + const secretFolderPolicies = secretSubjects + .map(({ subject, ...el }) => ({ ...el, // read permission is not needed anymore @@ -1012,6 +1046,7 @@ export const backfillPermissionV1SchemaToV2Schema = ( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts secretImportPolicies, + secretPolicies, dynamicSecretPolicies, hasReadOnlyFolder.length ? [] : secretFolderPolicies ); 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 d296d7975..72d36c26b 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 @@ -58,7 +58,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; @@ -77,6 +77,7 @@ import { TSecretApprovalDetailsDTO, TStatusChangeDTO } from "./secret-approval-request-types"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; type TSecretApprovalRequestServiceFactoryDep = { permissionService: Pick; @@ -88,7 +89,12 @@ type TSecretApprovalRequestServiceFactoryDep = { secretDAL: TSecretDALFactory; secretTagDAL: Pick< TSecretTagDALFactory, - "findManyTagsById" | "saveTagsToSecret" | "deleteTagsManySecret" | "saveTagsToSecretV2" | "deleteTagsToSecretV2" + | "findManyTagsById" + | "saveTagsToSecret" + | "deleteTagsManySecret" + | "saveTagsToSecretV2" + | "deleteTagsToSecretV2" + | "find" >; secretBlindIndexDAL: Pick; snapshotService: Pick; @@ -106,7 +112,7 @@ type TSecretApprovalRequestServiceFactoryDep = { kmsService: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" + "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" | "find" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; @@ -912,10 +918,11 @@ export const secretApprovalRequestServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); + + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath + }); await projectDAL.checkProjectUpgradeStatus(projectId); @@ -1000,6 +1007,7 @@ export const secretApprovalRequestServiceFactory = ({ : keyName2BlindIndex[secretName]; // add tags if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds; + return { ...latestSecretVersions[secretId], ...el, @@ -1363,9 +1371,9 @@ export const secretApprovalRequestServiceFactory = ({ const tagsGroupById = groupBy(tags, (i) => i.id); commits.forEach((commit) => { - let action = ProjectPermissionActions.Create; - if (commit.op === SecretOperations.Update) action = ProjectPermissionActions.Edit; - if (commit.op === SecretOperations.Delete) action = ProjectPermissionActions.Delete; + let action = ProjectPermissionSecretActions.Create; + if (commit.op === SecretOperations.Update) action = ProjectPermissionSecretActions.Edit; + if (commit.op === SecretOperations.Delete) action = ProjectPermissionSecretActions.Delete; ForbiddenError.from(permission).throwUnlessCan( action, diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 3c25db98c..5fae2675d 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -265,6 +265,7 @@ export const secretReplicationServiceFactory = ({ folderDAL, secretImportDAL, decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), + viewSecretValue: true, hasSecretAccess: () => true }); // secrets that gets replicated across imports diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts index 02da4b7ea..8d458111a 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -15,7 +15,11 @@ import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "../permission/project-permission"; import { TSecretRotationDALFactory } from "./secret-rotation-dal"; import { TSecretRotationQueueFactory } from "./secret-rotation-queue"; import { TSecretRotationEncData } from "./secret-rotation-queue/secret-rotation-queue-types"; @@ -106,7 +110,7 @@ export const secretRotationServiceFactory = ({ }); } ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index dc2cff456..5fbb3f598 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument */ // akhilmhdh: I did this, quite strange bug with eslint. Everything do have a type stil has this error -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, TableName, TSecretTagJunctionInsert, TSecretV2TagJunctionInsert } from "@app/db/schemas"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; @@ -12,6 +12,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "@app/services/secret/secret-fns"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; @@ -22,8 +23,16 @@ import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secre import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { TLicenseServiceFactory } from "../license/license-service"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "../permission/project-permission"; import { TGetSnapshotDataDTO, TProjectSnapshotCountDTO, @@ -97,10 +106,10 @@ export const secretSnapshotServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); // We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder. - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath: path + }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) { @@ -134,10 +143,10 @@ export const secretSnapshotServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); // We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder. - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath: path + }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) @@ -162,6 +171,7 @@ export const secretSnapshotServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + const shouldUseBridge = snapshot.projectVersion === 3; let snapshotDetails; if (shouldUseBridge) { @@ -170,68 +180,112 @@ export const secretSnapshotServiceFactory = ({ projectId: snapshot.projectId }); const encryptedSnapshotDetails = await snapshotDAL.findSecretSnapshotV2DataById(id); + + const fullFolderPath = await getFullFolderPath({ + folderDAL, + folderId: encryptedSnapshotDetails.folderId, + envId: encryptedSnapshotDetails.environment.id + }); + snapshotDetails = { ...encryptedSnapshotDetails, - secretVersions: encryptedSnapshotDetails.secretVersions.map((el) => ({ - ...el, - secretKey: el.key, - secretValue: el.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : "", - secretComment: el.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() - : "" - })) + secretVersions: encryptedSnapshotDetails.secretVersions.map((el) => { + const canReadValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: encryptedSnapshotDetails.environment.slug, + secretPath: fullFolderPath, + secretName: el.key, + secretTags: el.tags.length ? el.tags.map((tag) => tag.slug) : undefined + } + ); + + let secretValue = ""; + if (canReadValue) { + secretValue = el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : ""; + } else { + secretValue = INFISICAL_SECRET_VALUE_HIDDEN_MASK; + } + + return { + ...el, + secretKey: el.key, + secretValueHidden: !canReadValue, + secretValue, + secretComment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "" + }; + }) }; } else { const encryptedSnapshotDetails = await snapshotDAL.findSecretSnapshotDataById(id); + + const fullFolderPath = await getFullFolderPath({ + folderDAL, + folderId: encryptedSnapshotDetails.folderId, + envId: encryptedSnapshotDetails.environment.id + }); + const { botKey } = await projectBotService.getBotKey(snapshot.projectId); if (!botKey) throw new NotFoundError({ message: `Project bot key not found for project with ID '${snapshot.projectId}'` }); snapshotDetails = { ...encryptedSnapshotDetails, - secretVersions: encryptedSnapshotDetails.secretVersions.map((el) => ({ - ...el, - secretKey: decryptSymmetric128BitHexKeyUTF8({ + secretVersions: encryptedSnapshotDetails.secretVersions.map((el) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: el.secretKeyCiphertext, iv: el.secretKeyIV, tag: el.secretKeyTag, key: botKey - }), - secretValue: decryptSymmetric128BitHexKeyUTF8({ - ciphertext: el.secretValueCiphertext, - iv: el.secretValueIV, - tag: el.secretValueTag, - key: botKey - }), - secretComment: - el.secretCommentTag && el.secretCommentIV && el.secretCommentCiphertext - ? decryptSymmetric128BitHexKeyUTF8({ - ciphertext: el.secretCommentCiphertext, - iv: el.secretCommentIV, - tag: el.secretCommentTag, - key: botKey - }) - : "" - })) + }); + + const canReadValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: encryptedSnapshotDetails.environment.slug, + secretPath: fullFolderPath, + secretName: secretKey, + secretTags: el.tags.length ? el.tags.map((tag) => tag.slug) : undefined + } + ); + + let secretValue = ""; + + if (canReadValue) { + secretValue = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag, + key: botKey + }); + } else { + secretValue = INFISICAL_SECRET_VALUE_HIDDEN_MASK; + } + + return { + ...el, + secretKey, + secretValueHidden: !canReadValue, + secretValue, + secretComment: + el.secretCommentTag && el.secretCommentIV && el.secretCommentCiphertext + ? decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.secretCommentCiphertext, + iv: el.secretCommentIV, + tag: el.secretCommentTag, + key: botKey + }) + : "" + }; + }) }; } - const fullFolderPath = await getFullFolderPath({ - folderDAL, - folderId: snapshotDetails.folderId, - envId: snapshotDetails.environment.id - }); - - // We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder. - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: snapshotDetails.environment.slug, - secretPath: fullFolderPath - }) - ); - return snapshotDetails; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1b458175d..fe893d5ab 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." @@ -666,6 +667,7 @@ export const SECRETS = { secretPath: "The path of the secret to attach tags to.", type: "The type of the secret to attach tags to. (shared/personal)", environment: "The slug of the environment where the secret is located", + viewSecretValue: "Whether or not to retrieve the secret value.", projectSlug: "The slug of the project where the secret is located.", tagSlugs: "An array of existing tag slugs to attach to the secret." }, @@ -689,6 +691,7 @@ export const RAW_SECRETS = { "The slug of the project to list secrets from. This parameter is only applicable by machine identities.", environment: "The slug of the environment to list secrets from.", secretPath: "The secret path to list secrets from.", + viewSecretValue: "Whether or not to retrieve the secret value.", includeImports: "Weather to include imported secrets or not.", tagSlugs: "The comma separated tag slugs to filter secrets.", metadataFilter: @@ -717,6 +720,7 @@ export const RAW_SECRETS = { secretPath: "The path of the secret to get.", version: "The version of the secret to get.", type: "The type of the secret to get.", + viewSecretValue: "Whether or not to retrieve the secret value.", includeImports: "Weather to include imported secrets or not." }, UPDATE: { diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index f3dc83f26..950bfd29f 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -1,4 +1,5 @@ /* eslint-disable max-classes-per-file */ + export class DatabaseError extends Error { name: string; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index a6cc8bae0..77149eb1b 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -7,6 +7,7 @@ import { ProjectRolesSchema, ProjectsSchema, SecretApprovalPoliciesSchema, + SecretTagsSchema, UsersSchema } from "@app/db/schemas"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -241,3 +242,11 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ kmsCertificateKeyId: true, auditLogsRetentionDays: true }); + +export const SanitizedTagSchema = SecretTagsSchema.pick({ + id: true, + slug: true, + color: true +}).extend({ + name: z.string() +}); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 076b33e54..b63550d2c 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -118,7 +118,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { querystring: z.object({ searchTerm: z.string().default(""), offset: z.coerce.number().default(0), - limit: z.coerce.number().max(100).default(20) + limit: z.coerce.number().max(100).default(20), + // TODO: remove this once z.coerce.boolean() is supported + adminsOnly: z + .string() + .transform((val) => val === "true") + .default("false") }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index d96c45f04..db61594a2 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1,10 +1,11 @@ import { ForbiddenError, subject } from "@casl/ability"; import { z } from "zod"; -import { ActionProjectType, SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas"; +import { ActionProjectType, SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionDynamicSecretActions, + ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { DASHBOARD } from "@app/lib/api-docs"; @@ -15,7 +16,7 @@ import { secretsLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { SanitizedDynamicSecretSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; +import { SanitizedDynamicSecretSchema, SanitizedTagSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; @@ -116,16 +117,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(), secrets: secretRawSchema .extend({ + secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional() + tags: SanitizedTagSchema.array().optional() }) .array() .optional(), @@ -294,6 +289,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { secrets = await server.services.secret.getSecretsRawMultiEnv({ + viewSecretValue: true, actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, @@ -393,6 +389,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(), tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(), + viewSecretValue: booleanSchema.default(true), includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), @@ -410,16 +407,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(), secrets: secretRawSchema .extend({ + secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional() + tags: SanitizedTagSchema.array().optional() }) .array() .optional(), @@ -601,23 +592,25 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { - const secretsRaw = await server.services.secret.getSecretsRaw({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - environment, - actorAuthMethod: req.permission.authMethod, - projectId, - path: secretPath, - orderBy, - orderDirection, - search, - limit: remainingLimit, - offset: adjustedOffset, - tagSlugs: tags - }); - - secrets = secretsRaw.secrets; + secrets = ( + await server.services.secret.getSecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + viewSecretValue: req.query.viewSecretValue, + throwOnMissingReadValuePermission: false, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset, + tagSlugs: tags + }) + ).secrets; await server.services.auditLog.createAuditLog({ projectId, @@ -696,16 +689,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), secrets: secretRawSchema .extend({ + secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional() + tags: SanitizedTagSchema.array().optional() }) .array() .optional() @@ -749,6 +736,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { const secrets = await server.services.secret.getSecretsRawByFolderMappings( { + filterByAction: ProjectPermissionSecretActions.DescribeSecret, projectId, folderMappings, filters: { @@ -846,6 +834,52 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/accessible-secrets", + config: { + rateLimit: secretsLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + filterByAction: z + .enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue]) + .default(ProjectPermissionSecretActions.ReadValue) + }), + response: { + 200: z.object({ + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + secretValueHidden: z.boolean() + }) + .array() + .optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { projectId, environment, secretPath, filterByAction } = req.query; + + const { secrets } = await server.services.secret.getAccessibleSecrets({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + environment, + secretPath, + projectId, + filterByAction + }); + + return { secrets }; + } + }); + server.route({ method: "GET", url: "/secrets-by-keys", @@ -862,22 +896,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - keys: z.string().trim().transform(decodeURIComponent) + keys: z.string().trim().transform(decodeURIComponent), + viewSecretValue: booleanSchema.default(false) }), response: { 200: z.object({ secrets: secretRawSchema .extend({ + secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional() + tags: SanitizedTagSchema.array().optional() }) .array() .optional() @@ -886,7 +915,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { secretPath, projectId, environment } = req.query; + const { secretPath, projectId, environment, viewSecretValue } = req.query; const keys = req.query.keys?.split(",").filter((key) => Boolean(key.trim())) ?? []; if (!keys.length) throw new BadRequestError({ message: "One or more keys required" }); @@ -895,6 +924,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, + viewSecretValue, environment, actorAuthMethod: req.permission.authMethod, projectId, diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index e96a577d9..724468e02 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -6,6 +6,7 @@ import { authRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { validateSignUpAuthorization } from "@app/services/auth/auth-fns"; import { AuthMode } from "@app/services/auth/auth-type"; +import { UserEncryption } from "@app/services/user/user-types"; export const registerPasswordRouter = async (server: FastifyZodProvider) => { server.route({ @@ -113,20 +114,16 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - message: z.string(), user: UsersSchema, - token: z.string() + token: z.string(), + userEncryptionVersion: z.nativeEnum(UserEncryption) }) } }, handler: async (req) => { - const { token, user } = await server.services.password.verifyPasswordResetEmail(req.body.email, req.body.code); + const passwordReset = await server.services.password.verifyPasswordResetEmail(req.body.email, req.body.code); - return { - message: "Successfully verified email", - user, - token - }; + return passwordReset; } }); 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/v2/index.ts b/backend/src/server/routes/v2/index.ts index 3d7581a70..cece502da 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -3,6 +3,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; +import { registerPasswordRouter } from "./password-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerServiceTokenRouter } from "./service-token-router"; @@ -12,6 +13,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerMfaRouter, { prefix: "/auth" }); await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); + await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register( async (orgRouter) => { await orgRouter.register(registerOrgRouter); diff --git a/backend/src/server/routes/v2/password-router.ts b/backend/src/server/routes/v2/password-router.ts new file mode 100644 index 000000000..165130dec --- /dev/null +++ b/backend/src/server/routes/v2/password-router.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { authRateLimit } from "@app/server/config/rateLimiter"; +import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type"; + +export const registerPasswordRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/password-reset", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + newPassword: z.string().trim() + }) + }, + handler: async (req) => { + const token = validatePasswordResetAuthorization(req.headers.authorization); + await server.services.password.resetPasswordV2({ + type: ResetPasswordV2Type.Recovery, + newPassword: req.body.newPassword, + userId: token.userId + }); + } + }); + + server.route({ + method: "POST", + url: "/user/password-reset", + schema: { + body: z.object({ + oldPassword: z.string().trim(), + newPassword: z.string().trim() + }) + }, + config: { + rateLimit: authRateLimit + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + await server.services.password.resetPasswordV2({ + type: ResetPasswordV2Type.LoggedInReset, + userId: req.permission.id, + newPassword: req.body.newPassword, + oldPassword: req.body.oldPassword + }); + } + }); +}; diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 4935345dc..f854baade 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -1,13 +1,7 @@ import picomatch from "picomatch"; import { z } from "zod"; -import { - SecretApprovalRequestsSchema, - SecretsSchema, - SecretTagsSchema, - SecretType, - ServiceTokenScopes -} from "@app/db/schemas"; +import { SecretApprovalRequestsSchema, SecretsSchema, SecretType, ServiceTokenScopes } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -23,7 +17,7 @@ import { SecretOperations, SecretProtectionType } from "@app/services/secret/sec import { SecretUpdateMode } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; -import { secretRawSchema } from "../sanitizedSchemas"; +import { SanitizedTagSchema, secretRawSchema } from "../sanitizedSchemas"; const SecretReferenceNode = z.object({ key: z.string(), @@ -31,6 +25,14 @@ const SecretReferenceNode = z.object({ environment: z.string(), secretPath: z.string() }); + +const convertStringBoolean = (defaultValue: boolean = false) => { + return z + .enum(["true", "false"]) + .default(defaultValue ? "true" : "false") + .transform((value) => value === "true"); +}; + type TSecretReferenceNode = z.infer & { children: TSecretReferenceNode[] }; const SecretReferenceNodeTree: z.ZodType = SecretReferenceNode.extend({ @@ -75,17 +77,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( - z.object({ - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - }) - ) + secret: SecretsSchema.omit({ secretBlindIndex: true }).extend({ + tags: SanitizedTagSchema.array() + }) }) } }, @@ -139,13 +133,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ secret: SecretsSchema.omit({ secretBlindIndex: true }).extend({ - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() + tags: SanitizedTagSchema.array() }) }) } @@ -247,21 +235,10 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), - expandSecretReferences: z - .enum(["true", "false"]) - .default("false") - .transform((value) => value === "true") - .describe(RAW_SECRETS.LIST.expand), - recursive: z - .enum(["true", "false"]) - .default("false") - .transform((value) => value === "true") - .describe(RAW_SECRETS.LIST.recursive), - include_imports: z - .enum(["true", "false"]) - .default("false") - .transform((value) => value === "true") - .describe(RAW_SECRETS.LIST.includeImports), + viewSecretValue: convertStringBoolean(true).describe(RAW_SECRETS.LIST.viewSecretValue), + expandSecretReferences: convertStringBoolean().describe(RAW_SECRETS.LIST.expand), + recursive: convertStringBoolean().describe(RAW_SECRETS.LIST.recursive), + include_imports: convertStringBoolean().describe(RAW_SECRETS.LIST.includeImports), tagSlugs: z .string() .describe(RAW_SECRETS.LIST.tagSlugs) @@ -274,15 +251,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secrets: secretRawSchema .extend({ secretPath: z.string().optional(), + secretValueHidden: z.boolean(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional() + tags: SanitizedTagSchema.array().optional() }) .array(), imports: z @@ -293,6 +264,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secrets: secretRawSchema .omit({ createdAt: true, updatedAt: true }) .extend({ + secretValueHidden: z.boolean(), secretMetadata: ResourceMetadataSchema.optional() }) .array() @@ -342,6 +314,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { expandSecretReferences: req.query.expandSecretReferences, actorAuthMethod: req.permission.authMethod, projectId: workspaceId, + viewSecretValue: req.query.viewSecretValue, path: secretPath, metadataFilter: req.query.metadataFilter, includeImports: req.query.include_imports, @@ -376,6 +349,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); } + return { secrets, imports }; } }); @@ -394,14 +368,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { 200: z.object({ secret: secretRawSchema.extend({ secretPath: z.string(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional(), + tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.optional() }) }) @@ -445,28 +412,15 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), version: z.coerce.number().optional().describe(RAW_SECRETS.GET.version), type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.GET.type), - expandSecretReferences: z - .enum(["true", "false"]) - .default("false") - .transform((value) => value === "true") - .describe(RAW_SECRETS.GET.expand), - include_imports: z - .enum(["true", "false"]) - .default("false") - .transform((value) => value === "true") - .describe(RAW_SECRETS.GET.includeImports) + viewSecretValue: convertStringBoolean(true).describe(RAW_SECRETS.GET.viewSecretValue), + expandSecretReferences: convertStringBoolean().describe(RAW_SECRETS.GET.expand), + include_imports: convertStringBoolean().describe(RAW_SECRETS.GET.includeImports) }), response: { 200: z.object({ secret: secretRawSchema.extend({ - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() - .optional(), + secretValueHidden: z.boolean(), + tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.optional() }) }) @@ -498,6 +452,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { expandSecretReferences: req.query.expandSecretReferences, environment, projectId: workspaceId, + viewSecretValue: req.query.viewSecretValue, projectSlug: workspaceSlug, path: secretPath, secretName: req.params.secretName, @@ -704,7 +659,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secret: secretRawSchema + secret: secretRawSchema.extend({ + secretValueHidden: z.boolean() + }) }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) @@ -800,7 +757,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secret: secretRawSchema + secret: secretRawSchema.extend({ + secretValueHidden: z.boolean() + }) }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) @@ -822,6 +781,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { if (secretOperation.type === SecretProtectionType.Approval) { return { approval: secretOperation.approval }; } + const { secret } = secretOperation; await server.services.auditLog.createAuditLog({ @@ -884,13 +844,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { workspace: z.string(), environment: z.string(), secretPath: z.string().optional(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - color: true - }) - .extend({ name: z.string() }) - .array() + tags: SanitizedTagSchema.array() }) .array(), imports: z @@ -986,10 +940,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretPath: z.string().trim().default("/").transform(removeTrailingSlash), type: z.nativeEnum(SecretType).default(SecretType.Shared), version: z.coerce.number().optional(), - include_imports: z - .enum(["true", "false"]) - .default("false") - .transform((value) => value === "true") + include_imports: convertStringBoolean() }), response: { 200: z.object({ @@ -1260,6 +1211,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { z.object({ secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( z.object({ + secretValueHidden: z.boolean(), _id: z.string(), workspace: z.string(), environment: z.string() @@ -1429,13 +1381,12 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( - z.object({ - _id: z.string(), - workspace: z.string(), - environment: z.string() - }) - ) + secret: SecretsSchema.omit({ secretBlindIndex: true }).extend({ + _id: z.string(), + secretValueHidden: z.boolean(), + workspace: z.string(), + environment: z.string() + }) }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) @@ -1747,7 +1698,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + secrets: SecretsSchema.omit({ secretBlindIndex: true }).extend({ secretValueHidden: z.boolean() }).array() }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) @@ -1862,7 +1813,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() + secrets: SecretsSchema.omit({ secretBlindIndex: true }) + .extend({ + secretValueHidden: z.boolean() + }) + .array() }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) @@ -2124,7 +2079,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secrets: secretRawSchema.array() + secrets: secretRawSchema.extend({ secretValueHidden: z.boolean() }).array() }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) @@ -2246,7 +2201,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.union([ z.object({ - secrets: secretRawSchema.array() + secrets: secretRawSchema + .extend({ + secretValueHidden: z.boolean() + }) + .array() }), z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled") ]) diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 5f7aca812..ec6e0a303 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -45,6 +45,36 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid if (decodedToken.userId !== userId) throw new UnauthorizedError(); }; +export const validatePasswordResetAuthorization = (token?: string) => { + if (!token) throw new UnauthorizedError(); + + const appCfg = getConfig(); + const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>token?.split(" ", 2) ?? [null, null]; + if (AUTH_TOKEN_TYPE === null) { + throw new UnauthorizedError({ message: "Missing Authorization Header in the request header." }); + } + if (AUTH_TOKEN_TYPE.toLowerCase() !== "bearer") { + throw new UnauthorizedError({ + message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.` + }); + } + if (AUTH_TOKEN_VALUE === null) { + throw new UnauthorizedError({ + message: "Missing Authorization Body in the request header" + }); + } + + const decodedToken = jwt.verify(AUTH_TOKEN_VALUE, appCfg.AUTH_SECRET) as AuthModeProviderSignUpTokenPayload; + + if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) { + throw new UnauthorizedError({ + message: `The provided authentication token type is not supported.` + }); + } + + return decodedToken; +}; + export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => { if (isLocked) { throw new ForbiddenRequestError({ diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 9f004eafc..f5f42a236 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -4,6 +4,8 @@ import jwt from "jsonwebtoken"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; @@ -12,14 +14,18 @@ import { TokenType } from "../auth-token/auth-token-types"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TTotpConfigDALFactory } from "../totp/totp-config-dal"; import { TUserDALFactory } from "../user/user-dal"; +import { UserEncryption } from "../user/user-types"; import { TAuthDALFactory } from "./auth-dal"; import { + ResetPasswordV2Type, TChangePasswordDTO, TCreateBackupPrivateKeyDTO, + TResetPasswordV2DTO, TResetPasswordViaBackupKeyDTO, TSetupPasswordViaBackupKeyDTO } from "./auth-password-type"; import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; +import { logger } from "@app/lib/logger"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; @@ -114,26 +120,31 @@ export const authPaswordServiceFactory = ({ * Email password reset flow via email. Step 1 send email */ const sendPasswordResetEmail = async (email: string) => { - const user = await userDAL.findUserByUsername(email); - // ignore as user is not found to avoid an outside entity to identify infisical registered accounts - if (!user || (user && !user.isAccepted)) return; + const sendEmail = async () => { + const user = await userDAL.findUserByUsername(email); - const cfg = getConfig(); - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, - userId: user.id - }); + if (user && user.isAccepted) { + const cfg = getConfig(); + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, + userId: user.id + }); - await smtpService.sendMail({ - template: SmtpTemplates.ResetPassword, - recipients: [email], - subjectLine: "Infisical password reset", - substitutions: { - email, - token, - callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" + await smtpService.sendMail({ + template: SmtpTemplates.ResetPassword, + recipients: [email], + subjectLine: "Infisical password reset", + substitutions: { + email, + token, + callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" + } + }); } - }); + }; + + // note(daniel): run in background to prevent timing attacks + void sendEmail().catch((err) => logger.error(err, "Failed to send password reset email")); }; /* @@ -142,6 +153,11 @@ export const authPaswordServiceFactory = ({ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); const user = await userDAL.findUserByUsername(email); + + const userEnc = await userDAL.findUserEncKeyByUserId(user.id); + + if (!userEnc) throw new BadRequestError({ message: "Failed to find user encryption data" }); + // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) { throw new Error("Failed email verification for pass reset"); @@ -162,8 +178,91 @@ export const authPaswordServiceFactory = ({ { expiresIn: cfg.JWT_SIGNUP_LIFETIME } ); - return { token, user }; + return { token, user, userEncryptionVersion: userEnc.encryptionVersion as UserEncryption }; }; + + const resetPasswordV2 = async ({ userId, newPassword, type, oldPassword }: TResetPasswordV2DTO) => { + const cfg = getConfig(); + + const user = await userDAL.findUserEncKeyByUserId(userId); + if (!user) { + throw new BadRequestError({ message: `User encryption key not found for user with ID '${userId}'` }); + } + + if (!user.hashedPassword) { + throw new BadRequestError({ message: "Unable to reset password, no password is set" }); + } + + if (!user.authMethods?.includes(AuthMethod.EMAIL)) { + throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" }); + } + + // we check the old password if the user is resetting their password while logged in + if (type === ResetPasswordV2Type.LoggedInReset) { + if (!oldPassword) { + throw new BadRequestError({ message: "Current password is required." }); + } + + const isValid = await bcrypt.compare(oldPassword, user.hashedPassword); + if (!isValid) { + throw new BadRequestError({ message: "Incorrect current password." }); + } + } + + const newHashedPassword = await bcrypt.hash(newPassword, cfg.BCRYPT_SALT_ROUND); + + // we need to get the original private key first for v2 + let privateKey: string; + if ( + user.serverEncryptedPrivateKey && + user.serverEncryptedPrivateKeyTag && + user.serverEncryptedPrivateKeyIV && + user.serverEncryptedPrivateKeyEncoding && + user.encryptionVersion === UserEncryption.V2 + ) { + privateKey = infisicalSymmetricDecrypt({ + iv: user.serverEncryptedPrivateKeyIV, + tag: user.serverEncryptedPrivateKeyTag, + ciphertext: user.serverEncryptedPrivateKey, + keyEncoding: user.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding + }); + } else { + throw new BadRequestError({ + message: "Cannot reset password without current credentials or recovery method", + name: "Reset password" + }); + } + + const encKeys = await generateUserSrpKeys(user.username, newPassword, { + publicKey: user.publicKey, + privateKey + }); + + const { tag, iv, ciphertext, encoding } = infisicalSymmetricEncypt(privateKey); + + await userDAL.updateUserEncryptionByUserId(userId, { + hashedPassword: newHashedPassword, + + // srp params + salt: encKeys.salt, + verifier: encKeys.verifier, + + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + + serverEncryptedPrivateKey: ciphertext, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyEncoding: encoding + }); + + await tokenService.revokeAllMySessions(userId); + }; + /* * Reset password of a user via backup key * */ @@ -391,6 +490,7 @@ export const authPaswordServiceFactory = ({ createBackupPrivateKey, getBackupPrivateKeyOfUser, sendPasswordSetupEmail, - setupPassword + setupPassword, + resetPasswordV2 }; }; diff --git a/backend/src/services/auth/auth-password-type.ts b/backend/src/services/auth/auth-password-type.ts index 7c67c0934..b3b14c3b4 100644 --- a/backend/src/services/auth/auth-password-type.ts +++ b/backend/src/services/auth/auth-password-type.ts @@ -13,6 +13,18 @@ export type TChangePasswordDTO = { password: string; }; +export enum ResetPasswordV2Type { + Recovery = "recovery", + LoggedInReset = "logged-in-reset" +} + +export type TResetPasswordV2DTO = { + type: ResetPasswordV2Type; + userId: string; + newPassword: string; + oldPassword?: string; +}; + export type TResetPasswordViaBackupKeyDTO = { userId: string; protectedKey: string; diff --git a/backend/src/services/external-migration/external-migration-fns.ts b/backend/src/services/external-migration/external-migration-fns.ts index f4a54f0db..856b39012 100644 --- a/backend/src/services/external-migration/external-migration-fns.ts +++ b/backend/src/services/external-migration/external-migration-fns.ts @@ -31,9 +31,9 @@ export type TImportDataIntoInfisicalDTO = { projectEnvDAL: Pick; kmsService: Pick; - secretDAL: Pick; + secretDAL: Pick; secretVersionDAL: Pick; - secretTagDAL: Pick; + secretTagDAL: Pick; secretVersionTagDAL: Pick; resourceMetadataDAL: Pick; diff --git a/backend/src/services/external-migration/external-migration-queue.ts b/backend/src/services/external-migration/external-migration-queue.ts index e3d77e832..8aa46b94c 100644 --- a/backend/src/services/external-migration/external-migration-queue.ts +++ b/backend/src/services/external-migration/external-migration-queue.ts @@ -27,9 +27,9 @@ export type TExternalMigrationQueueFactoryDep = { projectEnvDAL: Pick; kmsService: Pick; - secretDAL: Pick; + secretDAL: Pick; secretVersionDAL: Pick; - secretTagDAL: Pick; + secretTagDAL: Pick; secretVersionTagDAL: Pick; folderDAL: Pick; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index fdefd0e62..4b07245ac 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -68,7 +68,8 @@ const getIntegrationSecretsV2 = async ( secretDAL: secretV2BridgeDAL, secretImportDAL, secretImports, - hasSecretAccess: () => true + hasSecretAccess: () => true, + viewSecretValue: true }); for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 35994433a..ad08e89d1 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -1,8 +1,13 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { NotFoundError } from "@app/lib/errors"; import { TProjectPermission } from "@app/lib/types"; @@ -91,13 +96,10 @@ export const integrationServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: sourceEnvironment, - secretPath - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: sourceEnvironment, + secretPath + }); const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath); if (!folder) { @@ -174,13 +176,10 @@ export const integrationServiceFactory = ({ const newSecretPath = secretPath || integration.secretPath; if (environment || secretPath) { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: newEnvironment, - secretPath: newSecretPath - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: newEnvironment, + secretPath: newSecretPath + }); } const folder = await folderDAL.findBySecretPath(integration.projectId, newEnvironment, newSecretPath); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e1653d371..dbef33c10 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -10,8 +10,13 @@ import { } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; @@ -563,11 +568,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; @@ -747,7 +765,7 @@ export const projectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); const project = await projectDAL.findProjectById(projectId); 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-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index d21ad3b9c..e5a450441 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -3,6 +3,7 @@ import { groupBy, unique } from "@app/lib/fn"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretDALFactory } from "../secret/secret-dal"; +import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretImportDALFactory } from "./secret-import-dal"; @@ -32,6 +33,12 @@ type TSecretImportSecretsV2 = { folderId: string | undefined; importFolderId: string; secrets: (TSecretsV2 & { + secretTags: { + slug: string; + name: string; + color?: string | null; + id: string; + }[]; workspace: string; environment: string; _id: string; @@ -39,6 +46,7 @@ type TSecretImportSecretsV2 = { // akhilmhdh: yes i know you can put ?. // But for somereason ts consider ? and undefined explicit as different just ts things secretValue: string; + secretValueHidden: boolean; secretComment: string; secretMetadata?: ResourceMetadataDTO; })[]; @@ -150,12 +158,14 @@ export const fnSecretsV2FromImports = async ({ secretImportDAL, decryptor, expandSecretReferences, - hasSecretAccess + hasSecretAccess, + viewSecretValue }: { secretImports: (Omit & { importEnv: { id: string; slug: string; name: string }; })[]; folderDAL: Pick; + viewSecretValue: boolean; secretDAL: Pick; secretImportDAL: Pick; decryptor: (value?: Buffer | null) => string; @@ -168,9 +178,14 @@ export const fnSecretsV2FromImports = async ({ hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean; }) => { const cyclicDetector = new Set(); - const stack: { secretImports: typeof rootSecretImports; depth: number; parentImportedSecrets: TSecretsV2[] }[] = [ - { secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] } - ]; + const stack: { + secretImports: typeof rootSecretImports; + depth: number; + parentImportedSecrets: (TSecretsV2 & { + secretValueHidden: boolean; + secretTags: { slug: string; name: string; id: string; color?: string | null }[]; + })[]; + }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; const processedImports: TSecretImportSecretsV2[] = []; @@ -229,7 +244,9 @@ export const fnSecretsV2FromImports = async ({ .map((item) => ({ ...item, secretKey: item.key, - secretValue: decryptor(item.encryptedValue), + secretValue: viewSecretValue ? decryptor(item.encryptedValue) : INFISICAL_SECRET_VALUE_HIDDEN_MASK, + secretValueHidden: !viewSecretValue, + secretTags: item.tags, secretComment: decryptor(item.encryptedComment), environment: importEnv.slug, workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend. @@ -267,6 +284,8 @@ export const fnSecretsV2FromImports = async ({ processedImport.secrets = unique(processedImport.secrets, (i) => i.key); return Promise.allSettled( processedImport.secrets.map(async (decryptedSecret, index) => { + if (decryptedSecret.secretValueHidden) return; + const expandedSecretValue = await expandSecretReferences({ value: decryptedSecret.secretValue, secretPath: processedImport.secretPath, diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index e8fde04d1..b8e8b2fa0 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -4,8 +4,16 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -89,13 +97,11 @@ export const secretImportServiceFactory = ({ ); // check if user has permission to import from target path - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: data.environment, - secretPath: data.path - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment: data.environment, + secretPath: data.path + }); + if (isReplication) { const plan = await licenseService.getPlan(actorOrgId); if (!plan.secretApproval) { @@ -401,13 +407,10 @@ export const secretImportServiceFactory = ({ if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" }); // check if user has permission to import from target path - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: secretImportDoc.importEnv.slug, - secretPath: secretImportDoc.importPath - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment: secretImportDoc.importEnv.slug, + secretPath: secretImportDoc.importPath + }); await projectDAL.checkProjectUpgradeStatus(projectId); @@ -595,14 +598,12 @@ export const secretImportServiceFactory = ({ // so anything based on this order will also be in right position const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false }); const allowedImports = secretImports.filter((el) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: el.importEnv.slug, - secretPath: el.importPath - }) - ) + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: el.importEnv.slug, + secretPath: el.importPath + }) ); + return fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL }); }; @@ -642,20 +643,19 @@ export const secretImportServiceFactory = ({ const importedSecrets = await fnSecretsV2FromImports({ secretImports, folderDAL, + viewSecretValue: true, secretDAL: secretV2BridgeDAL, secretImportDAL, decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: expandEnvironment, - secretPath: expandSecretPath, - secretName: expandSecretKey, - secretTags: expandSecretTags - }) - ) + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: expandEnvironment, + secretPath: expandSecretPath, + secretName: expandSecretKey, + secretTags: expandSecretTags + }) }); + return importedSecrets; } @@ -666,13 +666,10 @@ export const secretImportServiceFactory = ({ }); const allowedImports = secretImports.filter((el) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: el.importEnv.slug, - secretPath: el.importPath - }) - ) + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: el.importEnv.slug, + secretPath: el.importPath + }) ); const importedSecrets = await fnSecretsFromImports({ allowedImports, @@ -683,7 +680,10 @@ export const secretImportServiceFactory = ({ return importedSecrets.map((el) => ({ ...el, secrets: el.secrets.map((encryptedSecret) => - decryptSecretRaw({ ...encryptedSecret, workspace: projectId, environment, secretPath }, botKey) + decryptSecretRaw( + { ...encryptedSecret, workspace: projectId, environment, secretPath, secretValueHidden: false }, + botKey + ) ) })); }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index cdc9540da..8afcf6416 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -249,7 +249,8 @@ export const secretSyncQueueFactory = ({ expandSecretReferences, secretImportDAL, secretImports, - hasSecretAccess: () => true + hasSecretAccess: () => true, + viewSecretValue: true }); for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 8211180e8..5c4a7e850 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -1,9 +1,10 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { - ProjectPermissionActions, + ProjectPermissionSecretActions, ProjectPermissionSecretSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -178,13 +179,10 @@ export const secretSyncServiceFactory = ({ ProjectPermissionSub.SecretSyncs ); - ForbiddenError.from(projectPermission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(projectPermission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath + }); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); @@ -269,13 +267,10 @@ export const secretSyncServiceFactory = ({ if (!updatedEnvironment || !updatedSecretPath) throw new BadRequestError({ message: "Must specify both source environment and secret path" }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: updatedEnvironment, - secretPath: updatedSecretPath - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: updatedEnvironment, + secretPath: updatedSecretPath + }); const newFolder = await folderDAL.findBySecretPath(secretSync.projectId, updatedEnvironment, updatedSecretPath); diff --git a/backend/src/services/secret-tag/secret-tag-dal.ts b/backend/src/services/secret-tag/secret-tag-dal.ts index 1df64afa2..3b9151557 100644 --- a/backend/src/services/secret-tag/secret-tag-dal.ts +++ b/backend/src/services/secret-tag/secret-tag-dal.ts @@ -47,6 +47,7 @@ export const secretTagDALFactory = (db: TDbClient) => { throw new DatabaseError({ error, name: "Find all by ids" }); } }; + return { ...secretTagOrm, saveTagsToSecret: secretJnTagOrm.insertMany, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 751235cde..8e1de2d91 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; +import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; @@ -108,6 +109,7 @@ export const fnSecretBulkInsert = async ({ [`${TableName.SecretV2}Id` as const]: newSecretGroupedByKeyName[key][0].id })) ); + const secretVersions = await secretVersionDAL.insertMany( sanitizedInputSecrets.map((el) => ({ ...el, @@ -146,6 +148,7 @@ export const fnSecretBulkInsert = async ({ if (newSecretTags.length) { const secTags = await secretTagDAL.saveTagsToSecretV2(newSecretTags, tx); const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secrets_v2Id, secret_tagsId }) => ({ [`${TableName.SecretVersionV2}Id` as const]: secVersionsGroupBySecId[secrets_v2Id][0].id, [`${TableName.SecretTag}Id` as const]: secret_tagsId @@ -154,7 +157,16 @@ export const fnSecretBulkInsert = async ({ await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); } - return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); + const secretsWithTags = await secretDAL.find( + { + $in: { + [`${TableName.SecretV2}.id` as "id"]: newSecrets.map((s) => s.id) + } + }, + { tx } + ); + + return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id })); }; export const fnSecretBulkUpdate = async ({ @@ -300,7 +312,15 @@ export const fnSecretBulkUpdate = async ({ tx ); - return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); + const secretsWithTags = await secretDAL.find( + { + $in: { + [`${TableName.SecretV2}.id` as "id"]: newSecrets.map((s) => s.id) + } + }, + { tx } + ); + return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id })); }; export const fnSecretBulkDelete = async ({ @@ -533,7 +553,7 @@ export const expandSecretReferencesFactory = ({ const referredValue = await fetchSecret(environment, secretPath, secretKey); if (!canExpandValue(environment, secretPath, secretKey, referredValue.tags)) throw new ForbiddenRequestError({ - message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to.` + message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to read value on.` }); const cacheKey = getCacheUniqueKey(environment, secretPath); @@ -552,7 +572,7 @@ export const expandSecretReferencesFactory = ({ const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey); if (!canExpandValue(secretReferenceEnvironment, secretReferencePath, secretReferenceKey, referedValue.tags)) throw new ForbiddenRequestError({ - message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to.` + message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to read value on.` }); const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath); @@ -646,13 +666,13 @@ export const reshapeBridgeSecret = ( name: string; }[]; secretMetadata?: ResourceMetadataDTO; - } + }, + secretValueHidden: boolean ) => ({ secretKey: secret.key, secretPath, workspace: workspaceId, environment, - secretValue: secret.value || "", secretComment: secret.comment || "", version: secret.version, type: secret.type, @@ -674,5 +694,15 @@ export const reshapeBridgeSecret = ( metadata: secret.metadata, secretMetadata: secret.secretMetadata, createdAt: secret.createdAt, - updatedAt: secret.updatedAt + updatedAt: secret.updatedAt, + + ...(secretValueHidden + ? { + secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, + secretValueHidden: true + } + : { + secretValue: secret.value || "", + secretValueHidden: false + }) }); 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 d6ba02856..09b3a50cb 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 @@ -1,4 +1,4 @@ -import { ForbiddenError, PureAbility, subject } from "@casl/ability"; +import { ForbiddenError, MongoAbility, subject } from "@casl/ability"; import { Knex } from "knex"; import { z } from "zod"; @@ -10,8 +10,17 @@ import { TableName, TSecretsV2 } from "@app/db/schemas"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; @@ -51,6 +60,7 @@ import { TCreateSecretDTO, TDeleteManySecretDTO, TDeleteSecretDTO, + TGetAccessibleSecretsDTO, TGetASecretDTO, TGetSecretReferencesTreeDTO, TGetSecretsDTO, @@ -118,7 +128,7 @@ export const secretV2BridgeServiceFactory = ({ }: TSecretV2BridgeServiceFactoryDep) => { const $validateSecretReferences = async ( projectId: string, - permission: PureAbility, + permission: MongoAbility, references: ReturnType["nestedReferences"], tx?: Knex ) => { @@ -126,6 +136,7 @@ export const secretV2BridgeServiceFactory = ({ const uniqueReferenceEnvironmentSlugs = Array.from(new Set(references.map((el) => el.environment))); const referencesEnvironments = await projectEnvDAL.findBySlugs(projectId, uniqueReferenceEnvironmentSlugs, tx); + if (referencesEnvironments.length !== uniqueReferenceEnvironmentSlugs.length) throw new BadRequestError({ message: `Referenced environment not found. Missing ${diff( @@ -142,6 +153,7 @@ export const secretV2BridgeServiceFactory = ({ })), tx ); + const referencesFolderGroupByPath = groupBy(referredFolders.filter(Boolean), (i) => `${i?.envId}-${i?.path}`); const referredSecrets = await secretDAL.find( { @@ -189,15 +201,12 @@ export const secretV2BridgeServiceFactory = ({ const referredSecretsGroupBySecretKey = groupBy(referredSecrets, (i) => i.key); references.forEach((el) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: el.environment, - secretPath: el.secretPath, - secretName: el.secretKey, - tags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug) - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment: el.environment, + secretPath: el.secretPath, + secretName: el.secretKey, + secretTags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug) + }); }); return referredSecrets; @@ -259,7 +268,7 @@ export const secretV2BridgeServiceFactory = ({ const { secretName, type, ...inputSecretData } = inputSecret; ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -272,14 +281,15 @@ export const secretV2BridgeServiceFactory = ({ const allSecretReferences = nestedReferences.concat( localReferences.map((el) => ({ secretKey: el, secretPath, environment })) ); + await $validateSecretReferences(projectId, permission, allSecretReferences); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); - const secret = await secretDAL.transaction((tx) => - fnSecretBulkInsert({ + const secret = await secretDAL.transaction(async (tx) => { + const [createdSecret] = await fnSecretBulkInsert({ folderId, orgId: actorOrgId, inputSecrets: [ @@ -313,8 +323,10 @@ export const secretV2BridgeServiceFactory = ({ actorId }, tx - }) - ); + }); + + return createdSecret; + }); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); @@ -328,11 +340,17 @@ export const secretV2BridgeServiceFactory = ({ }); } - return reshapeBridgeSecret(projectId, environment, secretPath, { - ...secret[0], - value: inputSecret.secretValue, - comment: inputSecret.secretComment || "" - }); + return reshapeBridgeSecret( + projectId, + environment, + secretPath, + { + ...secret, + value: inputSecret.secretValue, + comment: inputSecret.secretComment || "" + }, + false + ); }; const updateSecret = async ({ @@ -401,7 +419,7 @@ export const secretV2BridgeServiceFactory = ({ } ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -412,18 +430,22 @@ export const secretV2BridgeServiceFactory = ({ // validate tags // fetch all tags and if not same count throw error meaning one was invalid tags - const tags = inputSecret.tagIds ? await secretTagDAL.find({ projectId, $in: { id: inputSecret.tagIds } }) : []; - if ((inputSecret.tagIds || []).length !== tags.length) - throw new NotFoundError({ message: `Tag not found. Found ${tags.map((el) => el.slug).join(",")}` }); + const newTags = inputSecret.tagIds ? await secretTagDAL.find({ projectId, $in: { id: inputSecret.tagIds } }) : []; + if ((inputSecret.tagIds || []).length !== newTags.length) + throw new NotFoundError({ message: `Tag not found. Found ${newTags.map((el) => el.slug).join(",")}` }); + + const tagsToCheck = inputSecret.tagIds ? newTags : secret.tags; // now check with new ids ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, secretName: inputSecret.secretName, - secretTags: tags?.map((el) => el.slug) + ...(tagsToCheck.length && { + secretTags: tagsToCheck.map((el) => el.slug) + }) }) ); @@ -435,12 +457,14 @@ export const secretV2BridgeServiceFactory = ({ }); if (doesNewNameSecretExist) throw new BadRequestError({ message: "Secret with the new name already exist" }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, secretName: inputSecret.newSecretName, - secretTags: tags?.map((el) => el.slug) + ...(tagsToCheck.length && { + secretTags: tagsToCheck.map((el) => el.slug) + }) }) ); } @@ -522,11 +546,30 @@ export const secretV2BridgeServiceFactory = ({ }); } - return reshapeBridgeSecret(projectId, environment, secretPath, { - ...updatedSecret[0], - value: inputSecret.secretValue || "", - comment: inputSecret.secretComment || "" - }); + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: inputSecret.secretName, + ...(tagsToCheck.length && { + secretTags: tagsToCheck.map((el) => el.slug) + }) + } + ); + + return reshapeBridgeSecret( + projectId, + environment, + secretPath, + { + ...updatedSecret[0], + value: inputSecret.secretValue || "", + comment: inputSecret.secretComment || "" + }, + secretValueHidden + ); }; const deleteSecret = async ({ @@ -572,7 +615,7 @@ export const secretV2BridgeServiceFactory = ({ }); if (!secretToDelete) throw new NotFoundError({ message: "Secret not found" }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -614,15 +657,33 @@ export const secretV2BridgeServiceFactory = ({ type: KmsDataKey.SecretManager, projectId }); - return reshapeBridgeSecret(projectId, environment, secretPath, { - ...deletedSecret[0], - value: deletedSecret[0].encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() - : "", - comment: deletedSecret[0].encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() - : "" - }); + + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: secretToDelete.key, + secretTags: secretToDelete.tags?.map((el) => el.slug) + } + ); + + return reshapeBridgeSecret( + projectId, + environment, + secretPath, + { + ...deletedSecret[0], + value: deletedSecret[0].encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() + : "", + comment: deletedSecret[0].encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() + : "" + }, + secretValueHidden + ); }; // get unique secrets count for multiple envs @@ -649,8 +710,7 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); } const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); @@ -696,8 +756,7 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) return 0; @@ -708,7 +767,13 @@ export const secretV2BridgeServiceFactory = ({ }; const getSecretsByFolderMappings = async ( - { projectId, userId, filters, folderMappings }: TGetSecretsRawByFolderMappingsDTO, + { + projectId, + userId, + filters, + folderMappings, + filterByAction = ProjectPermissionSecretActions.ReadValue + }: TGetSecretsRawByFolderMappingsDTO, projectPermission: Awaited>["permission"] ) => { const groupedFolderMappings = groupBy(folderMappings, (folderMapping) => folderMapping.folderId); @@ -727,18 +792,28 @@ export const secretV2BridgeServiceFactory = ({ const decryptedSecrets = secrets .filter((el) => - projectPermission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: groupedFolderMappings[el.folderId][0].environment, - secretPath: groupedFolderMappings[el.folderId][0].path, - secretName: el.key, - secretTags: el.tags.map((i) => i.slug) - }) - ) + hasSecretReadValueOrDescribePermission(projectPermission, filterByAction, { + environment: groupedFolderMappings[el.folderId][0].environment, + secretPath: groupedFolderMappings[el.folderId][0].path, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + }) ) - .map((secret) => - reshapeBridgeSecret( + + .map((secret) => { + // Note(Daniel): This is only relevant if the filterAction isn't set to ReadValue. This is needed for the frontend. + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + projectPermission, + ProjectPermissionSecretActions.ReadValue, + { + environment: groupedFolderMappings[secret.folderId][0].environment, + secretPath: groupedFolderMappings[secret.folderId][0].path, + secretName: secret.key, + secretTags: secret.tags.map((i) => i.slug) + } + ); + + return reshapeBridgeSecret( projectId, groupedFolderMappings[secret.folderId][0].environment, groupedFolderMappings[secret.folderId][0].path, @@ -750,9 +825,10 @@ export const secretV2BridgeServiceFactory = ({ comment: secret.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() : "" - } - ) - ); + }, + secretValueHidden + ); + }); return decryptedSecrets; }; @@ -781,7 +857,7 @@ export const secretV2BridgeServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); if (!isInternal) { - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); } const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); @@ -801,7 +877,8 @@ export const secretV2BridgeServiceFactory = ({ projectId, folderMappings, filters: params, - userId: actorId + userId: actorId, + filterByAction: ProjectPermissionSecretActions.DescribeSecret }, permission ); @@ -816,10 +893,12 @@ export const secretV2BridgeServiceFactory = ({ projectId, actor, actorOrgId, + viewSecretValue, actorAuthMethod, includeImports, recursive, expandSecretReferences: shouldExpandSecretReferences, + throwOnMissingReadValuePermission = true, ...params }: TGetSecretsDTO) => { const { permission } = await permissionService.getProjectPermission({ @@ -830,8 +909,11 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath: path, + secretTags: params.tagSlugs + }); let paths: { folderId: string; path: string }[] = []; @@ -869,28 +951,83 @@ export const secretV2BridgeServiceFactory = ({ }); const decryptedSecrets = secrets - .filter((el) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { + .filter((el) => { + const canDescribeSecret = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.DescribeSecret, + { environment, secretPath: groupedPaths[el.folderId][0].path, secretName: el.key, secretTags: el.tags.map((i) => i.slug) - }) - ) - ) - .map((secret) => - reshapeBridgeSecret(projectId, environment, groupedPaths[secret.folderId][0].path, { - ...secret, - value: secret.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() - : "", - comment: secret.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() - : "" - }) - ); + } + ); + + if (!canDescribeSecret) { + return false; + } + + if (viewSecretValue) { + // Recursive secret, should be filtered out + if (groupedPaths[el.folderId][0].path !== path) { + const canReadRecursiveSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath: groupedPaths[el.folderId][0].path, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + } + ); + + if (!canReadRecursiveSecretValue) { + return false; + } + } + + if (throwOnMissingReadValuePermission) { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: groupedPaths[el.folderId][0].path, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + }); + } + // Else, we do nothing. Because we don't want to filter out the secret, OR throw an error. + // If the user doesn't have access to read the value, in the below map function, we mask the secret value and return the secret with a hidden value. + } + + return canDescribeSecret; + }) + .map((secret) => { + const isPersonalSecret = secret.userId === actorId && secret.type === SecretType.Personal; + + const secretValueHidden = + !viewSecretValue || + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: groupedPaths[secret.folderId][0].path, + secretName: secret.key, + secretTags: secret.tags.map((i) => i.slug) + }); + + return reshapeBridgeSecret( + projectId, + environment, + groupedPaths[secret.folderId][0].path, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + }, + secretValueHidden && !isPersonalSecret + ); + }); const { expandSecretReferences } = expandSecretReferencesFactory({ projectId, @@ -898,15 +1035,12 @@ export const secretV2BridgeServiceFactory = ({ secretDAL, decryptSecretValue: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined), canExpandValue: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: expandEnvironment, - secretPath: expandSecretPath, - secretName: expandSecretKey, - secretTags: expandSecretTags - }) - ) + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: expandEnvironment, + secretPath: expandSecretPath, + secretName: expandSecretKey, + secretTags: expandSecretTags + }) }); if (shouldExpandSecretReferences) { @@ -938,22 +1072,38 @@ export const secretV2BridgeServiceFactory = ({ const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); const allowedImports = secretImports.filter(({ isReplication }) => !isReplication); const importedSecrets = await fnSecretsV2FromImports({ + viewSecretValue, secretImports: allowedImports, secretDAL, folderDAL, secretImportDAL, expandSecretReferences, decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), - hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { + hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => { + const canDescribe = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.DescribeSecret, + { environment: expandEnvironment, secretPath: expandSecretPath, secretName: expandSecretKey, secretTags: expandSecretTags - }) - ) + } + ); + + const canReadValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: expandEnvironment, + secretPath: expandSecretPath, + secretName: expandSecretKey, + secretTags: expandSecretTags + } + ); + + return viewSecretValue ? canDescribe && canReadValue : canDescribe; + } }); return { @@ -992,15 +1142,12 @@ export const secretV2BridgeServiceFactory = ({ 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) - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + 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({ @@ -1022,11 +1169,17 @@ export const secretV2BridgeServiceFactory = ({ ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() : ""; - return reshapeBridgeSecret(secret.projectId, folderWithPath.environmentSlug, folderWithPath.path, { - ...secret, - value: secretValue, - comment: secretComment - }); + return reshapeBridgeSecret( + secret.projectId, + folderWithPath.environmentSlug, + folderWithPath.path, + { + ...secret, + value: secretValue, + comment: secretComment + }, + false + ); }; const getSecretByName = async ({ @@ -1040,6 +1193,7 @@ export const secretV2BridgeServiceFactory = ({ type, secretName, version, + viewSecretValue, includeImports, expandSecretReferences: shouldExpandSecretReferences }: TGetASecretDTO) => { @@ -1100,66 +1254,91 @@ export const secretV2BridgeServiceFactory = ({ }) )); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath: path, - secretName, - secretTags: (secret?.tags || []).map((el) => el.slug) - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath: path, + secretName, + secretTags: (secret?.tags || []).map((el) => el.slug) + }); + // this will throw if the user doesn't have read value permission no matter what + // because if its an expansion, it will fully depend on the value. const { expandSecretReferences } = expandSecretReferencesFactory({ projectId, folderDAL, secretDAL, decryptSecretValue: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined), - canExpandValue: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: expandEnvironment, - secretPath: expandSecretPath, - secretName: expandSecretKey, - secretTags: expandSecretTags - }) - ) + canExpandValue: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => { + return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: expandEnvironment, + secretPath: expandSecretPath, + secretName: expandSecretKey, + secretTags: expandSecretTags + }); + } }); // now if secret is not found // then search for imported secrets // here we consider the import order also thus starting from bottom + + // currently filters out the secrets that the user doesn't have access to read value on if (!secret && includeImports) { const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); const importedSecrets = await fnSecretsV2FromImports({ secretImports, + viewSecretValue, secretDAL, folderDAL, secretImportDAL, decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), - expandSecretReferences: shouldExpandSecretReferences ? expandSecretReferences : undefined, - hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: expandEnvironment, - secretPath: expandSecretPath, - secretName: expandSecretKey, - secretTags: expandSecretTags - }) - ) + expandSecretReferences: shouldExpandSecretReferences && viewSecretValue ? expandSecretReferences : undefined, + hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) => { + return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment: expandEnvironment, + secretPath: expandSecretPath, + secretName: expandSecretKey, + secretTags: expandSecretTags + }); + } }); for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { const importedSecret = importedSecrets[i].secrets[j]; if (secretName === importedSecret.key) { - return reshapeBridgeSecret(projectId, importedSecrets[i].environment, importedSecrets[i].secretPath, { - ...importedSecret, - value: importedSecret.secretValue || "", - comment: importedSecret.secretComment || "" - }); + let secretValueHidden = true; + + if (viewSecretValue) { + if ( + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: importedSecret.environment, + secretPath: importedSecrets[i].secretPath, + secretName: importedSecret.key, + secretTags: (importedSecret.secretTags || []).map((el) => el.slug) + }) && + secretType !== SecretType.Personal + ) { + throw new ForbiddenRequestError({ + message: `You do not have permission to view secret import value on secret with name '${secretName}'`, + name: "ForbiddenReadSecretError" + }); + } + + secretValueHidden = false; + } + + return reshapeBridgeSecret( + projectId, + importedSecrets[i].environment, + importedSecrets[i].secretPath, + { + ...importedSecret, + value: importedSecret.secretValue || "", + comment: importedSecret.secretComment || "" + }, + secretValueHidden + ); } } } @@ -1169,7 +1348,7 @@ export const secretV2BridgeServiceFactory = ({ let secretValue = secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() : ""; - if (shouldExpandSecretReferences && secretValue) { + if (shouldExpandSecretReferences && secretValue && viewSecretValue) { // eslint-disable-next-line const expandedSecretValue = await expandSecretReferences({ environment, @@ -1181,13 +1360,40 @@ export const secretV2BridgeServiceFactory = ({ secretValue = expandedSecretValue || ""; } - return reshapeBridgeSecret(projectId, environment, path, { - ...secret, - value: secretValue, - comment: secret.encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() - : "" - }); + let secretValueHidden = true; + + if (viewSecretValue) { + if ( + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: path, + secretName, + secretTags: (secret?.tags || []).map((el) => el.slug) + }) && + secretType !== SecretType.Personal + ) { + throw new ForbiddenRequestError({ + message: `You do not have permission to view secret value on secret with name '${secretName}'`, + name: "ForbiddenReadSecretError" + }); + } + + secretValueHidden = false; + } + + return reshapeBridgeSecret( + projectId, + environment, + path, + { + ...secret, + value: secretValue, + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + }, + secretValueHidden + ); }; const createManySecret = async ({ @@ -1255,7 +1461,7 @@ export const secretV2BridgeServiceFactory = ({ inputSecrets.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1330,13 +1536,30 @@ export const secretV2BridgeServiceFactory = ({ environmentSlug: folder.environment.slug }); - return newSecrets.map((el) => - reshapeBridgeSecret(projectId, environment, secretPath, { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }) - ); + return newSecrets.map((el) => { + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: el.key, + secretTags: el.tags?.map((i) => i.slug) + } + ); + + return reshapeBridgeSecret( + projectId, + environment, + secretPath, + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" + }, + secretValueHidden + ); + }); }; const updateManySecret = async ({ @@ -1379,7 +1602,17 @@ export const secretV2BridgeServiceFactory = ({ const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); - const updatedSecrets: Array = []; + const updatedSecrets: Array< + TSecretsV2 & { + secretPath: string; + tags: { + id: string; + slug: string; + color?: string | null; + name: string; + }[]; + } + > = []; await secretDAL.transaction(async (tx) => { for await (const folder of folders) { if (!folder) throw new NotFoundError({ message: "Folder not found" }); @@ -1430,7 +1663,7 @@ export const secretV2BridgeServiceFactory = ({ secretsToUpdateInDB.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1450,7 +1683,7 @@ export const secretV2BridgeServiceFactory = ({ if (updateMode === SecretUpdateMode.Upsert) { secretsToCreate.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1464,7 +1697,7 @@ export const secretV2BridgeServiceFactory = ({ // check again to avoid non authorized tags are removed secretsToUpdate.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1516,7 +1749,7 @@ export const secretV2BridgeServiceFactory = ({ secretsWithNewName.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1582,6 +1815,7 @@ export const secretV2BridgeServiceFactory = ({ }, resourceMetadataDAL }); + updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); if (updateMode === SecretUpdateMode.Upsert) { const bulkInsertedSecrets = await fnSecretBulkInsert({ @@ -1618,6 +1852,7 @@ export const secretV2BridgeServiceFactory = ({ }, tx }); + updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); } } @@ -1639,13 +1874,34 @@ export const secretV2BridgeServiceFactory = ({ ) ); - return updatedSecrets.map((el) => - reshapeBridgeSecret(projectId, environment, el.secretPath, { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }) - ); + return updatedSecrets.map((el) => { + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath: el.secretPath, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + } + ); + + return { + ...reshapeBridgeSecret( + projectId, + environment, + el.secretPath, + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "" + }, + secretValueHidden + ) + }; + }); }; const deleteManySecret = async ({ @@ -1707,7 +1963,7 @@ export const secretV2BridgeServiceFactory = ({ }); secretsToDelete.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1746,13 +2002,32 @@ export const secretV2BridgeServiceFactory = ({ type: KmsDataKey.SecretManager, projectId }); - return secretsDeleted.map((el) => - reshapeBridgeSecret(projectId, environment, secretPath, { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }) - ); + return secretsDeleted.map((el) => { + const secretToDeleteMatch = secretsToDelete.find( + (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type + ); + + const secretValueHidden = + !secretToDeleteMatch || + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName: el.key, + secretTags: secretToDeleteMatch.tags?.map((i) => i.slug) + }); + + return reshapeBridgeSecret( + projectId, + environment, + secretPath, + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" + }, + secretValueHidden + ); + }); }; const getSecretVersions = async ({ @@ -1765,11 +2040,18 @@ export const secretV2BridgeServiceFactory = ({ secretId }: TGetSecretVersionsDTO) => { const secret = await secretDAL.findById(secretId); + if (!secret) throw new NotFoundError({ message: `Secret with ID '${secretId}' not found` }); const folder = await folderDAL.findById(secret.folderId); if (!folder) throw new NotFoundError({ message: `Folder with ID '${secret.folderId}' not found` }); + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(folder.projectId, [folder.id]); + + if (!folderWithPath) { + throw new NotFoundError({ message: `Folder with ID '${folder.id}' not found` }); + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -1788,13 +2070,31 @@ export const secretV2BridgeServiceFactory = ({ limit, sort: [["createdAt", "desc"]] }); - return secretVersions.map((el) => { - return reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }); + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: folder.environment.envSlug, + secretPath: folderWithPath.path, + secretName: el.key, + ...(el.tags?.length && { + secretTags: el.tags.map((tag) => tag.slug) + }) + } + ); + + return reshapeBridgeSecret( + folder.projectId, + folder.environment.envSlug, + folderWithPath.path, + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" + }, + secretValueHidden + ); }); }; @@ -1890,16 +2190,38 @@ export const secretV2BridgeServiceFactory = ({ [`${TableName.SecretV2}.id` as "id"]: secretIds } }); + + const sourceActions = [ + ProjectPermissionSecretActions.Delete, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.DescribeSecret + ] as const; + const destinationActions = [ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit] as const; + sourceSecrets.forEach((secret) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { - environment: sourceEnvironment, - secretPath: sourceSecretPath, - secretName: secret.key, - secretTags: secret.tags.map((el) => el.slug) - }) - ); + for (const sourceAction of sourceActions) { + if ( + sourceAction === ProjectPermissionSecretActions.DescribeSecret || + sourceAction === ProjectPermissionSecretActions.ReadValue + ) { + throwIfMissingSecretReadValueOrDescribePermission(permission, sourceAction, { + environment: sourceEnvironment, + secretPath: sourceSecretPath, + secretName: secret.key, + secretTags: secret.tags.map((el) => el.slug) + }); + } else { + ForbiddenError.from(permission).throwUnlessCan( + sourceAction, + subject(ProjectPermissionSub.Secrets, { + environment: sourceEnvironment, + secretPath: sourceSecretPath, + secretName: secret.key, + secretTags: secret.tags.map((el) => el.slug) + }) + ); + } + } }); if (sourceSecrets.length !== secretIds.length) { @@ -1974,27 +2296,17 @@ export const secretV2BridgeServiceFactory = ({ // permission check whether can create or edit the ones in the destination folder locallyCreatedSecrets.forEach((secret) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { - environment: destinationEnvironment, - secretPath: destinationEnvironment, - secretName: secret.key, - secretTags: secret.tags.map((el) => el.slug) - }) - ); - }); - - locallyUpdatedSecrets.forEach((secret) => { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment: destinationEnvironment, - secretPath: destinationEnvironment, - secretName: secret.key, - secretTags: secret.tags.map((el) => el.slug) - }) - ); + for (const destinationAction of destinationActions) { + ForbiddenError.from(permission).throwUnlessCan( + destinationAction, + subject(ProjectPermissionSub.Secrets, { + environment: destinationEnvironment, + secretPath: destinationFolder.path, + secretName: secret.key, + secretTags: secret.tags.map((el) => el.slug) + }) + ); + } }); const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( @@ -2231,10 +2543,10 @@ export const secretV2BridgeServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath + }); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) @@ -2255,17 +2567,14 @@ export const secretV2BridgeServiceFactory = ({ type: SecretType.Shared }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName, - secretTags: (secret?.tags || []).map((el) => el.slug) - }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath, + secretName, + secretTags: (secret?.tags || []).map((el) => el.slug) + }); - const secretValue = secret.encryptedValue + const decryptedSecretValue = secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() : ""; @@ -2275,26 +2584,125 @@ export const secretV2BridgeServiceFactory = ({ secretDAL, decryptSecretValue: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : undefined), canExpandValue: (expandEnvironment, expandSecretPath, expandSecretName, expandSecretTags) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: expandEnvironment, - secretPath: expandSecretPath, - secretName: expandSecretName, - secretTags: expandSecretTags - }) - ) + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: expandEnvironment, + secretPath: expandSecretPath, + secretName: expandSecretName, + secretTags: expandSecretTags + }) }); + if ( + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName, + secretTags: (secret?.tags || []).map((el) => el.slug) + }) + ) { + throw new ForbiddenRequestError({ + message: `Unable to get secret reference tree for secret with key '${secretName}', because you don't have permission to view secret value.` + }); + } + const { expandedValue, stackTrace } = await getExpandedSecretStackTrace({ environment, secretPath, - value: secretValue + value: decryptedSecretValue }); return { tree: stackTrace, value: expandedValue }; }; + const getAccessibleSecrets = async ({ + projectId, + secretPath, + environment, + filterByAction, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TGetAccessibleSecretsDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) return { secrets: [] }; + + const secrets = await secretDAL.findByFolderIds([folder.id]); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedSecrets = secrets + .filter((el) => { + if ( + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + }) + ) { + return false; + } + + if (filterByAction === ProjectPermissionSecretActions.ReadValue) { + return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName: el.key, + secretTags: el.tags.map((i) => i.slug) + }); + } + + return true; + }) + .map((secret) => { + const secretValueHidden = + filterByAction === ProjectPermissionSecretActions.DescribeSecret && + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName: secret.key, + secretTags: secret.tags.map((i) => i.slug) + }); + + return reshapeBridgeSecret( + projectId, + environment, + secretPath, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + }, + secretValueHidden + ); + }); + + return { + secrets: decryptedSecrets + }; + }; + return { createSecret, deleteSecret, @@ -2312,6 +2720,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsMultiEnv, getSecretReferenceTree, getSecretsByFolderMappings, - getSecretById + getSecretById, + getAccessibleSecrets }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 22956463d..7f415bff8 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -1,6 +1,7 @@ import { Knex } from "knex"; import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas"; +import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; @@ -36,6 +37,8 @@ export type TGetSecretsDTO = { includeImports?: boolean; recursive?: boolean; tagSlugs?: string[]; + viewSecretValue: boolean; + throwOnMissingReadValuePermission?: boolean; metadataFilter?: { key?: string; value?: string; @@ -48,6 +51,11 @@ export type TGetSecretsDTO = { keys?: string[]; } & TProjectPermission; +export type TGetSecretsMissingReadValuePermissionDTO = Omit< + TGetSecretsDTO, + "viewSecretValue" | "recursive" | "expandSecretReferences" +>; + export type TGetASecretDTO = { secretName: string; path: string; @@ -57,6 +65,7 @@ export type TGetASecretDTO = { includeImports?: boolean; version?: number; projectId: string; + viewSecretValue: boolean; } & Omit; export type TCreateSecretDTO = TProjectPermission & { @@ -164,9 +173,9 @@ export type TFnSecretBulkInsert = { } >; resourceMetadataDAL: Pick; - secretDAL: Pick; + secretDAL: Pick; secretVersionDAL: Pick; - secretTagDAL: Pick; + secretTagDAL: Pick; secretVersionTagDAL: Pick; actor?: { type: string; @@ -192,9 +201,9 @@ export type TFnSecretBulkUpdate = { data: TRequireReferenceIfValue & { tags?: string[]; secretMetadata?: ResourceMetadataDTO }; }[]; resourceMetadataDAL: Pick; - secretDAL: Pick; + secretDAL: Pick; secretVersionDAL: Pick; - secretTagDAL: Pick; + secretTagDAL: Pick; secretVersionTagDAL: Pick; actor?: { type: string; @@ -340,4 +349,12 @@ export type TGetSecretsRawByFolderMappingsDTO = { folderMappings: { folderId: string; path: string; environment: string }[]; userId: string; filters: TFindSecretsByFolderIdsFilter; + filterByAction?: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; }; + +export type TGetAccessibleSecretsDTO = { + environment: string; + projectId: string; + secretPath: string; + filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; +} & TProjectPermission; diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index d06aa1472..b54b073a6 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -2,9 +2,9 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; +import { SecretVersionsV2Schema, TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; @@ -13,6 +13,58 @@ export type TSecretVersionV2DALFactory = ReturnType { const secretVersionV2Orm = ormify(db, TableName.SecretVersionV2); + const findBySecretId = async (secretId: string, { offset, limit, sort, tx }: TFindOpt = {}) => { + try { + const query = (tx || db.replicaNode())(TableName.SecretVersionV2) + .where(`${TableName.SecretVersionV2}.secretId`, secretId) + .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .select(selectAllTableCols(TableName.SecretVersionV2)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const docs = await query; + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretVersionsV2Schema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + } + ] + }); + + return data; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SecretVersionV2}: FindBySecretId` }); + } + }; + // This will fetch all latest secret versions from a folder const findLatestVersionByFolderId = async (folderId: string, tx?: Knex) => { try { @@ -135,6 +187,17 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretVersionV2}.userActorId` ) .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) + .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) .where((qb) => { void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); void qb.where(`${TableName.ProjectMembership}.projectId`, projectId); @@ -145,9 +208,12 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { }) .select( selectAllTableCols(TableName.SecretVersionV2), - `${TableName.Users}.username as userActorName`, - `${TableName.Identity}.name as identityActorName`, - `${TableName.ProjectMembership}.id as membershipId` + db.ref("username").withSchema(TableName.Users).as("userActorName"), + db.ref("name").withSchema(TableName.Identity).as("identityActorName"), + db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), + db.ref("id").withSchema(TableName.SecretTag).as("tagId"), + db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") ); if (limit) void query.limit(limit); @@ -162,14 +228,33 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { ); } - const docs: Array< - TSecretVersionsV2 & { - userActorName: string | undefined | null; - identityActorName: string | undefined | null; - membershipId: string | undefined | null; - } - > = await query; - return docs; + const docs = await query; + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + _id: el.id, + ...SecretVersionsV2Schema.parse(el), + userActorName: el.userActorName, + identityActorName: el.identityActorName, + membershipId: el.membershipId + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + } + ] + }); + + return data; } catch (error) { throw new DatabaseError({ error, name: "FindVersionsBySecretIdWithActors" }); } @@ -181,6 +266,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { findLatestVersionMany, bulkUpdate, findLatestVersionByFolderId, - findVersionsBySecretIdWithActors + findVersionsBySecretIdWithActors, + findBySecretId }; }; diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index cbaf7ddcd..dc41d129f 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -169,6 +169,48 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const findManySecretsWithTags = async ( + filter: { + secretIds: string[]; + type: SecretType; + }, + tx?: Knex + ) => { + try { + const secrets = await (tx || db.replicaNode())(TableName.Secret) + .whereIn(`${TableName.Secret}.id` as "id", filter.secretIds) + .where("type", filter.type) + .leftJoin(TableName.JnSecretTag, `${TableName.Secret}.id`, `${TableName.JnSecretTag}.${TableName.Secret}Id`) + .leftJoin(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .select(selectAllTableCols(TableName.Secret)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); + + const data = sqlNestRelationships({ + data: secrets, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsSchema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + } + ] + }); + + return data; + } catch (error) { + throw new DatabaseError({ error, name: "get many secrets with tags" }); + } + }; + const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => { try { // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) @@ -443,6 +485,7 @@ export const secretDALFactory = (db: TDbClient) => { upsertSecretReferences, findReferencedSecretReferences, findAllProjectSecretValues, - pruneSecretReminders + pruneSecretReminders, + findManySecretsWithTags }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index d7c9b86fb..aa8cec2bc 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -1,5 +1,4 @@ /* eslint-disable no-await-in-loop */ -import { subject } from "@casl/ability"; import path from "path"; import { @@ -12,8 +11,9 @@ import { TSecretFolders, TSecrets } from "@app/db/schemas"; +import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { buildSecretBlindIndexFromName, @@ -51,6 +51,8 @@ import { TUpdateManySecretsRawFnFactory } from "./secret-types"; +export const INFISICAL_SECRET_VALUE_HIDDEN_MASK = ""; + export const generateSecretBlindIndexBySalt = async (secretName: string, secretBlindIndexDoc: TSecretBlindIndexes) => { const appCfg = getConfig(); const secretBlindIndex = await buildSecretBlindIndexFromName({ @@ -189,13 +191,10 @@ export const recursivelyGetSecretPaths = ({ // Filter out paths that the user does not have permission to access, and paths that are not in the current path const allowedPaths = paths.filter( (folder) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath: folder.path - }) - ) && folder.path.startsWith(currentPath === "/" ? "" : currentPath) + hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: folder.path + }) && folder.path.startsWith(currentPath === "/" ? "" : currentPath) ); return allowedPaths; @@ -344,6 +343,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD export const decryptSecretRaw = ( secret: TSecrets & { + secretValueHidden: boolean; workspace: string; environment: string; secretPath: string; @@ -362,12 +362,14 @@ export const decryptSecretRaw = ( key }); - const secretValue = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key - }); + const secretValue = !secret.secretValueHidden + ? decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }) + : INFISICAL_SECRET_VALUE_HIDDEN_MASK; let secretComment = ""; @@ -385,6 +387,7 @@ export const decryptSecretRaw = ( secretPath: secret.secretPath, workspace: secret.workspace, environment: secret.environment, + secretValueHidden: secret.secretValueHidden, secretValue, secretComment, version: secret.version, @@ -1198,3 +1201,23 @@ export const fnDeleteProjectSecretReminders = async ( } } }; + +export const conditionallyHideSecretValue = ( + shouldHideValue: boolean, + { + secretValueCiphertext, + secretValueIV, + secretValueTag + }: { + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + } +) => { + return { + secretValueCiphertext: shouldHideValue ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : secretValueCiphertext, + secretValueIV: shouldHideValue ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : secretValueIV, + secretValueTag: shouldHideValue ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : secretValueTag, + secretValueHidden: shouldHideValue + }; +}; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index c84fe5ae0..76a486b46 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -403,7 +403,8 @@ export const secretQueueFactory = ({ expandSecretReferences, secretImportDAL, secretImports, - hasSecretAccess: () => true + hasSecretAccess: () => true, + viewSecretValue: true }); for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index cfb47d1dd..13a5c9736 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -6,14 +6,23 @@ import { ActionProjectType, ProjectMembershipRole, ProjectUpgradeStatus, + ProjectVersion, SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, SecretType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; @@ -48,6 +57,7 @@ import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bri import { TGetSecretReferencesTreeDTO } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretDALFactory } from "./secret-dal"; import { + conditionallyHideSecretValue, decryptSecretRaw, fnSecretBlindIndexCheck, fnSecretBulkDelete, @@ -71,6 +81,7 @@ import { TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, + TGetAccessibleSecretsDTO, TGetASecretByIdRawDTO, TGetASecretDTO, TGetASecretRawDTO, @@ -205,7 +216,7 @@ export const secretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -323,7 +334,7 @@ export const secretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -445,7 +456,23 @@ export const secretServiceFactory = ({ environmentSlug: folder.environment.slug }); } - return { ...updatedSecret[0], workspace: projectId, environment, secretPath: path }; + + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath: path + } + ); + + return { + ...updatedSecret[0], + ...conditionallyHideSecretValue(secretValueHidden, updatedSecret[0]), + workspace: projectId, + environment, + secretPath: path + }; }; const deleteSecret = async ({ @@ -468,7 +495,7 @@ export const secretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -541,7 +568,23 @@ export const secretServiceFactory = ({ }); } - return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path }; + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath: path + } + ); + + return { + ...deletedSecret[0], + ...conditionallyHideSecretValue(secretValueHidden, deletedSecret[0]), + _id: deletedSecret[0].id, + workspace: projectId, + environment, + secretPath: path + }; }; const getSecrets = async ({ @@ -589,10 +632,10 @@ export const secretServiceFactory = ({ paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: path + }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) return { secrets: [], imports: [] }; @@ -614,13 +657,10 @@ export const secretServiceFactory = ({ // if its service token allow full access over imported one actor === ActorType.SERVICE ? true - : permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: importEnv.slug, - secretPath: importPath - }) - ) + : hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: importEnv.slug, + secretPath: importPath + }) ); const importedSecrets = await fnSecretsFromImports({ allowedImports, @@ -671,10 +711,11 @@ export const secretServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) - ); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: path + }); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new NotFoundError({ @@ -721,14 +762,12 @@ export const secretServiceFactory = ({ // if its service token allow full access over imported one actor === ActorType.SERVICE ? true - : permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: importEnv.slug, - secretPath: importPath - }) - ) + : hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment: importEnv.slug, + secretPath: importPath + }) ); + const importedSecrets = await fnSecretsFromImports({ allowedImports, secretDAL, @@ -740,6 +779,7 @@ export const secretServiceFactory = ({ if (secretBlindIndex === importedSecrets[i].secrets[j].secretBlindIndex) { return { ...importedSecrets[i].secrets[j], + secretValueHidden: false, workspace: projectId, environment: importedSecrets[i].environment, secretPath: importedSecrets[i].secretPath @@ -750,7 +790,13 @@ export const secretServiceFactory = ({ } if (!secret) throw new NotFoundError({ message: `Secret with name '${secretName}' not found` }); - return { ...secret, workspace: projectId, environment, secretPath: path }; + return { + ...secret, + secretValueHidden: false, // Always false because we check permission at the beginning of the function + workspace: projectId, + environment, + secretPath: path + }; }; const createManySecret = async ({ @@ -772,7 +818,7 @@ export const secretServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -860,7 +906,7 @@ export const secretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -902,8 +948,8 @@ export const secretServiceFactory = ({ if (tagIds.length !== tags.length) throw new NotFoundError({ message: "One or more tags not found" }); const references = await getSecretReference(projectId); - const secrets = await secretDAL.transaction(async (tx) => - fnSecretBulkUpdate({ + const secrets = await secretDAL.transaction(async (tx) => { + const updatedSecrets = await fnSecretBulkUpdate({ folderId, projectId, tx, @@ -933,8 +979,22 @@ export const secretServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL - }) - ); + }); + + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath: path + } + ); + + return updatedSecrets.map((secret) => ({ + ...secret, + ...conditionallyHideSecretValue(secretValueHidden, secret) + })); + }); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -968,7 +1028,7 @@ export const secretServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -1019,8 +1079,19 @@ export const secretServiceFactory = ({ }); } } + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath: path + } + ); - return secrets; + return secrets.map((secret) => ({ + ...secret, + ...conditionallyHideSecretValue(secretValueHidden, secret) + })); }); await snapshotService.performSnapshot(folderId); @@ -1181,6 +1252,7 @@ export const secretServiceFactory = ({ secretName, path: secretPath, environment, + viewSecretValue: false, type: "shared" }); @@ -1195,12 +1267,25 @@ export const secretServiceFactory = ({ | (typeof groupPermissions)[number] ) => { const allowedActions = [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Delete, - ProjectPermissionActions.Create, - ProjectPermissionActions.Edit - ].filter((action) => - entityPermission.permission.can( + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Delete, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit + ].filter((action) => { + if ( + action === ProjectPermissionSecretActions.DescribeSecret || + action === ProjectPermissionSecretActions.ReadValue + ) { + return hasSecretReadValueOrDescribePermission(entityPermission.permission, action, { + environment, + secretPath, + secretName, + secretTags: secret?.tags?.map((el) => el.slug) + }); + } + + return entityPermission.permission.can( action, subject(ProjectPermissionSub.Secrets, { environment, @@ -1208,8 +1293,8 @@ export const secretServiceFactory = ({ secretName, secretTags: secret?.tags?.map((el) => el.slug) }) - ) - ); + ); + }); return { ...entityPermission, @@ -1228,6 +1313,39 @@ export const secretServiceFactory = ({ return { users: usersWithAccess, identities: identitiesWithAccess, groups: groupsWithAccess }; }; + const getAccessibleSecrets = async ({ + projectId, + secretPath, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + filterByAction + }: TGetAccessibleSecretsDTO) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) { + throw new BadRequestError({ + message: "Project version does not support this endpoint.", + name: "ProjectVersionNotSupported" + }); + } + + const secrets = await secretV2BridgeService.getAccessibleSecrets({ + projectId, + secretPath, + environment, + filterByAction, + actor, + actorId, + actorOrgId, + actorAuthMethod + }); + + return secrets; + }; + const getSecretsRaw = async ({ projectId, path, @@ -1235,11 +1353,13 @@ export const secretServiceFactory = ({ actorId, actorOrgId, actorAuthMethod, + viewSecretValue, environment, includeImports, expandSecretReferences, recursive, tagSlugs = [], + throwOnMissingReadValuePermission = true, ...paramsV2 }: TGetSecretsRawDTO) => { const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); @@ -1250,6 +1370,8 @@ export const secretServiceFactory = ({ actorId, actor, actorOrgId, + viewSecretValue, + throwOnMissingReadValuePermission, environment, path, recursive, @@ -1258,6 +1380,7 @@ export const secretServiceFactory = ({ tagSlugs, ...paramsV2 }); + return { secrets, imports }; } @@ -1286,14 +1409,20 @@ export const secretServiceFactory = ({ recursive }); - const decryptedSecrets = secrets.map((el) => decryptSecretRaw(el, botKey)); + const decryptedSecrets = secrets.map((el) => decryptSecretRaw({ ...el, secretValueHidden: false }, botKey)); const filteredSecrets = tagSlugs.length ? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug)))) : decryptedSecrets; const processedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => { const decryptedImportSecrets = importedSecrets.map((sec) => decryptSecretRaw( - { ...sec, environment: el.environment, workspace: projectId, secretPath: el.secretPath }, + { + ...sec, + environment: el.environment, + workspace: projectId, + secretPath: el.secretPath, + secretValueHidden: false + }, botKey ) ); @@ -1304,6 +1433,7 @@ export const secretServiceFactory = ({ const importedEntries = decryptedImportSecrets.reduce( ( accum: { + secretValueHidden: boolean; secretKey: string; secretPath: string; workspace: string; @@ -1347,6 +1477,7 @@ export const secretServiceFactory = ({ Object.keys(secretsGroupByPath).map((groupedPath) => Promise.allSettled( secretsGroupByPath[groupedPath].map(async (decryptedSecret, index) => { + if (decryptedSecret.secretValueHidden) return; const expandedSecretValue = await expandSecret({ value: decryptedSecret.secretValue, secretPath: groupedPath, @@ -1363,6 +1494,7 @@ export const secretServiceFactory = ({ processedImports.map((processedImport) => Promise.allSettled( processedImport.secrets.map(async (decryptedSecret, index) => { + if (decryptedSecret.secretValueHidden) return; const expandedSecretValue = await expandSecret({ value: decryptedSecret.secretValue, secretPath: path, @@ -1400,6 +1532,7 @@ export const secretServiceFactory = ({ path, actor, environment, + viewSecretValue, projectId: workspaceId, expandSecretReferences, projectSlug, @@ -1419,6 +1552,7 @@ export const secretServiceFactory = ({ includeImports, actorAuthMethod, path, + viewSecretValue, actorOrgId, actor, actorId, @@ -1449,6 +1583,7 @@ export const secretServiceFactory = ({ message: `Project bot for project with ID '${projectId}' not found. Please upgrade your project.`, name: "bot_not_found_error" }); + const decryptedSecret = decryptSecretRaw(encryptedSecret, botKey); if (expandSecretReferences) { @@ -1467,7 +1602,10 @@ export const secretServiceFactory = ({ decryptedSecret.secretValue = expandedSecretValue || ""; } - return { secretMetadata: undefined, ...decryptedSecret }; + return { + secretMetadata: undefined, + ...decryptedSecret + }; }; const createSecretRaw = async ({ @@ -1618,7 +1756,16 @@ export const secretServiceFactory = ({ tags: tagIds }); - return { type: SecretProtectionType.Direct as const, secret: decryptSecretRaw(secret, botKey) }; + return { + type: SecretProtectionType.Direct as const, + secret: decryptSecretRaw( + { + ...secret, + secretValueHidden: false + }, + botKey + ) + }; }; const updateSecretRaw = async ({ @@ -2014,7 +2161,7 @@ export const secretServiceFactory = ({ return { type: SecretProtectionType.Direct as const, secrets: secrets.map((secret) => - decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath, secretValueHidden: false }, botKey) ) }; }; @@ -2303,6 +2450,12 @@ export const secretServiceFactory = ({ const folder = await folderDAL.findById(secret.folderId); if (!folder) throw new NotFoundError({ message: `Folder with ID '${secret.folderId}' not found` }); + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(folder.projectId, [folder.id]); + + if (!folderWithPath) { + throw new NotFoundError({ message: `Folder with ID '${folder.id}' not found` }); + } + const { botKey } = await projectBotService.getBotKey(folder.projectId); if (!botKey) throw new NotFoundError({ message: `Project bot for project with ID '${folder.projectId}' not found` }); @@ -2316,18 +2469,43 @@ export const secretServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); - const secretVersions = await secretVersionDAL.find({ secretId }, { offset, limit, sort: [["createdAt", "desc"]] }); - return secretVersions.map((el) => - decryptSecretRaw( + const secretVersions = await secretVersionDAL.findBySecretId(secretId, { + offset, + limit, + sort: [["createdAt", "desc"]] + }); + return secretVersions.map((el) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: botKey + }); + + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, { + environment: folder.environment.envSlug, + secretPath: folderWithPath.path, + secretName: secretKey, + ...(el.tags?.length && { + secretTags: el.tags.map((tag) => tag.slug) + }) + } + ); + + return decryptSecretRaw( + { + secretValueHidden, ...el, workspace: folder.projectId, environment: folder.environment.envSlug, - secretPath: "/" + secretPath: folderWithPath.path }, botKey - ) - ); + ); + }); }; const attachTags = async ({ @@ -2353,7 +2531,7 @@ export const secretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); @@ -2459,7 +2637,7 @@ export const secretServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); @@ -2625,7 +2803,7 @@ export const secretServiceFactory = ({ message: `Project with slug '${projectSlug}' not found` }); } - if (project.version === 3) { + if (project.version === ProjectVersion.V3) { return secretV2BridgeService.moveSecrets({ sourceEnvironment, sourceSecretPath, @@ -2650,30 +2828,6 @@ export const secretServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { - environment: sourceEnvironment, - secretPath: sourceSecretPath - }) - ); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { - environment: destinationEnvironment, - secretPath: destinationSecretPath - }) - ); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment: destinationEnvironment, - secretPath: destinationSecretPath - }) - ); - const { botKey } = await projectBotService.getBotKey(project.id); if (!botKey) { throw new NotFoundError({ @@ -2701,11 +2855,9 @@ export const secretServiceFactory = ({ }); } - const sourceSecrets = await secretDAL.find({ + const sourceSecrets = await secretDAL.findManySecretsWithTags({ type: SecretType.Shared, - $in: { - id: secretIds - } + secretIds }); if (sourceSecrets.length !== secretIds.length) { @@ -2714,21 +2866,62 @@ export const secretServiceFactory = ({ }); } - const decryptedSourceSecrets = sourceSecrets.map((secret) => ({ - ...secret, - secretKey: decryptSymmetric128BitHexKeyUTF8({ + const sourceActions = [ + ProjectPermissionSecretActions.Delete, + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue + ] as const; + const destinationActions = [ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit] as const; + + const decryptedSourceSecrets = sourceSecrets.map((secret) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secret.secretKeyCiphertext, iv: secret.secretKeyIV, tag: secret.secretKeyTag, key: botKey - }), - secretValue: decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key: botKey - }) - })); + }); + + for (const destinationAction of destinationActions) { + ForbiddenError.from(permission).throwUnlessCan( + destinationAction, + subject(ProjectPermissionSub.Secrets, { + environment: destinationEnvironment, + secretPath: destinationSecretPath + }) + ); + } + + for (const sourceAction of sourceActions) { + if ( + sourceAction === ProjectPermissionSecretActions.ReadValue || + sourceAction === ProjectPermissionSecretActions.DescribeSecret + ) { + throwIfMissingSecretReadValueOrDescribePermission(permission, sourceAction, { + environment: sourceEnvironment, + secretPath: sourceSecretPath + }); + } else { + ForbiddenError.from(permission).throwUnlessCan( + sourceAction, + subject(ProjectPermissionSub.Secrets, { + environment: sourceEnvironment, + secretPath: sourceSecretPath + }) + ); + } + } + + return { + ...secret, + secretKey, + secretValue: decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: botKey + }) + }; + }); let isSourceUpdated = false; let isDestinationUpdated = false; @@ -3102,6 +3295,7 @@ export const secretServiceFactory = ({ getSecretReferenceTree, getSecretsRawByFolderMappings, getSecretAccessList, - getSecretByIdRaw + getSecretByIdRaw, + getAccessibleSecrets }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 46aedc2ee..f42f70b78 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -20,6 +20,7 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge- import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; +import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; type TPartialSecret = Pick; @@ -180,10 +181,18 @@ export enum SecretsOrderBy { Name = "name" // "key" for secrets but using name for use across resources } +export type TGetAccessibleSecretsDTO = { + secretPath: string; + environment: string; + filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; +} & TProjectPermission; + export type TGetSecretsRawDTO = { expandSecretReferences?: boolean; path: string; environment: string; + viewSecretValue: boolean; + throwOnMissingReadValuePermission?: boolean; includeImports?: boolean; recursive?: boolean; tagSlugs?: string[]; @@ -209,6 +218,7 @@ export type TGetASecretRawDTO = { secretName: string; path: string; environment: string; + viewSecretValue: boolean; expandSecretReferences?: boolean; type: "shared" | "personal"; includeImports?: boolean; @@ -417,7 +427,7 @@ export type TCreateManySecretsRawFnFactory = { kmsService: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" + "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" | "find" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; @@ -454,7 +464,7 @@ export type TUpdateManySecretsRawFnFactory = { kmsService: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, - "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" + "insertMany" | "upsertSecretReferences" | "findBySecretKeys" | "bulkUpdate" | "deleteMany" | "find" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 8e77858a5..8e4544c19 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -1,9 +1,9 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas"; +import { SecretVersionsSchema, TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; @@ -12,6 +12,50 @@ export type TSecretVersionDALFactory = ReturnType { const secretVersionOrm = ormify(db, TableName.SecretVersion); + const findBySecretId = async (secretId: string, { offset, limit, sort, tx }: TFindOpt = {}) => { + try { + const query = (tx || db.replicaNode())(TableName.SecretVersion) + .where(`${TableName.SecretVersion}.secretId`, secretId) + .leftJoin(TableName.Secret, `${TableName.SecretVersion}.secretId`, `${TableName.Secret}.id`) + .leftJoin(TableName.JnSecretTag, `${TableName.Secret}.id`, `${TableName.JnSecretTag}.${TableName.Secret}Id`) + .leftJoin(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .select(selectAllTableCols(TableName.SecretVersion)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const docs = await query; + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretVersionsSchema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + } + ] + }); + + return data; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SecretVersion}: FindBySecretId` }); + } + }; + // This will fetch all latest secret versions from a folder const findLatestVersionByFolderId = async (folderId: string, tx?: Knex) => { try { @@ -149,6 +193,7 @@ export const secretVersionDALFactory = (db: TDbClient) => { findLatestVersionMany, bulkUpdate, findLatestVersionByFolderId, + findBySecretId, bulkUpdateNoVersionIncrement }; }; diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 654917feb..9b87c29f8 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -5,7 +5,11 @@ import bcrypt from "bcrypt"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionActions, + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; @@ -67,7 +71,7 @@ export const serviceTokenServiceFactory = ({ scopes.forEach(({ environment, secretPath }) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); }); diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 166f73317..8efdfd7f9 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -271,12 +271,13 @@ export const superAdminServiceFactory = ({ return { token, user: userInfo, organization }; }; - const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => { + const getUsers = ({ offset, limit, searchTerm, adminsOnly }: TAdminGetUsersDTO) => { return userDAL.getUsersByFilter({ limit, offset, searchTerm, - sortBy: "username" + sortBy: "username", + adminsOnly }); }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 2d10941b4..d6de67e59 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -20,6 +20,7 @@ export type TAdminGetUsersDTO = { offset: number; limit: number; searchTerm: string; + adminsOnly: boolean; }; export enum LoginMethod { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 99f403e84..eba497f0f 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -23,15 +23,18 @@ export const userDALFactory = (db: TDbClient) => { limit, offset, searchTerm, - sortBy + sortBy, + adminsOnly }: { limit: number; offset: number; searchTerm: string; sortBy?: keyof TUsers; + adminsOnly: boolean; }) => { try { let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); + if (searchTerm) { query = query.where((qb) => { void qb @@ -42,6 +45,10 @@ export const userDALFactory = (db: TDbClient) => { }); } + if (adminsOnly) { + query = query.where("superAdmin", true); + } + if (sortBy) { query = query.orderBy(sortBy); } diff --git a/cli/go.sum b/cli/go.sum index ab46476d7..b72858e9d 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -868,4 +868,4 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= \ No newline at end of file diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index d796ffede..d41c2c20e 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -1,10 +1,6 @@ package cmd import ( - // "fmt" - - // "github.com/Infisical/infisical-merge/packages/api" - // "github.com/Infisical/infisical-merge/packages/models" "context" "fmt" "os" @@ -14,13 +10,8 @@ import ( "github.com/Infisical/infisical-merge/packages/gateway" "github.com/Infisical/infisical-merge/packages/util" - "github.com/rs/zerolog/log" - - // "github.com/Infisical/infisical-merge/packages/visualize" - // "github.com/rs/zerolog/log" - - // "github.com/go-resty/resty/v2" "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -40,6 +31,16 @@ var gatewayCmd = &cobra.Command{ util.HandleError(fmt.Errorf("Token not found")) } + domain, err := cmd.Flags().GetString("domain") + if err != nil { + util.HandleError(err, "Unable to parse domain flag") + } + + // Try to install systemd service if possible + if err := gateway.InstallGatewaySystemdService(token.Token, domain); err != nil { + log.Warn().Msgf("Failed to install systemd service: %v", err) + } + Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION)) sigCh := make(chan os.Signal, 1) diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index bcf977d6d..7a5403a12 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -79,13 +79,9 @@ func (g *Gateway) ConnectWithRelay() error { // 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) @@ -193,7 +189,7 @@ func (g *Gateway) Listen(ctx context.Context) error { log.Printf("Listener started on %s", quicListener.Addr()) - g.registerRelayIsActive(ctx, quicListener.Addr().String(), errCh) + g.registerRelayIsActive(ctx, errCh) log.Info().Msg("Gateway started successfully") @@ -330,7 +326,7 @@ func (g *Gateway) createPermissionForStaticIps(staticIps string) error { return nil } -func (g *Gateway) registerRelayIsActive(ctx context.Context, addr string, errCh chan error) error { +func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) error { ticker := time.NewTicker(15 * time.Second) maxFailures := 3 failures := 0 @@ -345,13 +341,7 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, addr string, errCh return case <-ticker.C: 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) + err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp) if err != nil && !strings.Contains(err.Error(), "tls:") { failures++ log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions") @@ -361,9 +351,6 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, addr string, errCh } continue } - if conn != nil { - defer conn.CloseWithError(0, "All good") - } } } }() diff --git a/cli/packages/gateway/systemd.go b/cli/packages/gateway/systemd.go new file mode 100644 index 000000000..8183f3535 --- /dev/null +++ b/cli/packages/gateway/systemd.go @@ -0,0 +1,82 @@ +package gateway + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/rs/zerolog/log" +) + +const systemdServiceTemplate = `[Unit] +Description=Infisical Gateway Service +After=network.target + +[Service] +Type=simple +EnvironmentFile=/etc/infisical/gateway.conf +ExecStart=/usr/local/bin/infisical gateway +Restart=on-failure +InaccessibleDirectories=/home +PrivateTmp=yes +LimitCORE=infinity +LimitNOFILE=1000000 +LimitNPROC=60000 +LimitRTPRIO=infinity +LimitRTTIME=7000000 + +[Install] +WantedBy=multi-user.target +` + +func InstallGatewaySystemdService(token string, domain string) error { + if runtime.GOOS != "linux" { + log.Info().Msg("Skipping systemd service installation - not on Linux") + return nil + } + + if os.Geteuid() != 0 { + log.Info().Msg("Skipping systemd service installation - not running as root/sudo") + return nil + } + + configDir := "/etc/infisical" + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %v", err) + } + + configContent := fmt.Sprintf("INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN=%s\n", token) + if domain != "" { + configContent += fmt.Sprintf("INFISICAL_API_URL=%s\n", domain) + } else { + configContent += "INFISICAL_API_URL=\n" + } + + configPath := filepath.Join(configDir, "gateway.conf") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + return fmt.Errorf("failed to write config file: %v", err) + } + + servicePath := "/etc/systemd/system/infisical-gateway.service" + if _, err := os.Stat(servicePath); err == nil { + log.Info().Msg("Systemd service file already exists") + return nil + } + + if err := os.WriteFile(servicePath, []byte(systemdServiceTemplate), 0644); err != nil { + return fmt.Errorf("failed to write systemd service file: %v", err) + } + + reloadCmd := exec.Command("systemctl", "daemon-reload") + if err := reloadCmd.Run(); err != nil { + return fmt.Errorf("failed to reload systemd: %v", err) + } + + log.Info().Msg("Successfully installed systemd service") + log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway") + log.Info().Msg("To enable the service on boot, run: sudo systemctl enable infisical-gateway") + + return nil +} diff --git a/frontend/src/components/auth/DonwloadBackupPDFStep.tsx b/frontend/src/components/auth/DonwloadBackupPDFStep.tsx deleted file mode 100644 index b11ca8bd2..000000000 --- a/frontend/src/components/auth/DonwloadBackupPDFStep.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { useToggle } from "@app/hooks"; -import { generateUserBackupKey } from "@app/lib/crypto"; - -import { createNotification } from "../notifications"; -import { generateBackupPDFAsync } from "../utilities/generateBackupPDF"; -import { Button } from "../v2"; - -interface DownloadBackupPDFStepProps { - incrementStep: () => void; - email: string; - password: string; - name: string; -} - -/** - * This is the step of the signup flow where the user downloads the backup pdf - * @param {object} obj - * @param {function} obj.incrementStep - function that moves the user on to the next stage of signup - * @param {string} obj.email - user's email - * @param {string} obj.password - user's password - * @param {string} obj.name - user's name - * @returns - */ -export default function DonwloadBackupPDFStep({ - incrementStep, - email, - password, - name -}: DownloadBackupPDFStepProps): JSX.Element { - const { t } = useTranslation(); - - const [isLoading, setIsLoading] = useToggle(); - - const handleBackupKeyGenerate = async () => { - try { - setIsLoading.on(); - const generatedKey = await generateUserBackupKey(email, password); - await generateBackupPDFAsync({ - generatedKey, - personalEmail: email, - personalName: name - }); - incrementStep(); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to generate backup key" - }); - } finally { - setIsLoading.off(); - } - }; - - return ( -
-

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

-
-
- - {t("signup.step4-description1")} {t("signup.step4-description3")} - -
-
-
- -
-
-
-
- ); -} diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx b/frontend/src/components/project/ProjectOverviewChangeSection.tsx similarity index 58% rename from frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx rename to frontend/src/components/project/ProjectOverviewChangeSection.tsx index d82415c0d..0f88ec2e9 100644 --- a/frontend/src/pages/cert-manager/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" + /> + )} + +
+
+ )}
{ + if (error instanceof AxiosError) { + const err = error?.response?.data as TApiErrors; + + if (err?.error === ApiErrorTypes.CustomForbiddenError) { + createNotification({ + title: "You don't have permission to view reference tree", + text: "You don't have permission to view one or more of the referenced secrets.", + type: "error" + }); + return; + } + createNotification({ + title: "Error fetching secret reference tree", + text: "Please try again later.", + type: "error" + }); + } + }, [error]); + if (isPending) { return (
@@ -114,11 +142,16 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
- {tree && ( + {isError ? ( +
+ +

Error fetching secret reference tree

+
+ ) : tree ? (
- )} + ) : null}
Click a secret key to view its sub-references. diff --git a/frontend/src/components/utilities/checks/password/PasswordCheck.ts b/frontend/src/components/utilities/checks/password/PasswordCheck.ts index e37abd475..fb5186220 100644 --- a/frontend/src/components/utilities/checks/password/PasswordCheck.ts +++ b/frontend/src/components/utilities/checks/password/PasswordCheck.ts @@ -34,12 +34,12 @@ const passwordCheck = async ({ const tests = [ { name: "tooShort", - validator: (pwd: string) => pwd.length >= 14, + validator: (pwd: string) => pwd?.length >= 14, setError: setPasswordErrorTooShort }, { name: "tooLong", - validator: (pwd: string) => pwd.length < 101, + validator: (pwd: string) => pwd?.length < 101, setError: setPasswordErrorTooLong }, { diff --git a/frontend/src/components/v2/Blur/Blur.tsx b/frontend/src/components/v2/Blur/Blur.tsx new file mode 100644 index 000000000..bd1ded40a --- /dev/null +++ b/frontend/src/components/v2/Blur/Blur.tsx @@ -0,0 +1,22 @@ +import { twMerge } from "tailwind-merge"; + +import { Tooltip } from "../Tooltip/Tooltip"; + +interface IProps { + className?: string; + tooltipText?: string; +} + +export const Blur = ({ className, tooltipText }: IProps) => { + return ( + +
+ ******** +
+
+ ); +}; diff --git a/frontend/src/components/v2/Blur/index.tsx b/frontend/src/components/v2/Blur/index.tsx new file mode 100644 index 000000000..50fb7ccc6 --- /dev/null +++ b/frontend/src/components/v2/Blur/index.tsx @@ -0,0 +1 @@ +export { Blur } from "./Blur"; diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index 6fbe96238..dd6d3575e 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -120,6 +120,7 @@ export const InfisicalSecretInput = forwardRef( const isPopupOpen = Boolean(suggestionSource.isOpen) && isFocused; const { data: secrets } = useGetProjectSecrets({ + viewSecretValue: false, environment: suggestionSource.environment || "", secretPath: suggestionSource.secretPath || "", workspaceId, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 6d274734e..0e09a3a12 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -7,6 +7,15 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum ProjectPermissionSecretActions { + DescribeAndReadValue = "read", + DescribeSecret = "describeSecret", + ReadValue = "readValue", + Create = "create", + Edit = "edit", + Delete = "delete" +} + export enum ProjectPermissionDynamicSecretActions { ReadRootCredential = "read-root-credential", CreateRootCredential = "create-root-credential", @@ -162,7 +171,7 @@ export type SecretImportSubjectFields = { export type ProjectPermissionSet = | [ - ProjectPermissionActions, + ProjectPermissionSecretActions, ( | ProjectPermissionSub.Secrets | (ForcedSubject & SecretSubjectFields) diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index e5d281fc4..80e3edc92 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -50,6 +50,7 @@ export type TUpdateAdminSlackConfigDTO = { export type AdminGetUsersFilters = { limit: number; searchTerm: string; + adminsOnly: boolean; }; export type AdminSlackConfig = { diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 66688cbdc..392f7b1f5 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -2,6 +2,8 @@ export { useGetAuthToken, useOauthTokenExchange, useResetPassword, + useResetPasswordV2, + useResetUserPasswordV2, useSelectOrganization, useSendMfaToken, useSendPasswordResetEmail, diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 9b8afbac7..796fd3152 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -22,12 +22,15 @@ import { LoginLDAPRes, MfaMethod, ResetPasswordDTO, + ResetPasswordV2DTO, + ResetUserPasswordV2DTO, SendMfaTokenDTO, SetupPasswordDTO, SRP1DTO, SRPR1Res, TOauthTokenExchangeDTO, UserAgentType, + UserEncryptionVersion, VerifyMfaTokenDTO, VerifyMfaTokenRes, VerifySignupInviteDTO @@ -247,7 +250,10 @@ export const useSendPasswordResetEmail = () => { export const useVerifyPasswordResetCode = () => { return useMutation({ mutationFn: async ({ email, code }: { email: string; code: string }) => { - const { data } = await apiRequest.post("/api/v1/password/email/password-reset-verify", { + const { data } = await apiRequest.post<{ + token: string; + userEncryptionVersion: UserEncryptionVersion; + }>("/api/v1/password/email/password-reset-verify", { email, code }); @@ -302,6 +308,26 @@ export const useResetPassword = () => { }); }; +export const useResetPasswordV2 = () => { + return useMutation({ + mutationFn: async (details: ResetPasswordV2DTO) => { + await apiRequest.post("/api/v2/password/password-reset", details, { + headers: { + Authorization: `Bearer ${details.verificationToken}` + } + }); + } + }); +}; + +export const useResetUserPasswordV2 = () => { + return useMutation({ + mutationFn: async (details: ResetUserPasswordV2DTO) => { + await apiRequest.post("/api/v2/password/user/password-reset", details); + } + }); +}; + export const changePassword = async (details: ChangePasswordDTO) => { const { data } = await apiRequest.post("/api/v1/password/change-password", details); return data; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 036897fed..32610c28d 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -3,6 +3,11 @@ export type GetAuthTokenAPI = { organizationId?: string; }; +export enum UserEncryptionVersion { + V1 = 1, + V2 = 2 +} + export type SendMfaTokenDTO = { email: string; }; @@ -136,6 +141,16 @@ export type ResetPasswordDTO = { password: string; }; +export type ResetPasswordV2DTO = { + newPassword: string; + verificationToken: string; +}; + +export type ResetUserPasswordV2DTO = { + oldPassword: string; + newPassword: string; +}; + export type SetupPasswordDTO = { protectedKey: string; protectedKeyIV: string; diff --git a/frontend/src/hooks/api/dashboard/index.ts b/frontend/src/hooks/api/dashboard/index.ts index 83206bdf8..bbd70e306 100644 --- a/frontend/src/hooks/api/dashboard/index.ts +++ b/frontend/src/hooks/api/dashboard/index.ts @@ -1,4 +1,5 @@ export { + useGetAccessibleSecrets, useGetProjectSecretsDetails, useGetProjectSecretsOverview, useGetProjectSecretsQuickSearch diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index 4b63be988..1fb456325 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -11,6 +11,7 @@ import { DashboardSecretsOrderBy, TDashboardProjectSecretsQuickSearch, TDashboardProjectSecretsQuickSearchResponse, + TGetAccessibleSecretsDTO, TGetDashboardProjectSecretsByKeys, TGetDashboardProjectSecretsDetailsDTO, TGetDashboardProjectSecretsOverviewDTO, @@ -20,6 +21,8 @@ import { OrderByDirection } from "@app/hooks/api/generic/types"; import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; import { groupBy, unique } from "@app/lib/fn/array"; +import { SecretV3Raw } from "../types"; + export const dashboardKeys = { all: () => ["dashboard"] as const, getDashboardSecrets: ({ @@ -58,6 +61,17 @@ export const dashboardKeys = { ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), "quick-search", params + ] as const, + getAccessibleSecrets: ({ + projectId, + secretPath, + environment, + filterByAction + }: TGetAccessibleSecretsDTO) => + [ + ...dashboardKeys.all(), + "accessible-secrets", + { projectId, secretPath, environment, filterByAction } ] as const }; @@ -207,6 +221,7 @@ export const useGetProjectSecretsDetails = ( search = "", includeSecrets, includeFolders, + viewSecretValue, includeImports, includeDynamicSecrets, tags @@ -231,6 +246,7 @@ export const useGetProjectSecretsDetails = ( limit, orderBy, orderDirection, + viewSecretValue, offset, projectId, environment, @@ -247,6 +263,7 @@ export const useGetProjectSecretsDetails = ( limit, orderBy, orderDirection, + viewSecretValue, offset, projectId, environment, @@ -292,6 +309,22 @@ export const fetchProjectSecretsQuickSearch = async ({ return data; }; +const fetchAccessibleSecrets = async ({ + projectId, + secretPath, + environment, + filterByAction +}: TGetAccessibleSecretsDTO) => { + const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>( + "/api/v1/dashboard/accessible-secrets", + { + params: { projectId, secretPath, environment, filterByAction } + } + ); + + return data.secrets; +}; + export const useGetProjectSecretsQuickSearch = ( { projectId, @@ -354,3 +387,32 @@ export const useGetProjectSecretsQuickSearch = ( placeholderData: (previousData) => previousData }); }; + +export const useGetAccessibleSecrets = ({ + projectId, + secretPath, + environment, + filterByAction, + options +}: TGetAccessibleSecretsDTO & { + options?: Omit< + UseQueryOptions< + SecretV3Raw[], + unknown, + SecretV3Raw[], + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => { + return useQuery({ + ...options, + queryKey: dashboardKeys.getAccessibleSecrets({ + projectId, + secretPath, + environment, + filterByAction + }), + queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction }) + }); +}; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 786b2b43a..9540c4ae6 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -1,3 +1,4 @@ +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; @@ -69,6 +70,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit< TGetDashboardProjectSecretsOverviewDTO, "environments" > & { + viewSecretValue: boolean; environment: string; includeImports?: boolean; tags: Record; @@ -100,3 +102,12 @@ export type TGetDashboardProjectSecretsByKeys = { environment: string; keys: string[]; }; + +export type TGetAccessibleSecretsDTO = { + projectId: string; + secretPath: string; + environment: string; + filterByAction: + | ProjectPermissionSecretActions.DescribeSecret + | ProjectPermissionSecretActions.ReadValue; +}; diff --git a/frontend/src/hooks/api/reactQuery.tsx b/frontend/src/hooks/api/reactQuery.tsx index be9dadbec..cd8622976 100644 --- a/frontend/src/hooks/api/reactQuery.tsx +++ b/frontend/src/hooks/api/reactQuery.tsx @@ -10,18 +10,14 @@ import { formatedConditionsOperatorNames, PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; +import { camelCaseToSpaces } from "@app/lib/fn/string"; import { ApiErrorTypes, TApiErrors } from "./types"; - // this is saved in react-query cache export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"]; export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"]; export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"]; -const camelCaseToSpaces = (input: string) => { - return input.replace(/([a-z])([A-Z])/g, "$1 $2"); -}; - export const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: (error) => { diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index 74acf744b..39fa1058a 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -79,6 +79,7 @@ export const decryptSecrets = ( id: encSecret.id, env: encSecret.environment, key: secretKey, + secretValueHidden: encSecret.secretValueHidden, value: secretValue, tags: encSecret.tags, comment: secretComment, diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index c2ddd2fbe..dfc8c6497 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -137,6 +137,7 @@ export const useGetImportedSecretsSingleEnv = ({ env: encSecret.environment, key: encSecret.secretKey, value: encSecret.secretValue, + secretValueHidden: encSecret.secretValueHidden, tags: encSecret.tags, comment: encSecret.secretComment, createdAt: encSecret.createdAt, @@ -176,6 +177,7 @@ export const useGetImportedSecretsAllEnvs = ({ env: encSecret.environment, key: encSecret.secretKey, value: encSecret.secretValue, + secretValueHidden: encSecret.secretValueHidden, tags: encSecret.tags, comment: encSecret.secretComment, createdAt: encSecret.createdAt, diff --git a/frontend/src/hooks/api/secretSnapshots/queries.tsx b/frontend/src/hooks/api/secretSnapshots/queries.tsx index 8a16b4180..d273bd5ea 100644 --- a/frontend/src/hooks/api/secretSnapshots/queries.tsx +++ b/frontend/src/hooks/api/secretSnapshots/queries.tsx @@ -75,6 +75,7 @@ export const useGetSnapshotSecrets = ({ snapshotId }: TSnapshotDataProps) => id: secretVersion.secretId, env: data.environment.slug, key: secretVersion.secretKey, + secretValueHidden: secretVersion.secretValueHidden, value: secretVersion.secretValue || "", tags: secretVersion.tags, comment: secretVersion.secretComment, diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index a6dd959b0..3e82c3b90 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -26,8 +26,13 @@ import { export const secretKeys = { // this is also used in secretSnapshot part - getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) => - [{ workspaceId, environment, secretPath }, "secrets"] as const, + getProjectSecret: ({ + workspaceId, + environment, + secretPath, + viewSecretValue + }: TGetProjectSecretsKey) => + [{ workspaceId, environment, secretPath, viewSecretValue }, "secrets"] as const, getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const, getSecretAccessList: ({ workspaceId, @@ -44,13 +49,15 @@ export const fetchProjectSecrets = async ({ environment, secretPath, includeImports, - expandSecretReferences + expandSecretReferences, + viewSecretValue }: TGetProjectSecretsKey) => { const { data } = await apiRequest.get("/api/v3/secrets/raw", { params: { environment, workspaceId, secretPath, + viewSecretValue, expandSecretReferences, include_imports: includeImports } @@ -68,6 +75,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { env: el.environment, key: el.secretKey, value: el.secretValue, + secretValueHidden: el.secretValueHidden, tags: el.tags || [], comment: el.secretComment || "", reminderRepeatDays: el.secretReminderRepeatDays, @@ -107,6 +115,7 @@ export const useGetProjectSecrets = ({ workspaceId, environment, secretPath, + viewSecretValue, options }: TGetProjectSecretsDTO & { options?: Omit< @@ -123,8 +132,13 @@ export const useGetProjectSecrets = ({ ...options, // wait for all values to be available enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true), - queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }), - queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }), + queryKey: secretKeys.getProjectSecret({ + workspaceId, + environment, + secretPath, + viewSecretValue + }), + queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath, viewSecretValue }), select: useCallback( (data: Awaited>) => mergePersonalSecrets(data.secrets), [] diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 9e0a82dda..5c4d4ffc6 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -19,6 +19,7 @@ export type EncryptedSecret = { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + secretValueHidden: boolean; __v: number; createdAt: string; updatedAt: string; @@ -37,6 +38,7 @@ export type SecretV3RawSanitized = { version: number; key: string; value?: string; + secretValueHidden: boolean; comment?: string; reminderRepeatDays?: number | null; reminderNote?: string | null; @@ -61,6 +63,7 @@ export type SecretV3Raw = { environment: string; version: number; type: string; + secretValueHidden: boolean; secretKey: string; secretPath: string; secretValue?: string; @@ -95,6 +98,7 @@ export type SecretVersions = { envId: string; secretKey: string; secretValue?: string; + secretValueHidden: boolean; secretComment?: string; tags: WsTag[]; __v: number; @@ -115,6 +119,7 @@ export type TGetProjectSecretsKey = { environment: string; secretPath?: string; includeImports?: boolean; + viewSecretValue?: boolean; expandSecretReferences?: boolean; }; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index cda34ad60..8f2acbbd7 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -47,7 +47,8 @@ export enum ApiErrorTypes { PermissionBoundaryError = "PermissionBoundaryError", BadRequestError = "BadRequest", UnauthorizedError = "UnauthorizedError", - ForbiddenError = "PermissionDenied" + ForbiddenError = "PermissionDenied", + CustomForbiddenError = "ForbiddenError" } export type TApiErrors = @@ -70,6 +71,12 @@ export type TApiErrors = details: PureAbility["rules"]; statusCode: 403; } + | { + reqId: string; + error: ApiErrorTypes.CustomForbiddenError; + message: string; + statusCode: 403; + } | { reqId: string; statusCode: 400; 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/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index 4a255acad..3cc104f48 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -30,10 +30,13 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, + Modal, + ModalContent, Tooltip } from "@app/components/v2"; import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; +import { isInfisicalCloud } from "@app/helpers/platform"; import { usePopUp, useToggle } from "@app/hooks"; import { useGetOrganizations, @@ -50,6 +53,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { MenuIconButton } from "../MenuIconButton"; +import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.dynamicSecret) return "Enterprise Plan"; @@ -77,6 +81,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [ , "Email Support", "mailto:support@infisical.com" + ], + [ + , + "Instance Admins", + "server-admins" ] ]; @@ -89,6 +98,7 @@ export const MinimizedOrgSidebar = () => { const [openSupport, setOpenSupport] = useState(false); const [openUser, setOpenUser] = useState(false); const [openOrg, setOpenOrg] = useState(false); + const [showAdminsModal, setShowAdminsModal] = useState(false); const { user } = useUser(); const { mutateAsync } = useGetOrgTrialUrl(); @@ -410,21 +420,39 @@ export const MinimizedOrgSidebar = () => { side="right" className="p-1" > - {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => ( - - -
- {icon} -
{text}
-
-
-
- ))} + {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => { + if (url === "server-admins" && isInfisicalCloud()) { + return null; + } + return ( + + {url === "server-admins" ? ( + + ) : ( + +
+ {icon} +
{text}
+
+
+ )} +
+ ); + })} {envConfig.PLATFORM_VERSION && (
@@ -540,6 +568,13 @@ export const MinimizedOrgSidebar = () => {
+ + +
+ +
+
+
handlePopUpToggle("createOrg", false)} diff --git a/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx b/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx new file mode 100644 index 000000000..c834daad8 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useGetOrgUsers } from "@app/hooks/api"; + +export const ServerAdminsPanel = () => { + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); + const { currentOrg } = useOrganization(); + + const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || ""); + + const adminUsers = orgUsers?.filter((orgUser) => { + const isSuperAdmin = orgUser.user.superAdmin; + const matchesSearch = debounedSearchTerm + ? orgUser.user.email?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || + orgUser.user.firstName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || + orgUser.user.lastName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) + : true; + return isSuperAdmin && matchesSearch; + }); + + const isEmpty = !isPending && (!adminUsers || adminUsers.length === 0); + + return ( +
+
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search server admins..." + className="w-full" + /> +
+
+ + + + + + + + + + {isPending && } + {!isPending && + adminUsers?.map(({ user }) => { + const name = + user.firstName || user.lastName + ? `${user.firstName} ${user.lastName}` + : user.username; + return ( + + + + + ); + })} + +
NameEmail
{name}{user.email}
+ {isEmpty && ( +
+ No server administrators found +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/lib/fn/permission.ts b/frontend/src/lib/fn/permission.ts new file mode 100644 index 000000000..c3937a0ff --- /dev/null +++ b/frontend/src/lib/fn/permission.ts @@ -0,0 +1,36 @@ +import { MongoAbility, subject } from "@casl/ability"; + +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSub, + SecretSubjectFields +} from "@app/context/ProjectPermissionContext/types"; + +export function hasSecretReadValueOrDescribePermission( + permission: MongoAbility, + action: Extract< + ProjectPermissionSecretActions, + ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue + >, + subjectFields?: SecretSubjectFields +) { + let canNewPermission = false; + let canOldPermission = false; + + if (subjectFields) { + canNewPermission = permission.can(action, subject(ProjectPermissionSub.Secrets, subjectFields)); + canOldPermission = permission.can( + ProjectPermissionSecretActions.DescribeAndReadValue, + subject(ProjectPermissionSub.Secrets, subjectFields) + ); + } else { + canNewPermission = permission.can(action, ProjectPermissionSub.Secrets); + canOldPermission = permission.can( + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSub.Secrets + ); + } + + return canNewPermission || canOldPermission; +} diff --git a/frontend/src/lib/fn/string.ts b/frontend/src/lib/fn/string.ts index 9d3d01cc5..f4c4a43db 100644 --- a/frontend/src/lib/fn/string.ts +++ b/frontend/src/lib/fn/string.ts @@ -7,3 +7,7 @@ export const formatReservedPaths = (secretPath: string) => { } return secretPath; }; + +export const camelCaseToSpaces = (input: string) => { + return input.replace(/([a-z])([A-Z])/g, "$1 $2"); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx index df51216a6..6654439de 100644 --- a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx @@ -1,6 +1,14 @@ import { useState } from "react"; -import { faMagnifyingGlass, faUsers, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { + faCheckCircle, + faEllipsis, + faFilter, + faMagnifyingGlass, + faUsers, + faUserShield +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -8,7 +16,13 @@ import { Badge, Button, DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, EmptyState, + IconButton, Input, Table, TableContainer, @@ -17,11 +31,7 @@ import { Td, Th, THead, - Tr, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger + Tr } from "@app/components/v2"; import { useSubscription, useUser } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; @@ -48,6 +58,7 @@ const UserPanelTable = ({ ) => void; }) => { const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); const { user } = useUser(); const userId = user?.id || ""; const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); @@ -55,18 +66,55 @@ const UserPanelTable = ({ const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ limit: 20, - searchTerm: debounedSearchTerm + searchTerm: debounedSearchTerm, + adminsOnly }); const isEmpty = !isPending && !data?.pages?.[0].length; + const isTableFiltered = Boolean(adminsOnly); + return ( <> - setSearchUserFilter(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> +
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search users..." + className="flex-1" + /> + + + + + + + + Filter By + { + e.preventDefault(); + setAdminsOnly(!adminsOnly); + }} + icon={adminsOnly && } + iconPos="right" + > +
+ + Server Admins +
+
+
+
+
diff --git a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx index f1c70d028..97b363558 100644 --- a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { Helmet } from "react-helmet"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -8,16 +7,13 @@ import { AnimatePresence, motion } from "framer-motion"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF"; // TODO(akhilmhdh): rewrite this into module functions in lib import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api"; -import { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto"; - -import { DownloadBackupKeys } from "./components/DownloadBackupKeys"; +import { generateUserPassKey } from "@app/lib/crypto"; const formSchema = z .object({ @@ -34,25 +30,17 @@ const formSchema = z type TFormSchema = z.infer; -enum SignupSteps { - DetailsForm = "details-form", - BackupKey = "backup-key" -} - export const SignUpPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { control, handleSubmit, - getValues, formState: { isSubmitting } } = useForm({ resolver: zodResolver(formSchema) }); - const [step, setStep] = useState(SignupSteps.DetailsForm); - const { config } = useServerConfig(); const { mutateAsync: createAdminUser } = useCreateAdminUser(); const { mutateAsync: selectOrganization } = useSelectOrganization(); @@ -84,7 +72,7 @@ export const SignUpPage = () => { // Will be refactored in next iteration to make it url based rather than local storage ones // Part of migration to nextjs 14 localStorage.setItem("orgData.id", res.organization.id); - setStep(SignupSteps.BackupKey); + navigate({ to: "/admin" }); } catch (err) { console.log(err); createNotification({ @@ -94,27 +82,7 @@ export const SignUpPage = () => { } }; - const handleBackupKeyGenerate = async () => { - try { - const { email, password, firstName, lastName } = getValues(); - const generatedKey = await generateUserBackupKey(email, password); - await generateBackupPDFAsync({ - generatedKey, - personalEmail: email, - personalName: `${firstName} ${lastName}` - }); - navigate({ to: "/admin" }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to generate backup" - }); - } - }; - - if (config?.initialized && step === SignupSteps.DetailsForm) - return ; + if (config?.initialized) return ; return (
@@ -127,56 +95,28 @@ export const SignUpPage = () => {
- {step === SignupSteps.DetailsForm && ( - -
- Infisical logo -
Welcome to Infisical
-
Create your first Super Admin Account
-
-
-
-
- ( - - - - )} - /> - ( - - - - )} - /> -
+ +
+ Infisical logo +
Welcome to Infisical
+
Create your first Super Admin Account
+
+ +
+
( @@ -186,56 +126,66 @@ export const SignUpPage = () => { /> ( - - - )} - /> - ( - - + )} />
- - - - )} - {step === SignupSteps.BackupKey && ( - - - - )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
diff --git a/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx b/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx deleted file mode 100644 index 253031a91..000000000 --- a/frontend/src/pages/admin/SignUpPage/components/DownloadBackupKeys/DownloadBackupKeys.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -type Props = { - onGenerate: () => Promise; -}; - -export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => { - const { t } = useTranslation(); - const [isLoading, setIsLoading] = useToggle(); - - return ( -
-

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

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

- Confirm your email -

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

- Enter your backup key -

-
-

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

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

- Enter new password -

-
-

- Make sure you save it somewhere safe. -

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

+ Confirm your email +

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

+ Enter new password +

+
+

+ Make sure you save it somewhere safe. +

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

+ Enter your backup key +

+
+

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

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

- Save your Emergency Kit -

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

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

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

-

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

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

@@ -102,8 +103,10 @@ export const VerifyEmailPage = () => { Look for an email in your inbox.

-

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

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

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/index.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/index.tsx deleted file mode 100644 index 66120d7fa..000000000 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection"; diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/index.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/index.tsx index 2f355dc94..04a624ec2 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/components/index.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/components/index.tsx @@ -1,2 +1 @@ export { DeleteProjectSection } from "./DeleteProjectSection"; -export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection"; diff --git a/frontend/src/pages/kms/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/pages/kms/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index ef684a21b..41e8107e0 100644 --- a/frontend/src/pages/kms/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/pages/kms/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/kms/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx b/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx deleted file mode 100644 index 34fe365ec..000000000 --- a/frontend/src/pages/kms/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/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx b/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx deleted file mode 100644 index d82415c0d..000000000 --- a/frontend/src/pages/kms/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) => ( - ( - -