diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..ae4b4fb80 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +# Learn more at https://direnv.net +# We instruct direnv to use our Nix flake for a consistent development environment. +use flake 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/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 621c3c631..449f6d8f6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +export type PasswordRequirements = { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; +}; + export enum SqlProviders { Postgres = "postgres", MySQL = "mysql2", @@ -100,6 +111,28 @@ export const DynamicSecretSqlDBSchema = z.object({ database: z.string().trim(), username: z.string().trim(), password: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements"), creationStatement: z.string().trim(), revocationStatement: z.string().trim(), renewStatement: z.string().trim().optional(), diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 68089ea4c..eea9fef94 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -1,6 +1,6 @@ +import { randomInt } from "crypto"; import handlebars from "handlebars"; import knex from "knex"; -import { customAlphabet } from "nanoid"; import { z } from "zod"; import { withGatewayProxy } from "@app/lib/gateway"; @@ -8,16 +8,99 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; -import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models"; +import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; -const generatePassword = (provider: SqlProviders) => { - // oracle has limit of 48 password length - const size = provider === SqlProviders.Oracle ? 30 : 48; +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; - return customAlphabet(charset, 48)(size); +const ORACLE_PASSWORD_REQUIREMENTS = { + ...DEFAULT_PASSWORD_REQUIREMENTS, + length: 30 +}; + +const generatePassword = (provider: SqlProviders, requirements?: PasswordRequirements) => { + const defaultReqs = provider === SqlProviders.Oracle ? ORACLE_PASSWORD_REQUIREMENTS : DEFAULT_PASSWORD_REQUIREMENTS; + const finalReqs = requirements || defaultReqs; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } }; const generateUsername = (provider: SqlProviders) => { @@ -115,7 +198,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); const username = generateUsername(providerInputs.client); - const password = generatePassword(providerInputs.client); + const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); try { diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 80a58db0a..cccadb86e 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -1,7 +1,109 @@ +/* eslint-disable no-nested-ternary */ +import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability"; +import { z } from "zod"; + import { TOrganizations } from "@app/db/schemas"; -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 { + 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; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 4389c3866..c622e65a7 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", @@ -115,7 +124,7 @@ export type IdentityManagementSubjectFields = { export type ProjectPermissionSet = | [ - ProjectPermissionActions, + ProjectPermissionSecretActions, ProjectPermissionSub.Secrets | (ForcedSubject & SecretSubjectFields) ] | [ @@ -429,6 +438,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."), @@ -460,7 +470,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( @@ -517,7 +527,6 @@ const buildAdminPermissionRules = () => { // Admins get full access to everything [ - ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretApproval, @@ -550,10 +559,22 @@ const buildAdminPermissionRules = () => { ProjectPermissionActions.Create, ProjectPermissionActions.Delete ], - el as ProjectPermissionSub + el ); }); + can( + [ + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ], + ProjectPermissionSub.Secrets + ); + can( [ ProjectPermissionDynamicSecretActions.ReadRootCredential, @@ -613,10 +634,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 ); @@ -788,7 +811,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); @@ -837,7 +862,6 @@ export const buildServiceTokenProjectPermission = ( (subject) => { if (canWrite) { can(ProjectPermissionActions.Edit, subject, { - // TODO: @Akhi // @ts-expect-error type secretPath: { $glob: secretPath }, environment @@ -916,7 +940,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 @@ -958,6 +992,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..98cb13865 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 @@ -6,6 +6,7 @@ import { SecretEncryptionAlgo, SecretKeyEncoding, SecretType, + TableName, TSecretApprovalRequestsSecretsInsert, TSecretApprovalRequestsSecretsV2Insert } from "@app/db/schemas"; @@ -57,8 +58,9 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; 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"; @@ -88,7 +90,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 +113,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 +919,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 +1008,7 @@ export const secretApprovalRequestServiceFactory = ({ : keyName2BlindIndex[secretName]; // add tags if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds; + return { ...latestSecretVersions[secretId], ...el, @@ -1327,17 +1336,48 @@ export const secretApprovalRequestServiceFactory = ({ // deleted secrets const deletedSecrets = data[SecretOperations.Delete]; if (deletedSecrets && deletedSecrets.length) { - const secretsToDeleteInDB = await secretV2BridgeDAL.findBySecretKeys( + const secretsToDeleteInDB = await secretV2BridgeDAL.find({ folderId, - deletedSecrets.map((el) => ({ - key: el.secretKey, - type: SecretType.Shared - })) - ); + $complex: { + operator: "and", + value: [ + { + operator: "or", + value: deletedSecrets.map((el) => ({ + operator: "and", + value: [ + { + operator: "eq", + field: `${TableName.SecretV2}.key` as "key", + value: el.secretKey + }, + { + operator: "eq", + field: "type", + value: SecretType.Shared + } + ] + })) + } + ] + } + }); if (secretsToDeleteInDB.length !== deletedSecrets.length) throw new NotFoundError({ message: `Secret does not exist: ${secretsToDeleteInDB.map((el) => el.key).join(",")}` }); + secretsToDeleteInDB.forEach((el) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: el.key, + secretTags: el.tags?.map((i) => i.slug) + }) + ); + }); + const secretsGroupedByKey = groupBy(secretsToDeleteInDB, (i) => i.key); const deletedSecretIds = deletedSecrets.map((el) => secretsGroupedByKey[el.secretKey][0].id); const latestSecretVersions = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, deletedSecretIds); @@ -1363,9 +1403,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) return; // we do the validation on top 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 94076cf25..fe893d5ab 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -667,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." }, @@ -690,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: @@ -718,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/lib/schemas.ts b/backend/src/server/lib/schemas.ts index ed97cb7d0..0d8eae848 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -21,3 +21,10 @@ export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInpu message: `${field} field can only contain lowercase letters, numbers, and hyphens` }); }; + +export const GenericResourceNameSchema = z + .string() + .trim() + .min(1, { message: "Name must be at least 1 character" }) + .max(64, { message: "Name must be 64 or fewer characters" }) + .regex(/^[a-zA-Z0-9\-_\s]+$/, "Name can only contain alphanumeric characters, dashes, underscores, and spaces"); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index efc1cb865..b9f47cb7e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -635,6 +635,7 @@ export const registerRoutes = async ( }); const superAdminService = superAdminServiceFactory({ userDAL, + identityDAL, userAliasDAL, authService: loginService, serverCfgDAL: superAdminDAL, 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..a1c433650 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,7 +1,7 @@ import DOMPurify from "isomorphic-dompurify"; import { z } from "zod"; -import { OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; +import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -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({ @@ -149,6 +154,43 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/identity-management/identities", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + searchTerm: z.string().default(""), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(20) + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ + name: true, + id: true + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const identities = await server.services.superAdmin.getIdentities({ + ...req.query + }); + + return { + identities + }; + } + }); + server.route({ method: "GET", url: "/integrations/slack/config", 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/index.ts b/backend/src/server/routes/v1/index.ts index 087bd9afd..50fd33840 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -91,7 +91,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await projectRouter.register(registerProjectMembershipRouter); await projectRouter.register(registerSecretTagRouter); }, - { prefix: "/workspace" } ); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 0b6f7c2e1..3cc17a3d8 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -13,7 +13,7 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { slugSchema } from "@app/server/lib/schemas"; +import { GenericResourceNameSchema, slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; @@ -251,7 +251,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ - name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), + name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), 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 dcedf5fa5..b44e93a66 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { IntegrationsSchema, + ProjectEnvironmentsSchema, ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, ProjectType, + SecretFoldersSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; @@ -675,4 +677,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return slackConfig; } }); + + server.route({ + method: "GET", + url: "/:workspaceId/environment-folder-tree", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.record( + ProjectEnvironmentsSchema.extend({ folders: SecretFoldersSchema.extend({ path: z.string() }).array() }) + ) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const environmentsFolders = await server.services.folder.getProjectEnvironmentsFolders( + req.params.workspaceId, + req.permission + ); + + return environmentsFolders; + } + }); }; 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/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 8ca105ad4..326b8a497 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -12,6 +12,7 @@ import { import { ORGANIZATIONS } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -330,7 +331,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - name: z.string().trim() + name: GenericResourceNameSchema }), response: { 200: z.object({ 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..63b6d8aac --- /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 { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns"; +import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type"; +import { AuthMode } from "@app/services/auth/auth-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/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index e95254816..d9196dc88 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -4,6 +4,7 @@ import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError } from "@app/lib/errors"; import { authRateLimit } from "@app/server/config/rateLimiter"; +import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -100,7 +101,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), verifier: z.string().trim(), - organizationName: z.string().trim().min(1), + organizationName: GenericResourceNameSchema, providerAuthToken: z.string().trim().optional().nullish(), attributionSource: z.string().trim().optional(), password: z.string() 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..14fb58258 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -4,7 +4,10 @@ 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 { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; @@ -12,10 +15,13 @@ 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"; @@ -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/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 2c9b6306e..555dc00a6 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -78,14 +78,22 @@ export const identityJwtAuthServiceFactory = ({ let tokenData: Record = {}; if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { - const decryptedJwksCaCert = orgDataKeyDecryptor({ - cipherTextBlob: identityJwtAuth.encryptedJwksCaCert - }).toString(); - const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); - const client = new JwksClient({ - jwksUri: identityJwtAuth.jwksUrl, - requestAgent - }); + let client: JwksClient; + if (identityJwtAuth.jwksUrl.includes("https:")) { + const decryptedJwksCaCert = orgDataKeyDecryptor({ + cipherTextBlob: identityJwtAuth.encryptedJwksCaCert + }).toString(); + + const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); + client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl, + requestAgent + }); + } else { + client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl + }); + } const { kid } = decodedToken.header; const jwtSigningKey = await client.getSigningKey(kid); diff --git a/backend/src/services/identity/identity-dal.ts b/backend/src/services/identity/identity-dal.ts index a74a84ce3..8b7fccab3 100644 --- a/backend/src/services/identity/identity-dal.ts +++ b/backend/src/services/identity/identity-dal.ts @@ -1,10 +1,42 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { TableName, TIdentities } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { DatabaseError } from "@app/lib/errors"; export type TIdentityDALFactory = ReturnType; export const identityDALFactory = (db: TDbClient) => { const identityOrm = ormify(db, TableName.Identity); - return identityOrm; + + const getIdentitiesByFilter = async ({ + limit, + offset, + searchTerm, + sortBy + }: { + limit: number; + offset: number; + searchTerm: string; + sortBy?: keyof TIdentities; + }) => { + try { + let query = db.replicaNode()(TableName.Identity); + + if (searchTerm) { + query = query.where((qb) => { + void qb.whereILike("name", `%${searchTerm}%`); + }); + } + + if (sortBy) { + query = query.orderBy(sortBy); + } + + return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity)); + } catch (error) { + throw new DatabaseError({ error, name: "Get identities by filter" }); + } + }; + + return { ...identityOrm, getIdentitiesByFilter }; }; 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 4b110ebd2..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"; @@ -760,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/secret-folder/secret-folder-fns.ts b/backend/src/services/secret-folder/secret-folder-fns.ts new file mode 100644 index 000000000..a3783a1b9 --- /dev/null +++ b/backend/src/services/secret-folder/secret-folder-fns.ts @@ -0,0 +1,17 @@ +import { TSecretFolders } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +export const buildFolderPath = ( + folder: TSecretFolders, + foldersMap: Record, + depth: number = 0 +): string => { + if (depth > 20) { + throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" }); + } + if (!folder.parentId) { + return depth === 0 ? "/" : ""; + } + + return `${buildFolderPath(foldersMap[folder.parentId], foldersMap, depth + 1)}/${folder.name}`; +}; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index aabc35683..7afbb290f 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -8,6 +8,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -27,7 +28,7 @@ type TSecretFolderServiceFactoryDep = { permissionService: Pick; snapshotService: Pick; folderDAL: TSecretFolderDALFactory; - projectEnvDAL: Pick; + projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; projectDAL: Pick; }; @@ -580,6 +581,44 @@ export const secretFolderServiceFactory = ({ return folders; }; + const getProjectEnvironmentsFolders = async (projectId: string, actor: OrgServiceActor) => { + // folder list is allowed to be read by anyone + // permission is to check if user has access + await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const environments = await projectEnvDAL.find({ projectId }); + + const folders = await folderDAL.find({ + $in: { + envId: environments.map((env) => env.id) + }, + isReserved: false + }); + + const environmentFolders = Object.fromEntries( + environments.map((env) => { + const relevantFolders = folders.filter((folder) => folder.envId === env.id); + const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder])); + + const foldersWithPath = relevantFolders.map((folder) => ({ + ...folder, + path: buildFolderPath(folder, foldersMap) + })); + + return [env.slug, { ...env, folders: foldersWithPath }]; + }) + ); + + return environmentFolders; + }; + return { createFolder, updateFolder, @@ -589,6 +628,7 @@ export const secretFolderServiceFactory = ({ getFolderById, getProjectFolderCount, getFoldersMultiEnv, - getFoldersDeepByEnvs + getFoldersDeepByEnvs, + getProjectEnvironmentsFolders }; }; 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..dc975854f 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,7 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); let paths: { folderId: string; path: string }[] = []; @@ -869,28 +947,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 +1031,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 +1068,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 +1138,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 +1165,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 +1189,7 @@ export const secretV2BridgeServiceFactory = ({ type, secretName, version, + viewSecretValue, includeImports, expandSecretReferences: shouldExpandSecretReferences }: TGetASecretDTO) => { @@ -1100,66 +1250,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 +1344,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 +1356,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 +1457,7 @@ export const secretV2BridgeServiceFactory = ({ inputSecrets.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1330,13 +1532,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 +1598,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 +1659,7 @@ export const secretV2BridgeServiceFactory = ({ secretsToUpdateInDB.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1450,7 +1679,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 +1693,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 +1745,7 @@ export const secretV2BridgeServiceFactory = ({ secretsWithNewName.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1582,6 +1811,7 @@ export const secretV2BridgeServiceFactory = ({ }, resourceMetadataDAL }); + updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); if (updateMode === SecretUpdateMode.Upsert) { const bulkInsertedSecrets = await fnSecretBulkInsert({ @@ -1618,6 +1848,7 @@ export const secretV2BridgeServiceFactory = ({ }, tx }); + updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); } } @@ -1639,13 +1870,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 +1959,7 @@ export const secretV2BridgeServiceFactory = ({ }); secretsToDelete.forEach((el) => { ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath, @@ -1746,13 +1998,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 +2036,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 +2066,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 +2186,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 +2292,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 +2539,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 +2563,16 @@ 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) - }) - ); + if (!secret) throw new NotFoundError({ message: `Secret with name '${secretName}' not found` }); - const secretValue = secret.encryptedValue + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment, + secretPath, + secretName, + secretTags: (secret?.tags || []).map((el) => el.slug) + }); + + const decryptedSecretValue = secret?.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() : ""; @@ -2275,26 +2582,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 +2718,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..6a5c097ed 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { z } from "zod"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } 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 { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -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..bf10d67ab 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -19,9 +19,11 @@ import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; +import { LoginMethod, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; type TSuperAdminServiceFactoryDep = { + identityDAL: Pick; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; userAliasDAL: Pick; @@ -51,6 +53,7 @@ const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; export const superAdminServiceFactory = ({ serverCfgDAL, userDAL, + identityDAL, userAliasDAL, authService, orgService, @@ -271,26 +274,30 @@ 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 }); }; const deleteUser = async (userId: string) => { - if (!licenseService.onPremFeatures?.instanceUserManagement) { - throw new BadRequestError({ - message: "Failed to delete user due to plan restriction. Upgrade to Infisical's Pro plan." - }); - } - const user = await userDAL.deleteById(userId); return user; }; + const getIdentities = ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { + return identityDAL.getIdentitiesByFilter({ + limit, + offset, + searchTerm, + sortBy: "name" + }); + }; + const grantServerAdminAccessToUser = async (userId: string) => { if (!licenseService.onPremFeatures?.instanceUserManagement) { throw new BadRequestError({ @@ -388,6 +395,7 @@ export const superAdminServiceFactory = ({ adminSignUp, getUsers, deleteUser, + getIdentities, getAdminSlackConfig, updateRootEncryptionStrategy, getConfiguredEncryptionStrategies, diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 2d10941b4..54a42c2ca 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -20,6 +20,13 @@ export type TAdminGetUsersDTO = { offset: number; limit: number; searchTerm: string; + adminsOnly: boolean; +}; + +export type TAdminGetIdentitiesDTO = { + offset: number; + limit: number; + searchTerm: string; }; 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.mod b/cli/go.mod index bdb8839d0..b2d0c83ad 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -20,6 +20,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 + github.com/pion/dtls/v3 v3.0.4 github.com/pion/logging v0.2.3 github.com/pion/turn/v4 v4.0.0 github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a @@ -90,7 +91,6 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/onsi/ginkgo/v2 v2.22.2 // indirect github.com/pelletier/go-toml v1.9.3 // indirect - github.com/pion/dtls/v3 v3.0.4 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect diff --git a/cli/go.sum b/cli/go.sum index ab46476d7..5f1f369bb 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -484,8 +484,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -592,8 +590,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -644,13 +640,9 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -662,8 +654,6 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -868,4 +858,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..81baf3910 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -1,39 +1,34 @@ package cmd import ( - // "fmt" - - // "github.com/Infisical/infisical-merge/packages/api" - // "github.com/Infisical/infisical-merge/packages/models" "context" "fmt" "os" + "os/exec" "os/signal" + "runtime" "syscall" "time" "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" ) var gatewayCmd = &cobra.Command{ - Example: `infisical gateway`, - Short: "Used to infisical gateway", Use: "gateway", + Short: "Run the Infisical gateway or manage its systemd service", + Long: "Run the Infisical gateway in the foreground or manage its systemd service installation. Use 'gateway install' to set up the systemd service.", + Example: `infisical gateway --token= + sudo infisical gateway install --token= --domain=`, DisableFlagsInUseLine: true, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { token, err := util.GetInfisicalToken(cmd) if err != nil { - util.HandleError(err, "Unable to parse flag") + util.HandleError(err, "Unable to parse token flag") } if token == nil { @@ -109,6 +104,50 @@ var gatewayCmd = &cobra.Command{ }, } +var gatewayInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install and enable systemd service for the gateway (requires sudo)", + Long: "Install and enable systemd service for the gateway. Must be run with sudo on Linux.", + Example: "sudo infisical gateway install --token= --domain=", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if runtime.GOOS != "linux" { + util.HandleError(fmt.Errorf("systemd service installation is only supported on Linux")) + } + + if os.Geteuid() != 0 { + util.HandleError(fmt.Errorf("systemd service installation requires root/sudo privileges")) + } + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if token == nil { + util.HandleError(fmt.Errorf("Token not found")) + } + + domain, err := cmd.Flags().GetString("domain") + if err != nil { + util.HandleError(err, "Unable to parse domain flag") + } + + if err := gateway.InstallGatewaySystemdService(token.Token, domain); err != nil { + util.HandleError(err, "Failed to install systemd service") + } + + enableCmd := exec.Command("systemctl", "enable", "infisical-gateway") + if err := enableCmd.Run(); err != nil { + util.HandleError(err, "Failed to enable systemd service") + } + + log.Info().Msg("Successfully installed and enabled infisical-gateway service") + log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway") + }, +} + var gatewayRelayCmd = &cobra.Command{ Example: `infisical gateway relay`, Short: "Used to run infisical gateway relay", @@ -138,9 +177,12 @@ var gatewayRelayCmd = &cobra.Command{ func init() { gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") + gatewayInstallCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") + gatewayInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance") gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path") + gatewayCmd.AddCommand(gatewayInstallCmd) gatewayCmd.AddCommand(gatewayRelayCmd) rootCmd.AddCommand(gatewayCmd) } 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..601cb9e90 --- /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=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/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx new file mode 100644 index 000000000..fd035f1fd --- /dev/null +++ b/docs/cli/commands/gateway.mdx @@ -0,0 +1,107 @@ +--- +title: "infisical gateway" +description: "Run the Infisical gateway or manage its systemd service" +--- + + + + ```bash + infisical gateway --token= + ``` + + + ```bash + sudo infisical gateway install --token= --domain= + ``` + + + +## Description + +Run the Infisical gateway in the foreground or manage its systemd service installation. The gateway allows secure communication between your self-hosted Infisical instance and client applications. + +## Subcommands & flags + + + Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. + + ```bash + infisical gateway --token= --domain= + ``` + + ### Flags + + + The machine identity access token to authenticate with Infisical. + + ```bash + # Example + infisical gateway --token= + ``` + + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the gateway command. + + + + Domain of your self-hosted Infisical instance. + + ```bash + # Example + sudo infisical gateway install --domain=https://app.your-domain.com + ``` + + + + + Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. + + ```bash + sudo infisical gateway install --token= --domain= + ``` + + ### Requirements + - Must be run on Linux + - Must be run with root/sudo privileges + - Requires systemd + + ### Flags + + + The machine identity access token to authenticate with Infisical. + + ```bash + # Example + sudo infisical gateway install --token= + ``` + + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command. + + + + Domain of your self-hosted Infisical instance. + + ```bash + # Example + sudo infisical gateway install --domain=https://app.your-domain.com + ``` + + + ### Service Details + The systemd service is installed with secure defaults: + - Service file: `/etc/systemd/system/infisical-gateway.service` + - Config file: `/etc/infisical/gateway.conf` + - Runs with restricted privileges: + - InaccessibleDirectories=/home + - PrivateTmp=yes + - Resource limits configured for stability + - Automatically restarts on failure + - Enabled to start on boot + + After installation, manage the service with standard systemd commands: + ```bash + sudo systemctl start infisical-gateway # Start the service + sudo systemctl stop infisical-gateway # Stop the service + sudo systemctl status infisical-gateway # Check service status + sudo systemctl disable infisical-gateway # Disable auto-start on boot + ``` + diff --git a/docs/documentation/platform/gateways/images/gateway-highlevel-diagram.png b/docs/documentation/platform/gateways/images/gateway-highlevel-diagram.png new file mode 100644 index 000000000..5f942bcf0 Binary files /dev/null and b/docs/documentation/platform/gateways/images/gateway-highlevel-diagram.png differ diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index d263dc278..02d9c863a 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -4,6 +4,8 @@ sidebarTitle: "Overview" description: "How to access private network resources from Infisical" --- +![Alt text](/documentation/platform/gateways/images/gateway-highlevel-diagram.png) + The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. Common use cases include generating dynamic credentials or rotating credentials for private databases. @@ -45,19 +47,53 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t - Use the Infisical CLI to deploy the Gateway. You can log in with your machine identity and start the Gateway in one command. The example below demonstrates how to deploy the Gateway using the Universal Auth method: - ```bash - infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) - ``` - Alternatively, if you already have the token, use it directly with the `--token` flag: - ```bash - infisical gateway --token - ``` - Or set it as an environment variable: - ```bash - export INFISICAL_TOKEN= - infisical gateway - ``` + Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: + + + + For production deployments on Linux, install the Gateway as a systemd service: + ```bash + sudo infisical gateway install --token --domain + sudo systemctl start infisical-gateway + ``` + This will install and start the Gateway as a secure systemd service that: + - Runs with restricted privileges: + - Runs as root user (required for secure token management) + - Restricted access to home directories + - Private temporary directory + - Automatically restarts on failure + - Starts on system boot + - Manages token and domain configuration securely in `/etc/infisical/gateway.conf` + + + The install command requires: + - Linux operating system + - Root/sudo privileges + - Systemd + + + + + For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: + ```bash + infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) + ``` + + Alternatively, if you already have the token, use it directly with the `--token` flag: + ```bash + infisical gateway --token + ``` + + Or set it as an environment variable: + ```bash + export INFISICAL_TOKEN= + infisical gateway + ``` + + + + For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway). + Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical. @@ -78,4 +114,3 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t Once added to a project, the Gateway becomes available for use by any feature that supports Gateways within that project. - diff --git a/docs/documentation/platform/secret-scanning.mdx b/docs/documentation/platform/secret-scanning.mdx new file mode 100644 index 000000000..4f030e882 --- /dev/null +++ b/docs/documentation/platform/secret-scanning.mdx @@ -0,0 +1,68 @@ +--- +title: 'Secret Scanning' +description: "Scan and prevent secret leaks in your code repositories" +--- + +The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories. + +To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes. + +## Code Scanning + +![Scanning Overview](/images/platform/secret-scanning/overview.png) + +Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets. + +If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it: +- **Date**: When the risk was first detected. +- **Secret Type**: Which type of secret was detected. +- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change. + +Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret. + + + Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed. + + + + +![Exposed Secret](/images/platform/secret-scanning/exposed-secret.png) + + +## Responding to Exposed Secrets + +After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible. + +You can mark the risk as `Resolved` by changing the status to one of the following states: +- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner. +- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed. +- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk. + +![Needs Attention](/images/platform/secret-scanning/needs-attention.png) + + + + +## Ignoring Known Secrets +If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. + +### infisical-scan:ignore + +To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. + +```js example.js +function helloWorld() { + console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore +} +``` + +### .infisicalignore +An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. +You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. +By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. + +```.ignore .infisicalignore +bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 +bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 +1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 +``` diff --git a/docs/images/platform/secret-scanning/exposed-secret.png b/docs/images/platform/secret-scanning/exposed-secret.png new file mode 100644 index 000000000..727765292 Binary files /dev/null and b/docs/images/platform/secret-scanning/exposed-secret.png differ diff --git a/docs/images/platform/secret-scanning/needs-attention.png b/docs/images/platform/secret-scanning/needs-attention.png new file mode 100644 index 000000000..6ac664ead Binary files /dev/null and b/docs/images/platform/secret-scanning/needs-attention.png differ diff --git a/docs/images/platform/secret-scanning/overview.png b/docs/images/platform/secret-scanning/overview.png new file mode 100644 index 000000000..19981fa11 Binary files /dev/null and b/docs/images/platform/secret-scanning/overview.png differ diff --git a/docs/integrations/cloud/databricks.mdx b/docs/integrations/cloud/databricks.mdx index 7fee3acd3..1971190de 100644 --- a/docs/integrations/cloud/databricks.mdx +++ b/docs/integrations/cloud/databricks.mdx @@ -7,6 +7,12 @@ Prerequisites: - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + + When integrating with Databricks, Infisical is intended to be the source of truth for the secrets in the configured Databricks scope. + + Any secrets not present in Infisical will be removed from the specified scope. To prevent removal of secrets not managed by Infisical, Infisical recommends creating a designated secret scope for your integration. + + Obtain a Personal Access Token in **User Settings** > **Developer** > **Access Tokens**. diff --git a/docs/integrations/secret-syncs/databricks.mdx b/docs/integrations/secret-syncs/databricks.mdx index 8f305ecd6..148542708 100644 --- a/docs/integrations/secret-syncs/databricks.mdx +++ b/docs/integrations/secret-syncs/databricks.mdx @@ -34,6 +34,8 @@ description: "Learn how to configure a Databricks Sync for Infisical." You must create a secret scope in your Databricks workspace prior to configuration. Ensure your service principal has [Write permissions](https://docs.databricks.com/en/security/auth/access-control/index.html#secret-acls) for the specified secret scope. + + Infisical recommends creating a designated Databricks secret scope for your sync to prevent removal of secrets not managed by Infisical. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. diff --git a/docs/mint.json b/docs/mint.json index f604ff310..4ab8f0579 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -220,7 +220,8 @@ "documentation/platform/admin-panel/org-admin-console" ] }, - "documentation/platform/secret-sharing" + "documentation/platform/secret-sharing", + "documentation/platform/secret-scanning" ] }, { @@ -339,6 +340,7 @@ "cli/commands/secrets", "cli/commands/dynamic-secrets", "cli/commands/ssh", + "cli/commands/gateway", "cli/commands/export", "cli/commands/token", "cli/commands/service-token", @@ -644,8 +646,7 @@ "api-reference/endpoints/oidc-auth/attach", "api-reference/endpoints/oidc-auth/retrieve", "api-reference/endpoints/oidc-auth/update", - "api-reference/endpoints/oidc-auth/revoke", - "integrations/frameworks/terraform-cloud" + "api-reference/endpoints/oidc-auth/revoke" ] }, { diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..3104f340d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1741445498, + "narHash": "sha256-F5Em0iv/CxkN5mZ9hRn3vPknpoWdcdCyR0e4WklHwiE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "52e3095f6d812b91b22fb7ad0bfc1ab416453634", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..094cfedc6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,24 @@ +{ + description = "Flake for github:Infisical/infisical repository."; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + }; + + outputs = { self, nixpkgs }: { + devShells.aarch64-darwin.default = let + pkgs = nixpkgs.legacyPackages.aarch64-darwin; + in + pkgs.mkShell { + packages = with pkgs; [ + git + lazygit + + python312Full + nodejs_20 + nodePackages.prettier + infisical + ]; + }; + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fdfeea1f..a7e9ca7e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@casl/ability": "^6.7.2", "@casl/react": "^4.0.0", + "@dagrejs/dagre": "^1.1.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -47,8 +48,10 @@ "@tanstack/react-router": "^1.95.1", "@tanstack/virtual-file-routes": "^1.87.6", "@tanstack/zod-adapter": "^1.91.0", + "@types/dagre": "^0.7.52", "@types/nprogress": "^0.2.3", "@ucast/mongo2js": "^1.3.4", + "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", "axios": "^1.7.9", "classnames": "^2.5.1", @@ -507,6 +510,24 @@ "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", + "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", @@ -3955,6 +3976,61 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.52", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.52.tgz", + "integrity": "sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4382,6 +4458,64 @@ "vite": "^4 || ^5 || ^6" } }, + "node_modules/@xyflow/react": { + "version": "12.4.4", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz", + "integrity": "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.52", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", + "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.52.tgz", + "integrity": "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -5456,6 +5590,12 @@ "node": ">= 0.10" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -5808,6 +5948,111 @@ "url": "https://polar.sh/cva" } }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index d62d11242..95ad59c7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@casl/ability": "^6.7.2", "@casl/react": "^4.0.0", + "@dagrejs/dagre": "^1.1.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -51,8 +52,10 @@ "@tanstack/react-router": "^1.95.1", "@tanstack/virtual-file-routes": "^1.87.6", "@tanstack/zod-adapter": "^1.91.0", + "@types/dagre": "^0.7.52", "@types/nprogress": "^0.2.3", "@ucast/mongo2js": "^1.3.4", + "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", "axios": "^1.7.9", "classnames": "^2.5.1", 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/components/auth/UserInfoStep.tsx b/frontend/src/components/auth/UserInfoStep.tsx index 2b7761d1e..582c2a850 100644 --- a/frontend/src/components/auth/UserInfoStep.tsx +++ b/frontend/src/components/auth/UserInfoStep.tsx @@ -11,6 +11,7 @@ import { encodeBase64 } from "tweetnacl-util"; import { initProjectHelper } from "@app/helpers/project"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; +import { onRequestError } from "@app/hooks/api/reactQuery"; import InputField from "../basic/InputField"; import checkPassword from "../utilities/checks/password/checkPassword"; @@ -206,6 +207,7 @@ export default function UserInfoStep({ incrementStep(); } catch (error) { + onRequestError(error); setIsLoading(false); console.error(error); } diff --git a/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx b/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx index b5712e596..bdf1c5c81 100644 --- a/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx +++ b/frontend/src/components/organization/CreateOrgModal/CreateOrgModal.tsx @@ -8,10 +8,11 @@ import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useCreateOrg, useSelectOrganization } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; const schema = z .object({ - name: z.string().nonempty({ message: "Name is required" }) + name: GenericResourceNameSchema.nonempty({ message: "Name is required" }) }) .required(); @@ -78,7 +79,7 @@ export const CreateOrgModal: FC = ({ isOpen, onClose }) => }; return ( - + ; +}; + +const EdgeTypes = { base: BasePermissionEdge }; + +const NodeTypes = { role: RoleNode, folder: FolderNode }; + +const AccessTreeContent = ({ permissions }: AccessTreeProps) => { + const accessTreeData = useAccessTree(permissions); + const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData; + + const { fitView, getViewport, setCenter } = useReactFlow(); + + const onNodeClick: NodeMouseHandler = useCallback( + (_, node) => { + setCenter( + node.position.x + (node.width ? node.width / 2 : 0), + node.position.y + (node.height ? node.height / 2 + 50 : 50), + { duration: 1000, zoom: 1 } + ); + }, + [setCenter] + ); + + useEffect(() => { + setTimeout(() => { + fitView({ + padding: 0.2, + duration: 1000, + maxZoom: 1 + }); + }, 1); + }, [fitView, nodes, edges, getViewport()]); + + const handleToggleModalView = () => + setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); + + const handleToggleUndockedView = () => + setViewMode((prev) => (prev === ViewMode.Undocked ? ViewMode.Docked : ViewMode.Undocked)); + + const undockButtonLabel = `${viewMode === ViewMode.Undocked ? "Dock" : "Undock"} View`; + const windowButtonLabel = `${viewMode === ViewMode.Modal ? "Dock" : "Expand"} View`; + + return ( +
+
+ {viewMode === ViewMode.Docked && ( +
+
+

Access Tree

+

+ Visual access policies for the configured role. +

+
+
+ + +
+
+ )} +
+
+ + {isLoading && ( + + + + )} + {viewMode !== ViewMode.Docked && ( + + + + + + + + + + + + + )} + + + + +
+
+
+
+ ); +}; + +export const AccessTree = (props: AccessTreeProps) => { + return ( + + + + + + + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx new file mode 100644 index 000000000..2a6767396 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx @@ -0,0 +1,51 @@ +import React, { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useMemo, + useState +} from "react"; + +import { ViewMode } from "../types"; + +export interface AccessTreeContextProps { + secretName: string; + setSecretName: Dispatch>; + viewMode: ViewMode; + setViewMode: Dispatch>; +} + +const AccessTreeContext = createContext(undefined); + +interface AccessTreeProviderProps { + children: ReactNode; +} + +export const AccessTreeProvider: React.FC = ({ children }) => { + const [secretName, setSecretName] = useState(""); + const [viewMode, setViewMode] = useState(ViewMode.Docked); + + const value = useMemo( + () => ({ + secretName, + setSecretName, + viewMode, + setViewMode + }), + [secretName, setSecretName, viewMode, setViewMode] + ); + + return {children}; +}; + +export const useAccessTreeContext = (): AccessTreeContextProps => { + const context = useContext(AccessTreeContext); + + if (!context) { + throw new Error("useAccessTreeContext must be used within a AccessTreeProvider"); + } + + return context; +}; diff --git a/frontend/src/components/permissions/AccessTree/components/AccessTreeErrorBoundary.tsx b/frontend/src/components/permissions/AccessTree/components/AccessTreeErrorBoundary.tsx new file mode 100644 index 000000000..c30eb09cb --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/AccessTreeErrorBoundary.tsx @@ -0,0 +1,105 @@ +import React, { ErrorInfo, ReactNode } from "react"; +import { MongoAbility, MongoQuery } from "@casl/ability"; +import { faCheck, faCopy, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton } from "@app/components/v2"; +import { SessionStorageKeys } from "@app/const"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { useTimedReset } from "@app/hooks"; + +interface ErrorBoundaryProps { + children: ReactNode; + permissions: MongoAbility; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +const ErrorDisplay = ({ + error, + permissions +}: { + error: Error | null; + permissions: MongoAbility; +}) => { + const display = JSON.stringify({ errorMessage: error?.message, permissions }, null, 2); + + const [isCopied, , setIsCopied] = useTimedReset({ + initialState: false + }); + + const copyToClipboard = () => { + navigator.clipboard.writeText(display); + setIsCopied(true); + sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN); + }; + + return ( +
+
+ +

+ Error displaying access tree. Please contact{" "} + + support@infisical.com + {" "} + with the following information. +

+
+
+
+          {display}
+        
+ + + +
+
+ ); +}; + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null + }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("Error caught by ErrorBoundary:", error, errorInfo, this.props); + } + + render(): ReactNode { + const { hasError, error } = this.state; + const { children, permissions } = this.props; + + if (hasError) { + return ; + } + return children; + } +} + +export const AccessTreeErrorBoundary = ({ children, permissions }: ErrorBoundaryProps) => { + return {children}; +}; diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx new file mode 100644 index 000000000..476f2d27c --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -0,0 +1,141 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Panel } from "@xyflow/react"; + +import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; + +import { ViewMode } from "../types"; + +type TProps = { + secretName: string; + setSecretName: Dispatch>; + viewMode: ViewMode; + setViewMode: Dispatch>; + setEnvironment: Dispatch>; + environment: string; + subject: ProjectPermissionSub; + setSubject: Dispatch>; + environments: { name: string; slug: string }[]; +}; + +export const PermissionSimulation = ({ + setEnvironment, + environment, + subject, + setSubject, + environments, + setViewMode, + viewMode, + secretName, + setSecretName +}: TProps) => { + const [expand, setExpand] = useState(false); + + const handlePermissionSimulation = () => { + setExpand(true); + setViewMode(ViewMode.Modal); + }; + + if (viewMode !== ViewMode.Modal) + return ( + + + + ); + + return ( + +
+
+
+ Permission Simulation + { + e.stopPropagation(); + setExpand((prev) => !prev); + }} + > + + +
+ {expand && ( +

+ Evaluate conditional policies to see what permissions will be granted given a secret + name or tags +

+ )} +
+ {expand && ( + <> +
+ + +
+
+ + +
+ {subject === ProjectPermissionSub.Secrets && ( +
+ + setSecretName(e.target.value)} + /> +
+ )} + + )} +
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/components/index.ts b/frontend/src/components/permissions/AccessTree/components/index.ts new file mode 100644 index 000000000..cce3f73c4 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/index.ts @@ -0,0 +1,3 @@ +export * from "./AccessTreeContext"; +export * from "./AccessTreeErrorBoundary"; +export * from "./PermissionSimulation"; diff --git a/frontend/src/components/permissions/AccessTree/edges/BasePermissionEdge.tsx b/frontend/src/components/permissions/AccessTree/edges/BasePermissionEdge.tsx new file mode 100644 index 000000000..ee8b4b531 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/edges/BasePermissionEdge.tsx @@ -0,0 +1,34 @@ +import { BaseEdge, BaseEdgeProps, EdgeProps, getSmoothStepPath } from "@xyflow/react"; + +export const BasePermissionEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + markerStart, + markerEnd, + style +}: Omit & EdgeProps) => { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY + }); + + return ( + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/edges/index.ts b/frontend/src/components/permissions/AccessTree/edges/index.ts new file mode 100644 index 000000000..566a0f3ee --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/edges/index.ts @@ -0,0 +1 @@ +export * from "./BasePermissionEdge"; diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts new file mode 100644 index 000000000..5672230c8 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { MongoAbility, MongoQuery } from "@casl/ability"; +import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; + +import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; + +import { useAccessTreeContext } from "../components"; +import { PermissionAccess } from "../types"; +import { + createBaseEdge, + createFolderNode, + createRoleNode, + getSubjectActionRuleMap, + positionElements +} from "../utils"; + +export const useAccessTree = (permissions: MongoAbility) => { + const { currentWorkspace } = useWorkspace(); + const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); + const [nodes, setNodes] = useNodesState([]); + const [edges, setEdges] = useEdgesState([]); + const [subject, setSubject] = useState(ProjectPermissionSub.Secrets); + const [environment, setEnvironment] = useState(currentWorkspace.environments[0]?.slug ?? ""); + const { data: environmentsFolders, isPending } = useListProjectEnvironmentsFolders( + currentWorkspace.id + ); + + useEffect(() => { + if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return; + + const { folders, name } = environmentsFolders[environment]; + + const roleNode = createRoleNode({ + subject, + environment: name + }); + + const actionRuleMap = getSubjectActionRuleMap(subject, permissions); + + const folderNodes = folders.map((folder) => + createFolderNode({ + folder, + permissions, + environment, + subject, + secretName, + actionRuleMap + }) + ); + + const folderEdges = folderNodes.map(({ data: folder }) => { + const actions = Object.values(folder.actions); + + let access: PermissionAccess; + if (Object.values(actions).some((action) => action === PermissionAccess.Full)) { + access = PermissionAccess.Full; + } else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) { + access = PermissionAccess.Partial; + } else { + access = PermissionAccess.None; + } + + return createBaseEdge({ + source: folder.parentId ?? roleNode.id, + target: folder.id, + access + }); + }); + + const init = positionElements([roleNode, ...folderNodes], [...folderEdges]); + setNodes(init.nodes); + setEdges(init.edges); + }, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]); + + return { + nodes, + edges, + subject, + environment, + setEnvironment, + setSubject, + isLoading: isPending, + environments: currentWorkspace.environments, + secretName, + setSecretName, + viewMode, + setViewMode + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/index.ts b/frontend/src/components/permissions/AccessTree/index.ts new file mode 100644 index 000000000..b04249099 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/index.ts @@ -0,0 +1 @@ +export * from "./AccessTree"; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/FolderNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/FolderNode.tsx new file mode 100644 index 000000000..0c680264e --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/FolderNode.tsx @@ -0,0 +1,78 @@ +import { + faCheckCircle, + faCircleMinus, + faCircleXmark, + faFolder +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Handle, NodeProps, Position } from "@xyflow/react"; + +import { Tooltip } from "@app/components/v2"; + +import { PermissionAccess } from "../../types"; +import { createFolderNode, formatActionName } from "../../utils"; +import { FolderNodeTooltipContent } from "./components"; + +const AccessMap = { + [PermissionAccess.Full]: { className: "text-green", icon: faCheckCircle }, + [PermissionAccess.Partial]: { className: "text-yellow", icon: faCircleMinus }, + [PermissionAccess.None]: { className: "text-red", icon: faCircleXmark } +}; + +export const FolderNode = ({ + data +}: NodeProps & { data: ReturnType["data"] }) => { + const { name, actions, actionRuleMap, parentId, subject } = data; + + const hasMinimalAccess = Object.values(actions).some( + (action) => action === PermissionAccess.Full || action === PermissionAccess.Partial + ); + + return ( + <> + +
+
+ + {parentId ? `/${name}` : "/"} +
+
+ {Object.entries(actions).map(([action, access]) => { + const { className, icon } = AccessMap[access]; + + return ( + + } + > +
+ + {formatActionName(action)} +
+
+ ); + })} +
+
+ + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx new file mode 100644 index 000000000..f2ca6e878 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx @@ -0,0 +1,131 @@ +import { ReactElement } from "react"; +import { faCheckCircle, faCircleMinus, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { NodeToolbar, Position } from "@xyflow/react"; + +import { + formatedConditionsOperatorNames, + PermissionConditionOperators +} from "@app/context/ProjectPermissionContext/types"; +import { camelCaseToSpaces } from "@app/lib/fn/string"; + +import { PermissionAccess } from "../../../types"; +import { createFolderNode, formatActionName } from "../../../utils"; + +type Props = { + action: string; + access: PermissionAccess; +} & Pick["data"], "actionRuleMap" | "subject">; + +export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => { + let component: ReactElement; + + switch (access) { + case PermissionAccess.Full: + component = ( + <> +
+ + Full {formatActionName(action)} Permissions +
+

+ Policy grants unconditional{" "} + + {formatActionName(action).toLowerCase()} + {" "} + permission for {subject.replaceAll("-", " ")} in this folder. +

+ + ); + break; + case PermissionAccess.Partial: + component = ( + <> +
+ + Conditional {formatActionName(action)} Permissions +
+

+ Policy conditionally allows{" "} + + {formatActionName(action).toLowerCase()} + {" "} + permission for {subject.replaceAll("-", " ")} in this folder. +

+
    + {actionRuleMap.map((ruleMap, index) => { + const rule = ruleMap[action]; + + if ( + !rule || + !rule.conditions || + (!rule.conditions.secretName && !rule.conditions.secretTags) + ) + return null; + + return ( +
  • + + {rule.inverted ? "Forbids" : "Allows"} + + when: + {Object.entries(rule.conditions).map(([key, condition]) => ( +
      + {Object.entries(condition as object).map(([operator, value]) => ( +
    • + + {camelCaseToSpaces(key)} + {" "} + + { + formatedConditionsOperatorNames[ + operator as PermissionConditionOperators + ] + } + {" "} + + {typeof value === "string" ? value : value.join(", ")} + + . +
    • + ))} +
    + ))} +
  • + ); + })} +
+ + ); + break; + case PermissionAccess.None: + component = ( + <> +
+ + No {formatActionName(action)} Permissions +
+

+ Policy always forbids{" "} + + {formatActionName(action).toLowerCase()} + {" "} + permission for {subject.replaceAll("-", " ")} in this folder. +

+ + ); + break; + default: + throw new Error(`Unhandled access type: ${access}`); + } + + return ( + + {component} + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/index.ts b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/index.ts new file mode 100644 index 000000000..79380317f --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/index.ts @@ -0,0 +1 @@ +export * from "./FolderNodeTooltipContent"; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/index.ts b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/index.ts new file mode 100644 index 000000000..c1c0583b8 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/index.ts @@ -0,0 +1 @@ +export * from "./FolderNode"; diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx new file mode 100644 index 000000000..d38d896f2 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -0,0 +1,30 @@ +import { Handle, NodeProps, Position } from "@xyflow/react"; + +import { createRoleNode } from "../utils"; + +export const RoleNode = ({ + data: { subject, environment } +}: NodeProps & { data: ReturnType["data"] }) => { + return ( + <> + +
+
+ {subject.replace("-", " ")} Access +
+

{environment}

+
+
+
+ + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/index.ts b/frontend/src/components/permissions/AccessTree/nodes/index.ts new file mode 100644 index 000000000..a67b58c18 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/index.ts @@ -0,0 +1,2 @@ +export * from "./FolderNode/FolderNode"; +export * from "./RoleNode"; diff --git a/frontend/src/components/permissions/AccessTree/types/index.ts b/frontend/src/components/permissions/AccessTree/types/index.ts new file mode 100644 index 000000000..9536d7f86 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/types/index.ts @@ -0,0 +1,21 @@ +export enum PermissionAccess { + Full = "full", + Partial = "partial", + None = "None" +} + +export enum PermissionNode { + Role = "role", + Folder = "folder", + Environment = "environment" +} + +export enum PermissionEdge { + Base = "base" +} + +export enum ViewMode { + Docked = "docked", + Modal = "modal", + Undocked = "undocked" +} diff --git a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts new file mode 100644 index 000000000..cc53360c8 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts @@ -0,0 +1,26 @@ +import { MarkerType } from "@xyflow/react"; + +import { PermissionAccess, PermissionEdge } from "../types"; + +export const createBaseEdge = ({ + source, + target, + access +}: { + source: string; + target: string; + access: PermissionAccess; +}) => { + const color = access === PermissionAccess.None ? "#707174" : "#ccccce"; + return { + id: `e-${source}-${target}`, + source, + target, + type: PermissionEdge.Base, + markerEnd: { + type: MarkerType.ArrowClosed, + color + }, + style: { stroke: color } + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts new file mode 100644 index 000000000..15c64ce8a --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts @@ -0,0 +1,180 @@ +import { MongoAbility, MongoQuery, subject as abilitySubject } from "@casl/ability"; +import picomatch from "picomatch"; + +import { + ProjectPermissionActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext"; +import { + PermissionConditionOperators, + ProjectPermissionSecretActions +} from "@app/context/ProjectPermissionContext/types"; +import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; + +import { PermissionAccess, PermissionNode } from "../types"; +import { TActionRuleMap } from "./getActionRuleMap"; + +const ACTION_MAP: Record = { + [ProjectPermissionSub.Secrets]: [ + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ], + [ProjectPermissionSub.DynamicSecrets]: Object.values(ProjectPermissionDynamicSecretActions), + [ProjectPermissionSub.SecretFolders]: [ + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ] +}; + +const evaluateCondition = ( + value: string, + operator: PermissionConditionOperators, + comparison: string | string[] +) => { + switch (operator) { + case PermissionConditionOperators.$EQ: + return value === comparison; + case PermissionConditionOperators.$NEQ: + return value !== comparison; + case PermissionConditionOperators.$GLOB: + return picomatch.isMatch(value, comparison); + case PermissionConditionOperators.$IN: + return (comparison as string[]).map((v: string) => v.trim()).includes(value); + default: + throw new Error(`Unhandled operator: ${operator}`); + } +}; + +export const createFolderNode = ({ + folder, + permissions, + environment, + subject, + secretName, + actionRuleMap +}: { + folder: TSecretFolderWithPath; + permissions: MongoAbility; + environment: string; + subject: ProjectPermissionSub; + secretName: string; + actionRuleMap: TActionRuleMap; +}) => { + const actions = Object.fromEntries( + Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => { + let access: PermissionAccess; + + // wrapped in try because while editing certain conditions, if their values are empty it throws an error + try { + let hasPermission: boolean; + + const subjectFields = { + secretPath: folder.path, + environment, + secretName: secretName || "*", + secretTags: ["*"] + }; + + if ( + subject === ProjectPermissionSub.Secrets && + (action === ProjectPermissionSecretActions.ReadValue || + action === ProjectPermissionSecretActions.DescribeSecret) + ) { + hasPermission = hasSecretReadValueOrDescribePermission( + permissions, + action, + subjectFields + ); + } else { + hasPermission = permissions.can( + // @ts-expect-error we are not specifying which so can't resolve if valid + action, + abilitySubject(subject, subjectFields) + ); + } + + if (hasPermission) { + // we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access + if ( + !secretName && + actionRuleMap.some((el) => { + // we only show conditional if secretName/secretTags are present - environment and path can be directly determined + if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags) + return false; + + // make sure condition applies to env + if (el[action]?.conditions?.environment) { + if ( + !Object.entries(el[action]?.conditions?.environment).every(([operator, value]) => + evaluateCondition(environment, operator as PermissionConditionOperators, value) + ) + ) { + return false; + } + } + + // and applies to path + if (el[action]?.conditions?.secretPath) { + if ( + !Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) => + evaluateCondition(folder.path, operator as PermissionConditionOperators, value) + ) + ) { + return false; + } + } + + return true; + }) + ) { + access = PermissionAccess.Partial; + } else { + access = PermissionAccess.Full; + } + } else { + access = PermissionAccess.None; + } + } catch (e) { + console.error(e); + access = PermissionAccess.None; + } + + return [action, access]; + }) + ); + + let height: number; + + switch (subject) { + case ProjectPermissionSub.DynamicSecrets: + height = 130; + break; + case ProjectPermissionSub.Secrets: + height = 85; + break; + default: + height = 64; + } + + return { + type: PermissionNode.Folder, + id: folder.id, + data: { + ...folder, + actions, + environment, + actionRuleMap, + subject + }, + position: { x: 0, y: 0 }, + width: 264, + height + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts new file mode 100644 index 000000000..354a69482 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts @@ -0,0 +1,19 @@ +import { PermissionNode } from "../types"; + +export const createRoleNode = ({ + subject, + environment +}: { + subject: string; + environment: string; +}) => ({ + id: `role-${subject}-${environment}`, + position: { x: 0, y: 0 }, + data: { + subject, + environment + }, + type: PermissionNode.Role, + height: 48, + width: 264 +}); diff --git a/frontend/src/components/permissions/AccessTree/utils/formatActionName.ts b/frontend/src/components/permissions/AccessTree/utils/formatActionName.ts new file mode 100644 index 000000000..c89adea8b --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/formatActionName.ts @@ -0,0 +1,3 @@ +import { camelCaseToSpaces } from "@app/lib/fn/string"; + +export const formatActionName = (action: string) => camelCaseToSpaces(action.replaceAll("-", " ")); diff --git a/frontend/src/components/permissions/AccessTree/utils/getActionRuleMap.ts b/frontend/src/components/permissions/AccessTree/utils/getActionRuleMap.ts new file mode 100644 index 000000000..40b723d22 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/getActionRuleMap.ts @@ -0,0 +1,27 @@ +import { MongoAbility, MongoQuery } from "@casl/ability"; + +import { ProjectPermissionSet, ProjectPermissionSub } from "@app/context/ProjectPermissionContext"; + +export type TActionRuleMap = ReturnType; + +export const getSubjectActionRuleMap = ( + subject: ProjectPermissionSub, + permissions: MongoAbility +) => { + const rules = permissions.rules.filter((rule) => { + const ruleSubject = typeof rule.subject === "string" ? rule.subject : rule.subject[0]; + + return ruleSubject === subject; + }); + + const actionRuleMap: Record[] = []; + rules.forEach((rule) => { + if (typeof rule.action === "string") { + actionRuleMap.push({ [rule.action]: rule }); + } else { + actionRuleMap.push(Object.fromEntries(rule.action.map((action) => [action, rule]))); + } + }); + + return actionRuleMap; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/index.ts b/frontend/src/components/permissions/AccessTree/utils/index.ts new file mode 100644 index 000000000..88e8fcceb --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/index.ts @@ -0,0 +1,6 @@ +export * from "./createBaseEdge"; +export * from "./createFolderNode"; +export * from "./createRoleNode"; +export * from "./formatActionName"; +export * from "./getActionRuleMap"; +export * from "./positionElements"; diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts new file mode 100644 index 000000000..523b402d1 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -0,0 +1,28 @@ +import Dagre from "@dagrejs/dagre"; +import { Edge, Node } from "@xyflow/react"; + +export const positionElements = (nodes: Node[], edges: Edge[]) => { + const dagre = new Dagre.graphlib.Graph({ directed: true }) + .setDefaultEdgeLabel(() => ({})) + .setGraph({ rankdir: "TB" }); + + edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); + nodes.forEach((node) => dagre.setNode(node.id, node)); + + Dagre.layout(dagre, {}); + + return { + nodes: nodes.map((node) => { + const { x, y } = dagre.node(node.id); + + return { + ...node, + position: { + x: x - (node.width ? node.width / 2 : 0), + y: y - (node.height ? node.height / 2 : 0) + } + }; + }), + edges + }; +}; diff --git a/frontend/src/components/permissions/index.tsx b/frontend/src/components/permissions/index.tsx index 5103b0f73..c40079a4f 100644 --- a/frontend/src/components/permissions/index.tsx +++ b/frontend/src/components/permissions/index.tsx @@ -1,3 +1,4 @@ +export * from "./AccessTree"; export { GlobPermissionInfo } from "./GlobPermissionInfo"; export { OrgPermissionCan } from "./OrgPermissionCan"; export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/DatabricksSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/DatabricksSyncFields.tsx index c6cfff0e5..533d0d2cf 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/DatabricksSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/DatabricksSyncFields.tsx @@ -40,6 +40,8 @@ export const DatabricksSyncFields = () => { isError={Boolean(error)} errorText={error?.message} label="Secret Scope" + tooltipClassName="max-w-md" + tooltipText="Infisical recommends creating a designated Databricks secret scope for your sync to prevent removal of secrets not managed by Infisical." helperText={ { + 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/components/v2/Popoverv2/Popoverv2.tsx b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx index 65a38cd66..fb5c7b089 100644 --- a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx +++ b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx @@ -50,7 +50,9 @@ export const PopoverContent = ({ )} - +
+ +
); diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 7259c8157..e0dc90186 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -59,7 +59,9 @@ export const Select = forwardRef( >
{props.icon && } - +
+ +
@@ -122,7 +124,7 @@ export const SelectItem = forwardRef( { staleTime: Infinity, select: (data) => { const rule = unpackRules>>(data.permissions); - const negatedRules = groupBy( - rule.filter((i) => i.inverted && i.conditions), - (i) => `${i.subject}-${JSON.stringify(i.conditions)}` - ); - const ability = createMongoAbility(rule, { - // this allows in frontend to skip some rules using * - conditionsMatcher: (rules) => { - return (entity) => { - // skip validation if its negated rules - const isNegatedRule = - // eslint-disable-next-line no-underscore-dangle - negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`]; - if (isNegatedRule) { - const baseMatcher = conditionsMatcher(rules); - return baseMatcher(entity); - } - - const rulesStrippedOfWildcard = omit( - rules, - Object.keys(entity).filter((el) => entity[el]?.includes("*")) - ); - const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard); - return baseMatcher(entity); - }; - } - }); - + const ability = evaluatePermissionsAbility(rule); return { permission: ability, membership: { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index d368a949f..495055977 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", @@ -138,7 +147,7 @@ export type SecretImportSubjectFields = { export type ProjectPermissionSet = | [ - ProjectPermissionActions, + ProjectPermissionSecretActions, ( | ProjectPermissionSub.Secrets | (ForcedSubject & SecretSubjectFields) diff --git a/frontend/src/helpers/permissions.ts b/frontend/src/helpers/permissions.ts new file mode 100644 index 000000000..2f07ee05c --- /dev/null +++ b/frontend/src/helpers/permissions.ts @@ -0,0 +1,39 @@ +import { createMongoAbility, MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability"; + +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { conditionsMatcher } from "@app/hooks/api/roles/queries"; +import { groupBy } from "@app/lib/fn/array"; +import { omit } from "@app/lib/fn/object"; + +export const evaluatePermissionsAbility = ( + rule: RawRuleOf>[] +) => { + const negatedRules = groupBy( + rule.filter((i) => i.inverted && i.conditions), + (i) => `${i.subject}-${JSON.stringify(i.conditions)}` + ); + const ability = createMongoAbility(rule, { + // this allows in frontend to skip some rules using * + conditionsMatcher: (rules) => { + return (entity) => { + // skip validation if its negated rules + const isNegatedRule = + // eslint-disable-next-line no-underscore-dangle + negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`]; + if (isNegatedRule) { + const baseMatcher = conditionsMatcher(rules); + return baseMatcher(entity); + } + + const rulesStrippedOfWildcard = omit( + rules, + Object.keys(entity).filter((el) => entity[el]?.includes("*")) + ); + const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard); + return baseMatcher(entity); + }; + } + }); + + return ability; +}; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 4d9f05bb1..5eb6c6732 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,10 +1,10 @@ export { useAdminDeleteUser, + useAdminGrantServerAdminAccess, useCreateAdminUser, useUpdateAdminSlackConfig, useUpdateServerConfig, - useUpdateServerEncryptionStrategy, - useAdminGrantServerAdminAccess + useUpdateServerEncryptionStrategy } from "./mutation"; export { useAdminGetUsers, diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 12ae1cf64..496990abe 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -4,19 +4,24 @@ import { apiRequest } from "@app/config/request"; import { User } from "../types"; import { + AdminGetIdentitiesFilters, AdminGetUsersFilters, AdminSlackConfig, TGetServerRootKmsEncryptionDetails, TServerConfig } from "./types"; +import { Identity } from "@app/hooks/api/identities/types"; export const adminStandaloneKeys = { - getUsers: "get-users" + getUsers: "get-users", + getIdentities: "get-identities" }; export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, + getIdentities: (filters: AdminGetIdentitiesFilters) => + [adminStandaloneKeys.getIdentities, { filters }] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const, getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const }; @@ -68,6 +73,28 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { }); }; +export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => { + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: adminQueryKeys.getIdentities(filters), + queryFn: async ({ pageParam }) => { + const { data } = await apiRequest.get<{ identities: Identity[] }>( + "/api/v1/admin/identity-management/identities", + { + params: { + ...filters, + offset: pageParam + } + } + ); + + return data.identities; + }, + getNextPageParam: (lastPage, pages) => + lastPage.length !== 0 ? pages.length * filters.limit : undefined + }); +}; + export const useGetAdminSlackConfig = () => { return useQuery({ queryKey: adminQueryKeys.getAdminSlackConfig(), diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index e5d281fc4..11f2cf44f 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -50,6 +50,12 @@ export type TUpdateAdminSlackConfigDTO = { export type AdminGetUsersFilters = { limit: number; searchTerm: string; + adminsOnly: boolean; +}; + +export type AdminGetIdentitiesFilters = { + limit: number; + searchTerm: string; }; 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..922b500b6 100644 --- a/frontend/src/hooks/api/reactQuery.tsx +++ b/frontend/src/hooks/api/reactQuery.tsx @@ -10,280 +10,52 @@ 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) => { - if (axios.isAxiosError(error)) { - const serverResponse = error.response?.data as TApiErrors; - if (serverResponse?.error === ApiErrorTypes.ValidationError) { - createNotification( - { - title: "Validation Error", - type: "error", - text: "Please check the input and try again.", - callToAction: ( - - - - - - - - - - - - - - - {serverResponse.message?.map(({ message, path }) => ( - - - - - ))} - -
FieldIssue
{path.join(".")}{message.toLowerCase()}
-
-
-
- ), - copyActions: [ - { - value: serverResponse.reqId, - name: "Request ID", - label: `Request ID: ${serverResponse.reqId}` - } - ] - }, - { closeOnClick: false } - ); - return; - } - if (serverResponse?.error === ApiErrorTypes.PermissionBoundaryError) { - createNotification( - { - title: "Forbidden Access", - type: "error", - text: `${serverResponse.message}.`, - callToAction: serverResponse?.details?.missingPermissions?.length ? ( - - - - - -
- {serverResponse.details?.missingPermissions?.map((el, index) => { - const hasConditions = Boolean(Object.keys(el.conditions || {}).length); - return ( -
-
- You are not authorized to perform the {el.action} action on the{" "} - {el.subject} resource.{" "} - {hasConditions && - "Your permission does not allow access to the following conditions:"} -
- {hasConditions && ( -
    - {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { - const operators = ( - el.conditions as Record< - string, - | string - | { [K in PermissionConditionOperators]: string | string[] } - > - )[field]; - - const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); - if (typeof operators === "string") { - return ( -
  • - - {formattedFieldName} - {" "} - equal to{" "} - {operators} -
  • - ); - } - - return Object.keys(operators).map((operator, operatorIndex) => ( -
  • - - {formattedFieldName} - {" "} - - { - formatedConditionsOperatorNames[ - operator as PermissionConditionOperators - ] - } - {" "} - - {operators[ - operator as PermissionConditionOperators - ].toString()} - -
  • - )); - })} -
- )} -
- ); - })} -
-
-
- ) : undefined, - copyActions: [ - { - value: serverResponse.reqId, - name: "Request ID", - label: `Request ID: ${serverResponse.reqId}` - } - ] - }, - { closeOnClick: false } - ); - return; - } - if (serverResponse?.error === ApiErrorTypes.ForbiddenError) { - createNotification( - { - title: "Forbidden Access", - type: "error", - text: `${serverResponse.message}.`, - callToAction: serverResponse?.details?.length ? ( - - - - - -
- {serverResponse.details?.map((el, index) => { - const hasConditions = Boolean(Object.keys(el.conditions || {}).length); - return ( -
-
- {el.inverted ? "Cannot" : "Can"}{" "} - - {el.action.toString().replaceAll(",", ", ")} - {" "} - {el.subject.toString()} {hasConditions && "with conditions:"} -
- {hasConditions && ( -
    - {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { - const operators = ( - el.conditions as Record< - string, - | string - | { [K in PermissionConditionOperators]: string | string[] } - > - )[field]; - - const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); - if (typeof operators === "string") { - return ( -
  • - - {formattedFieldName} - {" "} - equal to{" "} - {operators} -
  • - ); - } - - return Object.keys(operators).map((operator, operatorIndex) => ( -
  • - - {formattedFieldName} - {" "} - - { - formatedConditionsOperatorNames[ - operator as PermissionConditionOperators - ] - } - {" "} - - {operators[ - operator as PermissionConditionOperators - ].toString()} - -
  • - )); - })} -
- )} -
- ); - })} -
-
-
- ) : undefined, - copyActions: [ - { - value: serverResponse.reqId, - name: "Request ID", - label: `Request ID: ${serverResponse.reqId}` - } - ] - }, - { closeOnClick: false } - ); - return; - } - createNotification({ - title: "Bad Request", +export const onRequestError = (error: unknown) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as TApiErrors; + if (serverResponse?.error === ApiErrorTypes.ValidationError) { + createNotification( + { + title: "Validation Error", type: "error", - text: `${serverResponse.message}${serverResponse.message?.endsWith(".") ? "" : "."}`, + text: "Please check the input and try again.", + callToAction: ( + + + + + + + + + + + + + + + {serverResponse.message?.map(({ message, path }) => ( + + + + + ))} + +
FieldIssue
{path.join(".")}{message.toLowerCase()}
+
+
+
+ ), copyActions: [ { value: serverResponse.reqId, @@ -291,9 +63,128 @@ export const queryClient = new QueryClient({ label: `Request ID: ${serverResponse.reqId}` } ] - }); - } + }, + { closeOnClick: false } + ); + return; } + if (serverResponse?.error === ApiErrorTypes.ForbiddenError) { + createNotification( + { + title: "Forbidden Access", + type: "error", + text: `${serverResponse.message}.`, + callToAction: serverResponse?.details?.length ? ( + + + + + +
+ {serverResponse.details?.map((el, index) => { + const hasConditions = Boolean(Object.keys(el.conditions || {}).length); + return ( +
+
+ {el.inverted ? "Cannot" : "Can"}{" "} + + {el.action.toString().replaceAll(",", ", ")} + {" "} + {el.subject.toString()} {hasConditions && "with conditions:"} +
+ {hasConditions && ( +
    + {Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => { + const operators = ( + el.conditions as Record< + string, + | string + | { [K in PermissionConditionOperators]: string | string[] } + > + )[field]; + + const formattedFieldName = camelCaseToSpaces(field).toLowerCase(); + if (typeof operators === "string") { + return ( +
  • + + {formattedFieldName} + {" "} + equal to{" "} + {operators} +
  • + ); + } + + return Object.keys(operators).map((operator, operatorIndex) => ( +
  • + {formattedFieldName}{" "} + + { + formatedConditionsOperatorNames[ + operator as PermissionConditionOperators + ] + } + {" "} + + {operators[operator as PermissionConditionOperators].toString()} + +
  • + )); + })} +
+ )} +
+ ); + })} +
+
+
+ ) : undefined, + copyActions: [ + { + value: serverResponse.reqId, + name: "Request ID", + label: `Request ID: ${serverResponse.reqId}` + } + ] + }, + { closeOnClick: false } + ); + return; + } + createNotification({ + title: "Bad Request", + type: "error", + text: `${serverResponse.message}${serverResponse.message?.endsWith(".") ? "" : "."}`, + copyActions: [ + { + value: serverResponse.reqId, + name: "Request ID", + label: `Request ID: ${serverResponse.reqId}` + } + ] + }); + } +}; + +export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: onRequestError }), defaultOptions: { queries: { 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/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 10f835da8..b49fbd6cc 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -16,6 +16,7 @@ import { TDeleteFolderDTO, TGetFoldersByEnvDTO, TGetProjectFoldersDTO, + TProjectEnvironmentsFolders, TSecretFolder, TUpdateFolderBatchDTO, TUpdateFolderDTO @@ -23,7 +24,9 @@ import { export const folderQueryKeys = { getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) => - ["secret-folders", { projectId, environment, path }] as const + ["secret-folders", { projectId, environment, path }] as const, + getProjectEnvironmentsFolders: (projectId: string) => + ["secret-folders", "environment", projectId] as const }; const fetchProjectFolders = async (workspaceId: string, environment: string, path = "/") => { @@ -37,6 +40,29 @@ const fetchProjectFolders = async (workspaceId: string, environment: string, pat return data.folders; }; +export const useListProjectEnvironmentsFolders = ( + projectId: string, + options?: Omit< + UseQueryOptions< + TProjectEnvironmentsFolders, + unknown, + TProjectEnvironmentsFolders, + ReturnType + >, + "queryKey" | "queryFn" + > +) => + useQuery({ + queryKey: folderQueryKeys.getProjectEnvironmentsFolders(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${projectId}/environment-folder-tree` + ); + return data; + }, + ...options + }); + export const useGetProjectFolders = ({ projectId, environment, diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 454e159e1..33653ff72 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -1,3 +1,5 @@ +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + export enum ReservedFolders { SecretReplication = "__reserve_replication_" } @@ -6,6 +8,13 @@ export type TSecretFolder = { id: string; name: string; description?: string; + parentId?: string | null; +}; + +export type TSecretFolderWithPath = TSecretFolder & { path: string }; + +export type TProjectEnvironmentsFolders = { + [key: string]: WorkspaceEnv & { folders: TSecretFolderWithPath[] }; }; export type TGetProjectFoldersDTO = { 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/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index a3e5f8faf..89fc679c4 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -10,13 +10,16 @@ type FolderNameAndDescription = { export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => { const folderNamesAndDescriptions = useMemo(() => { const namesAndDescriptions = new Map(); - + folders?.forEach((folder) => { if (!namesAndDescriptions.has(folder.name)) { - namesAndDescriptions.set(folder.name, { name: folder.name, description: folder.description }); + namesAndDescriptions.set(folder.name, { + name: folder.name, + description: folder.description + }); } }); - + return Array.from(namesAndDescriptions.values()); }, [folders]); diff --git a/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx b/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx index 7da236ef4..99d24ece6 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx @@ -25,8 +25,8 @@ export const MenuIconButton = ({ type="button" role="menuitem" className={twMerge( - "group relative flex w-full cursor-pointer flex-col items-center justify-center rounded my-1 p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700", - isSelected && "bg-bunker-800 hover:bg-mineshaft-600 rounded-none", + "group relative my-1 flex w-full cursor-pointer flex-col items-center justify-center rounded p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700", + isSelected && "rounded-none bg-bunker-800 hover:bg-mineshaft-600", isDisabled && "cursor-not-allowed hover:bg-transparent", className )} 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..82231c0e4 --- /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 [debouncedSearchTerm] = 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 = debouncedSearchTerm + ? orgUser.user.email?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + orgUser.user.firstName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + orgUser.user.lastName?.toLowerCase().includes(debouncedSearchTerm.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/lib/schemas/index.ts b/frontend/src/lib/schemas/index.ts index 41b8ace4b..f46500dc8 100644 --- a/frontend/src/lib/schemas/index.ts +++ b/frontend/src/lib/schemas/index.ts @@ -1 +1,13 @@ +import { z } from "zod"; + export * from "./slugSchema"; + +export const GenericResourceNameSchema = z + .string() + .trim() + .min(1, { message: "Name must be at least 1 character" }) + .max(64, { message: "Name must be 64 or fewer characters" }) + .regex( + /^[a-zA-Z0-9\-_\s]+$/, + "Name can only contain alphanumeric characters, dashes, underscores, and spaces" + ); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e2114636e..088d28250 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,6 +10,7 @@ import { NotFoundPage } from "./pages/public/NotFoundPage/NotFoundPage"; // Import the generated route tree import { routeTree } from "./routeTree.gen"; +import "@xyflow/react/dist/style.css"; import "nprogress/nprogress.css"; import "react-toastify/dist/ReactToastify.css"; import "@fortawesome/fontawesome-svg-core/styles.css"; diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index ad07b71ce..776c5e20f 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -34,6 +34,7 @@ import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; import { RateLimitPanel } from "./components/RateLimitPanel"; import { UserPanel } from "./components/UserPanel"; +import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel"; enum TabSections { Settings = "settings", @@ -42,6 +43,7 @@ enum TabSections { RateLimit = "rate-limit", Integrations = "integrations", Users = "users", + Identities = "identities", Kmip = "kmip" } @@ -164,6 +166,7 @@ export const OverviewPage = () => { Rate Limit Integrations Users + Identities
@@ -409,6 +412,9 @@ export const OverviewPage = () => { + + +
)} diff --git a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx new file mode 100644 index 000000000..ee2166a4e --- /dev/null +++ b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + EmptyState, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; +import { useAdminGetIdentities } from "@app/hooks/api/admin/queries"; + +const IdentityPanelTable = () => { + const [searchIdentityFilter, setSearchIdentityFilter] = useState(""); + const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500); + + const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities( + { + limit: 20, + searchTerm: debouncedSearchTerm + } + ); + + const isEmpty = !isPending && !data?.pages?.[0].length; + + return ( + <> +
+ setSearchIdentityFilter(e.target.value)} + leftIcon={} + placeholder="Search identities by name..." + className="flex-1" + /> +
+
+ + + + + + + + + {isPending && } + {!isPending && + data?.pages?.map((identities) => + identities.map(({ name, id }) => ( + + + + )) + )} + +
Name
{name}
+ {!isPending && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ + ); +}; + +export const IdentityPanel = () => ( +
+
+

Identities

+
+ +
+); diff --git a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx index df51216a6..84d0ed6f0 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"; @@ -32,7 +42,6 @@ import { } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const deleteUserUpgradePlanMessage = "Deleting users via Admin UI"; const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; const UserPanelTable = ({ @@ -48,25 +57,63 @@ const UserPanelTable = ({ ) => void; }) => { const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); const { user } = useUser(); const userId = user?.id || ""; - const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); + const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); const { subscription } = useSubscription(); const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ limit: 20, - searchTerm: debounedSearchTerm + searchTerm: debouncedSearchTerm, + 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 +
+
+
+
+
@@ -108,14 +155,6 @@ const UserPanelTable = ({ { e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: deleteUserUpgradePlanMessage - }); - return; - } handlePopUpOpen("removeUser", { username, id }); }} > 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/organization/AuditLogsPage/components/LogsTable.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx index f37482548..4b3f2b270 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx @@ -28,7 +28,14 @@ type Props = { const AUDIT_LOG_LIMIT = 15; -const TABLE_HEADERS = ["Timestamp", "Event", "Project", "Actor", "Source", "Metadata"] as const; +const TABLE_HEADERS = [ + "Timestamp (MM/DD/YYYY)", + "Event", + "Project", + "Actor", + "Source", + "Metadata" +] as const; export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number]; export const LogsTable = ({ diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx index 5c3337938..cc67e5319 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx @@ -83,7 +83,7 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop hours %= 12; hours = hours || 12; // the hour '0' should be '12' - const formattedDate = `${day}-${month}-${year} at ${hours}:${minutes} ${period}`; + const formattedDate = `${month}-${day}-${year} at ${hours}:${minutes} ${period}`; return formattedDate; }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index bcf3e0446..15ce740bc 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -14,9 +14,10 @@ import { } from "@app/context"; import { isCustomOrgRole } from "@app/helpers/roles"; import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z.string().max(64, "Too long, maximum length is 64 characters"), + name: GenericResourceNameSchema, slug: z .string() .regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens"), diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx index 46a9c5680..fa9500a53 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -58,6 +58,7 @@ const schema = z.object({ permissions: z .object({ read: z.boolean(), + readValue: z.boolean(), write: z.boolean() }) .required() @@ -296,6 +297,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { name="permissions" defaultValue={{ read: true, + readValue: false, write: false }} render={({ field: { onChange, value }, fieldState: { error } }) => { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx index 8617fe7d1..e78859344 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionConditions.tsx @@ -45,8 +45,11 @@ export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: return (

Conditions

-

- When this policy should apply (always if no conditions are added). +

+ Conditions determine when a policy will be applied (always if no conditions are present). +

+

+ All conditions must evaluate to true for the policy to take effect.

{items.fields.map((el, index) => { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx index de2231fc9..a6e472976 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx @@ -35,12 +35,13 @@ export const GeneralPermissionPolicies = ) => { - const { control } = useFormContext(); + const { control, watch } = useFormContext(); const items = useFieldArray({ control, name: `permissions.${subject}` }); const [isOpen, setIsOpen] = useToggle(); + // const [hideFullReadAccess, setHideFullReadAccess] = useState(false); if (!items.fields.length) return
; @@ -71,116 +72,138 @@ export const GeneralPermissionPolicies = {isOpen && (
- {items.fields.map((el, rootIndex) => ( -
- {isConditionalSubjects(subject) && ( -
-
Permission
-
- ( - - )} - /> -
-
- -

- Whether to allow or forbid the selected actions when the following - conditions (if any) are met. -

-

Forbid rules must come after allow rules.

- - } - > - -
-
-
- )} -
-
Actions
-
- {actions.map(({ label, value }) => { - if (typeof value !== "string") return undefined; - return ( + {items.fields.map((el, rootIndex) => { + let isFullReadAccessEnabled = false; + + if (subject === ProjectPermissionSub.Secrets) { + isFullReadAccessEnabled = watch(`permissions.${subject}.${rootIndex}.read` as any); + } + + return ( +
+ {isConditionalSubjects(subject) && ( +
+
Permission
+
( -
- - {label} - -
+ )} /> - ); +
+
+ +

+ Whether to allow or forbid the selected actions when the following + conditions (if any) are met. +

+

Forbid rules must come after allow rules.

+ + } + > + +
+
+
+ )} +
+
Actions
+
+ {actions.map(({ label, value }, index) => { + if (typeof value !== "string") return undefined; + + if ( + subject === ProjectPermissionSub.Secrets && + value === "read" && + !isFullReadAccessEnabled + ) { + return null; + } + + return ( + { + return ( +
+ + {label} + +
+ ); + }} + /> + ); + })} +
+
+ {children && + cloneElement(children, { + position: rootIndex })} +
+ {!isDisabled && isConditionalSubjects(subject) && ( + + )} + {!isDisabled && ( + + )}{" "}
- {children && - cloneElement(children, { - position: rootIndex - })} -
- {!isDisabled && isConditionalSubjects(subject) && ( - - )} - {!isDisabled && ( - - )}{" "} -
-
- ))} + ); + })}
)}
diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/IdentityManagementPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/IdentityManagementPermissionConditions.tsx index c709ea49f..7ce043fac 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/IdentityManagementPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/IdentityManagementPermissionConditions.tsx @@ -39,8 +39,11 @@ export const IdentityManagementPermissionConditions = ({ position = 0, isDisable return (

Conditions

-

- When this policy should apply (always if no conditions are added). +

+ Conditions determine when a policy will be applied (always if no conditions are present). +

+

+ All conditions must evaluate to true for the policy to take effect.

{items.fields.map((el, index) => { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 7dcb118fd..a47a64614 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -1,5 +1,9 @@ +import { ReactNode } from "react"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { z } from "zod"; +import { Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionCmekActions, @@ -9,6 +13,7 @@ import { PermissionConditionOperators, ProjectPermissionDynamicSecretActions, ProjectPermissionKmipActions, + ProjectPermissionSecretActions, ProjectPermissionSecretSyncActions, TPermissionCondition, TPermissionConditionOperators @@ -22,6 +27,15 @@ const GeneralPolicyActionSchema = z.object({ create: z.boolean().optional() }); +const SecretPolicyActionSchema = z.object({ + [ProjectPermissionSecretActions.DescribeAndReadValue]: z.boolean().optional(), // existing read, gives both describe and read value + [ProjectPermissionSecretActions.DescribeSecret]: z.boolean().optional(), + [ProjectPermissionSecretActions.ReadValue]: z.boolean().optional(), + [ProjectPermissionSecretActions.Edit]: z.boolean().optional(), + [ProjectPermissionSecretActions.Delete]: z.boolean().optional(), + [ProjectPermissionSecretActions.Create]: z.boolean().optional() +}); + const CmekPolicyActionSchema = z.object({ read: z.boolean().optional(), edit: z.boolean().optional(), @@ -114,7 +128,7 @@ export const projectRoleFormSchema = z.object({ .refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }), permissions: z .object({ - [ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({ + [ProjectPermissionSub.Secrets]: SecretPolicyActionSchema.extend({ inverted: z.boolean().optional(), conditions: ConditionSchema }) @@ -283,6 +297,33 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { }); return; } + + if (subject === ProjectPermissionSub.Secrets) { + const canDescribeAndReadValue = action.includes( + ProjectPermissionSecretActions.DescribeAndReadValue + ); + const canDescribe = action.includes(ProjectPermissionSecretActions.DescribeSecret); + const canReadValue = action.includes(ProjectPermissionSecretActions.ReadValue); + + const canEdit = action.includes(ProjectPermissionSecretActions.Edit); + const canDelete = action.includes(ProjectPermissionSecretActions.Delete); + const canCreate = action.includes(ProjectPermissionSecretActions.Create); + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + describeSecret: canDescribe, + read: canDescribeAndReadValue, + readValue: canReadValue, + create: canCreate, + edit: canEdit, + delete: canDelete, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], + inverted + }); + + return; + } + // for other subjects const canRead = action.includes(ProjectPermissionActions.Read); const canEdit = action.includes(ProjectPermissionActions.Edit); @@ -470,7 +511,7 @@ export type TProjectPermissionObject = { [K in ProjectPermissionSub]: { title: string; actions: { - label: string; + label: string | ReactNode; value: keyof Omit< NonNullable[K]>[number], "conditions" | "inverted" @@ -483,10 +524,35 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { [ProjectPermissionSub.Secrets]: { title: "Secrets", actions: [ - { label: "Read", value: "read" }, - { label: "Create", value: "create" }, - { label: "Modify", value: "edit" }, - { label: "Remove", value: "delete" } + { + label: ( +
+

+ Read (legacy) +

+ + This is a legacy action and will be removed in the future. +
+
You should instead use the{" "} + Describe Secret and{" "} + Read Value actions. +
+ } + > + + +
+ ), + value: ProjectPermissionSecretActions.DescribeAndReadValue + }, + { label: "Describe Secret", value: ProjectPermissionSecretActions.DescribeSecret }, + { label: "Read Value", value: ProjectPermissionSecretActions.ReadValue }, + { label: "Modify", value: ProjectPermissionSecretActions.Edit }, + { label: "Remove", value: ProjectPermissionSecretActions.Delete }, + { label: "Create", value: ProjectPermissionSecretActions.Create } ] }, [ProjectPermissionSub.SecretFolders]: { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 716b6ba5e..d44927edf 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -1,10 +1,13 @@ +import { useMemo } from "react"; import { FormProvider, useForm } from "react-hook-form"; +import { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability"; import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; +import { AccessTree } from "@app/components/permissions"; import { Button, DropdownMenu, @@ -13,6 +16,8 @@ import { DropdownMenuTrigger } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { evaluatePermissionsAbility } from "@app/helpers/permissions"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; @@ -115,94 +120,109 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { } }; + const permissions = form.watch("permissions"); + + const formattedPermissions = useMemo( + () => + evaluatePermissionsAbility( + formRolePermission2API(permissions) as RawRuleOf< + MongoAbility + >[] + ), + [JSON.stringify(permissions)] + ); + return ( -
- -
-

Policies

-
- {isCustomRole && ( - <> - {isDirty && ( - - )} -
- - - - - - - {Object.keys(PROJECT_PERMISSION_OBJECT) - .sort((a, b) => - PROJECT_PERMISSION_OBJECT[ - a as keyof typeof PROJECT_PERMISSION_OBJECT - ].title - .toLowerCase() - .localeCompare( - PROJECT_PERMISSION_OBJECT[ - b as keyof typeof PROJECT_PERMISSION_OBJECT - ].title.toLowerCase() - ) - ) - .map((subject) => ( - onNewPolicy(subject as ProjectPermissionSub)} - > - {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} - - ))} - - -
- - )} +
+ + + +
+

Policies

+
+ {isCustomRole && ( + <> + {isDirty && ( + + )} +
+ + + + + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[ + a as keyof typeof PROJECT_PERMISSION_OBJECT + ].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + + +
+ + )} +
-
-
- {!isPending && } - {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( - - {renderConditionalComponents(subject, isDisabled)} - - ))} -
- - +
+ {!isPending && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {renderConditionalComponents(subject, isDisabled)} + + ))} +
+ + +
); }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx index ab893cb09..bfbe20af7 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx @@ -43,8 +43,11 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) return (

Conditions

-

- When this policy should apply (always if no conditions are added). +

+ Conditions determine when a policy will be applied (always if no conditions are present). +

+

+ All conditions must evaluate to true for the policy to take effect.

{items.fields.map((el, index) => { diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index f96aabf53..78a2380d0 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -228,7 +228,8 @@ export const OverviewPage = () => { setPage }); - const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); + const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = + useFolderOverview(folders); const { dynamicSecretNames, isDynamicSecretPresentInEnv } = useDynamicSecretOverview(dynamicSecrets); @@ -251,7 +252,7 @@ export const OverviewPage = () => { "updateFolder" ] as const); - const handleFolderCreate = async (folderName: string, description: string | null) => { + const handleFolderCreate = async (folderName: string, description: string | null) => { const promises = userAvailableEnvs.map((env) => { const environment = env.slug; return createFolder({ @@ -1029,7 +1030,7 @@ export const OverviewPage = () => { )} {!isOverviewLoading && visibleEnvs.length > 0 && ( <> - {folderNamesAndDescriptions.map(({name: folderName, description}, index) => ( + {folderNamesAndDescriptions.map(({ name: folderName, description }, index) => ( { )?.name} - defaultDescription={(popUp.updateFolder?.data as Pick)?.description} + defaultDescription={ + (popUp.updateFolder?.data as Pick)?.description + } onUpdateFolder={handleFolderUpdate} showDescriptionOverwriteWarning /> diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index c894d6b14..7248bc19f 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -16,6 +16,7 @@ import { useProjectPermission, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getKeyValue } from "@app/helpers/parseEnvVar"; import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; import { SecretType } from "@app/hooks/api/types"; @@ -275,7 +276,7 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { isMulti options={environments.filter((environment) => permission.can( - ProjectPermissionActions.Create, + ProjectPermissionSecretActions.Create, subject(ProjectPermissionSub.Secrets, { environment: environment.slug, secretPath, diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index d3dfd49e7..705d7e177 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -25,10 +25,13 @@ import { ModalTrigger, Tooltip } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useToggle } from "@app/hooks"; import { SecretType } from "@app/hooks/api/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; type Props = { defaultValue?: string | null; @@ -39,6 +42,7 @@ type Props = { isVisible?: boolean; isImportedSecret: boolean; environment: string; + secretValueHidden: boolean; secretPath: string; onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: ( @@ -58,6 +62,7 @@ export const SecretEditRow = ({ isImportedSecret, onSecretUpdate, secretName, + secretValueHidden, onSecretCreate, onSecretDelete, environment, @@ -76,6 +81,9 @@ export const SecretEditRow = ({ value: defaultValue || null } }); + + const { permission } = useProjectPermission(); + const [isDeleting, setIsDeleting] = useToggle(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -117,6 +125,11 @@ export const SecretEditRow = ({ reset({ value }); }; + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue + ); + const handleDeleteSecret = useCallback(async () => { setIsDeleting.on(); setIsModalOpen(false); @@ -140,24 +153,29 @@ export const SecretEditRow = ({ />
- ( - - )} - /> + {secretValueHidden ? ( + + ) : ( + ( + + )} + /> + )}
+
- - {(isAllowed) => ( -
- - -
- - - - - -
-
- e.preventDefault()} // prevents secret input from displaying value on open + +
+ + +
+ - - - -
- )} - + + + + +
+ + e.preventDefault()} // prevents secret input from displaying value on open + > + + +
+
+
-
NO ACCESS
+
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index b9376f6b4..3a66f6896 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -221,10 +221,13 @@ export const SecretOverviewTableRow = ({ secretPath={secretPath} isVisible={isSecretVisible} secretName={secretKey} + secretValueHidden={secret?.secretValueHidden || false} defaultValue={ - secret?.valueOverride || - secret?.value || - importedSecret?.secret?.value + secret?.secretValueHidden + ? "" + : secret?.valueOverride || + secret?.value || + importedSecret?.secret?.value } secretId={secret?.id} isOverride={Boolean(secret?.valueOverride)} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx index ae1916b86..ce2e43f67 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretRenameRow.tsx @@ -10,15 +10,12 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - useProjectPermission, - useWorkspace -} from "@app/context"; +import { ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useToggle } from "@app/hooks"; import { useUpdateSecretV3 } from "@app/hooks/api"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; enum SecretActionType { Created = "created", @@ -55,8 +52,11 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath } secretTags: (secretDetails?.tags || []).map((i) => i.slug) }); const isSecretInEnvReadOnly = - permission.can(ProjectPermissionActions.Read, secretPermissionSubject) && - permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject); + hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.DescribeSecret, + secretPermissionSubject + ) && permission.cannot(ProjectPermissionSecretActions.Edit, secretPermissionSubject); if (isSecretInEnvReadOnly) { return true; } diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx index e901a4ff7..9943de5b6 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx @@ -110,21 +110,31 @@ export const QuickSearchSecretItem = ({ )} {isSingleEnv ? ( - { - e.stopPropagation(); - const el = envSlugMap.get(groupSecret.env)?.name; - if (el) { - handleCopy(groupSecret.value!, el); - } - }} + - - + { + e.stopPropagation(); + const el = envSlugMap.get(groupSecret.env)?.name; + if (el) { + handleCopy(groupSecret.value!, el); + } + }} + > + + + ) : ( @@ -158,14 +168,24 @@ export const QuickSearchSecretItem = ({ )} - - - + + + + Hover to Reveal... diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx index 4738e411e..4e048b941 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -11,6 +11,7 @@ import { useProjectPermission, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; import { @@ -58,7 +59,7 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri // user should have the ability to delete secrets/folders in at least one of the envs const shouldShowDelete = userAvailableEnvs.some((env) => permission.can( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath, @@ -110,7 +111,7 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri (accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => { const entry = secretRecord[env.slug]; const canDeleteSecret = permission.can( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath, diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx index 8a27a24c0..a81fa3e53 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/MoveSecretsDialog.tsx @@ -25,7 +25,8 @@ import { Spinner, Switch } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useDebounce } from "@app/hooks"; import { useMoveSecrets } from "@app/hooks/api"; import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard"; @@ -95,7 +96,7 @@ const Content = ({ env.slug, { missingPermissions: permission.cannot( - ProjectPermissionActions.Delete, + ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath: sourceSecretPath, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index daf875a78..e6fc5eadf 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -25,6 +25,7 @@ import { useProjectPermission, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useGetImportedSecretsSingleEnv, @@ -37,6 +38,7 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection"; @@ -102,14 +104,27 @@ const Page = () => { const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; const secretPath = (routerQueryParams.secretPath as string) || "/"; - const canReadSecret = permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { + + const canReadSecret = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.DescribeSecret, + { environment, secretPath, secretName: "*", secretTags: ["*"] - }) + } + ); + + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: "*", + secretTags: ["*"] + } ); const canReadSecretImports = permission.can( @@ -176,6 +191,7 @@ const Page = () => { orderDirection, includeImports: canReadSecretImports && filter.include.import, includeFolders: filter.include.folder, + viewSecretValue: canReadSecretValue, includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic, includeSecrets: canReadSecret && filter.include.secret, tags: filter.tags diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index 03ff3dd57..d545ba748 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -21,6 +21,7 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AxiosError } from "axios"; import FileSaver from "file-saver"; import { twMerge } from "tailwind-merge"; @@ -54,7 +55,7 @@ import { import { usePopUp } from "@app/hooks"; import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api"; import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries"; -import { SecretType, WsTag } from "@app/hooks/api/types"; +import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types"; import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput"; import { @@ -152,51 +153,71 @@ export const ActionBar = ({ }; const handleSecretDownload = async () => { - const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({ - workspaceId, - expandSecretReferences: true, - includeImports: true, - environment, - secretPath - }); - const secretsPicked = new Set(); - const secretsToDownload: { key: string; value?: string; comment?: string }[] = []; - localSecrets.forEach((el) => { - secretsPicked.add(el.secretKey); - secretsToDownload.push({ - key: el.secretKey, - value: el.secretValue, - comment: el.secretComment + try { + const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({ + workspaceId, + expandSecretReferences: true, + includeImports: true, + environment, + secretPath + }); + const secretsPicked = new Set(); + const secretsToDownload: { key: string; value?: string; comment?: string }[] = []; + localSecrets.forEach((el) => { + secretsPicked.add(el.secretKey); + secretsToDownload.push({ + key: el.secretKey, + value: el.secretValue, + comment: el.secretComment + }); }); - }); - for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) { - for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) { - const secret = localImportedSecrets[i].secrets[j]; - if (!secretsPicked.has(secret.secretKey)) { - secretsToDownload.push({ - key: secret.secretKey, - value: secret.secretValue, - comment: secret.secretComment - }); + for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) { + for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) { + const secret = localImportedSecrets[i].secrets[j]; + if (!secretsPicked.has(secret.secretKey)) { + secretsToDownload.push({ + key: secret.secretKey, + value: secret.secretValue, + comment: secret.secretComment + }); + } + secretsPicked.add(secret.secretKey); } - secretsPicked.add(secret.secretKey); } + + const file = secretsToDownload + .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) + .reduce( + (prev, { key, comment, value }, index) => + prev + + (comment + ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` + : `${key}=${value}\n`), + "" + ); + + const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, `${environment}.env`); + } catch (err) { + if (err instanceof AxiosError) { + const error = err?.response?.data as TApiErrors; + + if (error?.error === ApiErrorTypes.ForbiddenError && error.message.includes("readValue")) { + createNotification({ + title: "You don't have permission to download secrets", + text: "You don't have permission to view one or more of the secrets in the current folder. Please contact your administrator.", + type: "error" + }); + return; + } + } + createNotification({ + title: "Failed to download secrets", + text: "Please try again later.", + type: "error" + }); } - - const file = secretsToDownload - .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) - .reduce( - (prev, { key, comment, value }, index) => - prev + - (comment - ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` - : `${key}=${value}\n`), - "" - ); - - const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); - FileSaver.saveAs(blob, `${environment}.env`); }; const handleSecretBulkDelete = async () => { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx index a4962d10c..4af92b210 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx @@ -23,6 +23,27 @@ import { useWorkspace } from "@app/context"; import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; +const passwordRequirementsSchema = z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length"); + const formSchema = z.object({ provider: z.object({ client: z.nativeEnum(SqlProviders), @@ -31,6 +52,7 @@ const formSchema = z.object({ database: z.string().min(1), username: z.string().min(1), password: z.string().min(1), + passwordRequirements: passwordRequirementsSchema.optional(), creationStatement: z.string().min(1), revocationStatement: z.string().min(1), renewStatement: z.string().optional(), @@ -133,11 +155,24 @@ export const SqlDatabaseInputForm = ({ control, setValue, formState: { isSubmitting }, - handleSubmit + handleSubmit, + watch } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - provider: getSqlStatements(SqlProviders.Postgres) + provider: { + ...getSqlStatements(SqlProviders.Postgres), + passwordRequirements: { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" + } + } } }); @@ -174,6 +209,10 @@ export const SqlDatabaseInputForm = ({ setValue("provider.renewStatement", sqlStatment.renewStatement); setValue("provider.revocationStatement", sqlStatment.revocationStatement); setValue("provider.port", getDefaultPort(type)); + + // Update password requirements based on provider + const length = type === SqlProviders.Oracle ? 30 : 48; + setValue("provider.passwordRequirements.length", length); }; return ( @@ -197,6 +236,7 @@ export const SqlDatabaseInputForm = ({ )} />
+
)} /> - - - Modify SQL Statements + + + + Creation, Revocation & Renew Statements (optional) + +
+ Customize SQL statements for managing database user lifecycle +
+ + + Password Configuration (optional) + +
+ Set constraints on the generated database password +
+
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+ +
+

Minimum Required Character Counts

+
+ {(() => { + const total = Object.values( + watch("provider.passwordRequirements.required") || {} + ).reduce((sum, count) => sum + Number(count || 0), 0); + const length = watch("provider.passwordRequirements.length") || 0; + const isError = total > length; + return ( + + Total required characters: {total}{" "} + {isError ? `(exceeds length of ${length})` : ""} + + ); + })()} +
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+
+ +
+

Allowed Symbols

+ ( + + + + )} + /> +
+
+
+
+
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx index 78d075b4f..885838498 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx @@ -15,7 +15,8 @@ type Props = { showDescriptionOverwriteWarning?: boolean; }; -const descriptionOverwriteWarningMessage = "Warning: Any changes made here will overwrite any custom edits in individual environment folders." +const descriptionOverwriteWarningMessage = + "Warning: Any changes made here will overwrite any custom edits in individual environment folders."; const formSchema = z.object({ name: z @@ -25,9 +26,7 @@ const formSchema = z.object({ /^[a-zA-Z0-9-_]+$/, "Folder name can only contain letters, numbers, dashes, and underscores" ), - description: z - .string() - .optional() + description: z.string().optional() }); type TFormData = z.infer; @@ -59,7 +58,7 @@ export const FolderForm = ({ if (textarea) { const lines = textarea.value.split("\n"); const maxDescriptionLines = 10; - + if (lines.length > maxDescriptionLines) { textarea.value = lines.slice(0, maxDescriptionLines).join("\n"); } @@ -90,30 +89,32 @@ export const FolderForm = ({ )} /> ( - -