diff --git a/.github/workflows/release-k8-operator-helm.yml b/.github/workflows/release-k8-operator-helm.yml new file mode 100644 index 000000000..f3731fb46 --- /dev/null +++ b/.github/workflows/release-k8-operator-helm.yml @@ -0,0 +1,27 @@ +name: Release K8 Operator Helm Chart +on: + workflow_dispatch: + +jobs: + release-helm: + name: Release Helm Chart + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install Helm + uses: azure/setup-helm@v3 + with: + version: v3.10.0 + + - name: Install python + uses: actions/setup-python@v4 + + - name: Install Cloudsmith CLI + run: pip install --upgrade cloudsmith-cli + + - name: Build and push helm package to CloudSmith + run: cd helm-charts && sh upload-k8s-operator-cloudsmith.sh + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} diff --git a/.github/workflows/release_docker_k8_operator.yaml b/.github/workflows/release_docker_k8_operator.yaml index 162cc9dc0..4af62df1c 100644 --- a/.github/workflows/release_docker_k8_operator.yaml +++ b/.github/workflows/release_docker_k8_operator.yaml @@ -1,52 +1,103 @@ -name: Release image + Helm chart K8s Operator +name: Release K8 Operator Docker Image on: - push: - tags: - - "infisical-k8-operator/v*.*.*" + push: + tags: + - "infisical-k8-operator/v*.*.*" jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Extract version from tag - id: extract_version - run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical-k8-operator/}" - - uses: actions/checkout@v2 + release-image: + name: Generate Helm Chart PR + runs-on: ubuntu-latest + outputs: + pr_number: ${{ steps.create-pr.outputs.pull-request-number }} + steps: + - name: Extract version from tag + id: extract_version + run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical-k8-operator/}" - - name: 🔧 Set up QEMU - uses: docker/setup-qemu-action@v1 + - name: Checkout code + uses: actions/checkout@v2 - - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + # Dependency for helm generation + - name: Install Helm + uses: azure/setup-helm@v3 + with: + version: v3.10.0 - - name: 🐋 Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + # Dependency for helm generation + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: 1.21 - - name: Build and push - id: docker_build - uses: docker/build-push-action@v2 - with: - context: k8-operator - push: true - platforms: linux/amd64,linux/arm64 - tags: | - infisical/kubernetes-operator:latest - infisical/kubernetes-operator:${{ steps.extract_version.outputs.version }} + # Install binaries for helm generation + - name: Install dependencies + working-directory: k8-operator + run: | + make helmify + make kustomize + make controller-gen - - name: Checkout - uses: actions/checkout@v2 - - name: Install Helm - uses: azure/setup-helm@v3 - with: - version: v3.10.0 - - name: Install python - uses: actions/setup-python@v4 - - name: Install Cloudsmith CLI - run: pip install --upgrade cloudsmith-cli - - name: Build and push helm package to Cloudsmith - run: cd helm-charts && sh upload-k8s-operator-cloudsmith.sh - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + - name: Generate Helm Chart + working-directory: k8-operator + run: make helm + + - name: Update Helm Chart Version + run: ./k8-operator/scripts/update-version.sh ${{ steps.extract_version.outputs.version }} + + - name: Debug - Check file changes + run: | + echo "Current git status:" + git status + echo "" + echo "Modified files:" + git diff --name-only + + # If there is no diff, exit with error. Version should always be changed, so if there is no diff, something is wrong and we should exit. + if [ -z "$(git diff --name-only)" ]; then + echo "No helm changes or version changes. Invalid release detected, Exiting." + exit 1 + fi + + - name: Create Helm Chart PR + id: create-pr + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "Update Helm chart to version ${{ steps.extract_version.outputs.version }}" + committer: GitHub + author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com> + branch: helm-update-${{ steps.extract_version.outputs.version }} + delete-branch: true + title: "Update Helm chart to version ${{ steps.extract_version.outputs.version }}" + body: | + This PR updates the Helm chart to version `${{ steps.extract_version.outputs.version }}`. + Additionally the helm chart has been updated to match the latest operator code changes. + + Associated Release Workflow: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + + Once you have approved this PR, you can trigger the helm release workflow manually. + base: main + + - name: 🔧 Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: 🐋 Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: k8-operator + push: true + platforms: linux/amd64,linux/arm64 + tags: | + infisical/kubernetes-operator:latest + infisical/kubernetes-operator:${{ steps.extract_version.outputs.version }} diff --git a/.infisicalignore b/.infisicalignore index 638a799e2..8072078d4 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -8,3 +8,7 @@ frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/S docs/mint.json:generic-api-key:651 backend/src/ee/services/hsm/hsm-service.ts:generic-api-key:134 docs/documentation/platform/audit-log-streams/audit-log-streams.mdx:generic-api-key:104 +docs/cli/commands/bootstrap.mdx:jwt:86 +docs/documentation/platform/audit-log-streams/audit-log-streams.mdx:generic-api-key:102 +docs/self-hosting/guides/automated-bootstrapping.mdx:jwt:74 +frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx:generic-api-key:72 diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 99c7c4184..5d10830ad 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -106,6 +106,7 @@ declare module "@fastify/request-context" { claims: Record; }; }; + identityPermissionMetadata?: Record; // filled by permission service } } diff --git a/backend/src/db/manual-migrations/partition-audit-logs.ts b/backend/src/db/manual-migrations/partition-audit-logs.ts index 382ef0dbf..fbead3a24 100644 --- a/backend/src/db/manual-migrations/partition-audit-logs.ts +++ b/backend/src/db/manual-migrations/partition-audit-logs.ts @@ -16,7 +16,7 @@ const createAuditLogPartition = async (knex: Knex, startDate: Date, endDate: Dat const startDateStr = formatPartitionDate(startDate); const endDateStr = formatPartitionDate(endDate); - const partitionName = `${TableName.AuditLog}_${startDateStr.replace(/-/g, "")}_${endDateStr.replace(/-/g, "")}`; + const partitionName = `${TableName.AuditLog}_${startDateStr.replaceAll("-", "")}_${endDateStr.replaceAll("-", "")}`; await knex.schema.raw( `CREATE TABLE ${partitionName} PARTITION OF ${TableName.AuditLog} FOR VALUES FROM ('${startDateStr}') TO ('${endDateStr}')` diff --git a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts new file mode 100644 index 000000000..9823f4d8e --- /dev/null +++ b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Organization, "shouldUseNewPrivilegeSystem"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("shouldUseNewPrivilegeSystem"); + t.string("privilegeUpgradeInitiatedByUsername"); + t.dateTime("privilegeUpgradeInitiatedAt"); + }); + + await knex(TableName.Organization).update({ + shouldUseNewPrivilegeSystem: false + }); + + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("shouldUseNewPrivilegeSystem").defaultTo(true).notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "shouldUseNewPrivilegeSystem")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("shouldUseNewPrivilegeSystem"); + t.dropColumn("privilegeUpgradeInitiatedByUsername"); + t.dropColumn("privilegeUpgradeInitiatedAt"); + }); + } +} diff --git a/backend/src/db/migrations/20250319134021_recursive-folder-index.ts b/backend/src/db/migrations/20250319134021_recursive-folder-index.ts new file mode 100644 index 000000000..e26b6d876 --- /dev/null +++ b/backend/src/db/migrations/20250319134021_recursive-folder-index.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesParentColumExist = await knex.schema.hasColumn(TableName.SecretFolder, "parentId"); + const doesNameColumnExist = await knex.schema.hasColumn(TableName.SecretFolder, "name"); + if (doesParentColumExist && doesNameColumnExist) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + t.index(["parentId", "name"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesParentColumExist = await knex.schema.hasColumn(TableName.SecretFolder, "parentId"); + const doesNameColumnExist = await knex.schema.hasColumn(TableName.SecretFolder, "name"); + if (doesParentColumExist && doesNameColumnExist) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + t.dropIndex(["parentId", "name"]); + }); + } +} diff --git a/backend/src/db/migrations/20250321100157_k8s-self-reviewer-jwt.ts b/backend/src/db/migrations/20250321100157_k8s-self-reviewer-jwt.ts new file mode 100644 index 000000000..6cc3a2696 --- /dev/null +++ b/backend/src/db/migrations/20250321100157_k8s-self-reviewer-jwt.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasReviewerJwtCol = await knex.schema.hasColumn( + TableName.IdentityKubernetesAuth, + "encryptedKubernetesTokenReviewerJwt" + ); + if (hasReviewerJwtCol) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => { + t.binary("encryptedKubernetesTokenReviewerJwt").nullable().alter(); + }); + } +} + +export async function down(): Promise { + // we can't make it back to non nullable, it will fail +} diff --git a/backend/src/db/migrations/20250324142102_add-self-approvals-to-secret-approval-policies.ts b/backend/src/db/migrations/20250324142102_add-self-approvals-to-secret-approval-policies.ts new file mode 100644 index 000000000..ee99fce67 --- /dev/null +++ b/backend/src/db/migrations/20250324142102_add-self-approvals-to-secret-approval-policies.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas/models"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "allowedSelfApprovals"))) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.boolean("allowedSelfApprovals").notNullable().defaultTo(true); + }); + } + if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "allowedSelfApprovals"))) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.boolean("allowedSelfApprovals").notNullable().defaultTo(true); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "allowedSelfApprovals")) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("allowedSelfApprovals"); + }); + } + if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "allowedSelfApprovals")) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("allowedSelfApprovals"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index 3650face9..19a98675f 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -16,7 +16,8 @@ export const AccessApprovalPoliciesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), enforcementLevel: z.string().default("hard"), - deletedAt: z.date().nullable().optional() + deletedAt: z.date().nullable().optional(), + allowedSelfApprovals: z.boolean().default(true) }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 85f210ff1..448cec386 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -28,7 +28,7 @@ export const IdentityKubernetesAuthsSchema = z.object({ allowedNamespaces: z.string(), allowedNames: z.string(), allowedAudience: z.string(), - encryptedKubernetesTokenReviewerJwt: zodBuffer, + encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(), encryptedKubernetesCaCertificate: zodBuffer.nullable().optional() }); diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 6639528a0..7e5994938 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,6 +23,9 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + shouldUseNewPrivilegeSystem: z.boolean().default(true), + privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), + privilegeUpgradeInitiatedAt: z.date().nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional() }); diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 06ae3e5c4..8b9174456 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -16,7 +16,8 @@ export const SecretApprovalPoliciesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), enforcementLevel: z.string().default("hard"), - deletedAt: z.date().nullable().optional() + deletedAt: z.date().nullable().optional(), + allowedSelfApprovals: z.boolean().default(true) }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 7f401216e..e67d037ea 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -16,7 +16,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = // for CSRs sent in PEM, we leave them as is // for CSRs sent in base64, we preprocess them to remove new lines and spaces if (!csrBody.includes("BEGIN CERTIFICATE REQUEST")) { - csrBody = csrBody.replace(/\n/g, "").replace(/ /g, ""); + csrBody = csrBody.replaceAll("\n", "").replaceAll(" ", ""); } done(null, csrBody); diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 814d19841..97a819234 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -29,7 +29,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .array() .min(1, { message: "At least one approver should be provided" }), approvals: z.number().min(1).default(1), - enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), + allowedSelfApprovals: z.boolean().default(true) }), response: { 200: z.object({ @@ -147,7 +148,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .array() .min(1, { message: "At least one approver should be provided" }), approvals: z.number().min(1).optional(), - enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), + allowedSelfApprovals: z.boolean().default(true) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 4aa26eb36..6a6ec3c07 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -110,7 +110,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv secretPath: z.string().nullish(), envId: z.string(), enforcementLevel: z.string(), - deletedAt: z.date().nullish() + deletedAt: z.date().nullish(), + allowedSelfApprovals: z.boolean() }), reviewers: z .object({ diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 2057677cf..5f80ad02b 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -61,8 +61,8 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { if (ldapConfig.groupSearchBase) { const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; const groupSearchFilter = (ldapConfig.groupSearchFilter || groupFilter) - .replace(/{{\.Username}}/g, user.uid) - .replace(/{{\.UserDN}}/g, user.dn); + .replaceAll("{{.Username}}", user.uid) + .replaceAll("{{.UserDN}}", user.dn); if (!isValidLdapFilter(groupSearchFilter)) { throw new Error("Generated LDAP search filter is invalid."); diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index 40f0a71bd..846b60923 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -35,7 +35,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .array() .min(1, { message: "At least one approver should be provided" }), approvals: z.number().min(1).default(1), - enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), + allowedSelfApprovals: z.boolean().default(true) }), response: { 200: z.object({ @@ -85,7 +86,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .nullable() .transform((val) => (val ? removeTrailingSlash(val) : val)) .transform((val) => (val === "" ? "/" : val)), - enforcementLevel: z.nativeEnum(EnforcementLevel).optional() + enforcementLevel: z.nativeEnum(EnforcementLevel).optional(), + allowedSelfApprovals: z.boolean().default(true) }), response: { 200: z.object({ 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 1ceb6019b..98cb9244d 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -49,7 +49,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv .array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), - deletedAt: z.date().nullish() + deletedAt: z.date().nullish(), + allowedSelfApprovals: z.boolean() }), committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), @@ -267,7 +268,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv approvers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), - deletedAt: z.date().nullish() + deletedAt: z.date().nullish(), + allowedSelfApprovals: z.boolean() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts index a3ce95cb4..249a96b50 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts @@ -5,9 +5,11 @@ import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-type import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerSshCertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -73,6 +75,16 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { } }); + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SignSshKey, + distinctId: getTelemetryDistinctId(req), + properties: { + certificateTemplateId: req.body.certificateTemplateId, + principals: req.body.principals, + ...req.auditLogInfo + } + }); + return { serialNumber, signedKey: signedPublicKey @@ -152,6 +164,16 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { } }); + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueSshCreds, + distinctId: getTelemetryDistinctId(req), + properties: { + certificateTemplateId: req.body.certificateTemplateId, + principals: req.body.principals, + ...req.auditLogInfo + } + }); + return { serialNumber, signedKey: signedPublicKey, diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 2313cdec8..6b5014acc 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -65,7 +65,8 @@ export const accessApprovalPolicyServiceFactory = ({ approvers, projectSlug, environment, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }: TCreateAccessApprovalPolicy) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -153,7 +154,8 @@ export const accessApprovalPolicyServiceFactory = ({ approvals, secretPath, name, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }, tx ); @@ -216,7 +218,8 @@ export const accessApprovalPolicyServiceFactory = ({ actorOrgId, actorAuthMethod, approvals, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }: TUpdateAccessApprovalPolicy) => { const groupApprovers = approvers .filter((approver) => approver.type === ApproverType.Group) @@ -262,7 +265,8 @@ export const accessApprovalPolicyServiceFactory = ({ approvals, secretPath, name, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }, tx ); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index a42c89e7a..dde8ffbea 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -26,6 +26,7 @@ export type TCreateAccessApprovalPolicy = { projectSlug: string; name: string; enforcementLevel: EnforcementLevel; + allowedSelfApprovals: boolean; } & Omit; export type TUpdateAccessApprovalPolicy = { @@ -35,6 +36,7 @@ export type TUpdateAccessApprovalPolicy = { secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; + allowedSelfApprovals: boolean; } & Omit; export type TDeleteAccessApprovalPolicy = { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index c1ccedff7..e2075af0a 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -61,6 +61,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), + db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ) @@ -119,6 +120,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: doc.policyApprovals, secretPath: doc.policySecretPath, enforcementLevel: doc.policyEnforcementLevel, + allowedSelfApprovals: doc.policyAllowedSelfApprovals, envId: doc.policyEnvId, deletedAt: doc.policyDeletedAt }, @@ -254,6 +256,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), + tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ); @@ -275,6 +278,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: el.policyApprovals, secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, + allowedSelfApprovals: el.policyAllowedSelfApprovals, deletedAt: el.policyDeletedAt }, requestedByUser: { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 29a151515..50670cb49 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -320,6 +320,11 @@ export const accessApprovalRequestServiceFactory = ({ message: "The policy associated with this access request has been deleted." }); } + if (!policy.allowedSelfApprovals && actorId === accessApprovalRequest.requestedByUserId) { + throw new BadRequestError({ + message: "Failed to review access approval request. Users are not authorized to review their own request." + }); + } const { membership, hasRole } = await permissionService.getProjectPermission({ actor, diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts index ab5e0ee5a..c5a562a18 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -45,7 +45,6 @@ export const auditLogStreamServiceFactory = ({ }: TCreateAuditLogStreamDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - const appCfg = getConfig(); const plan = await licenseService.getPlan(actorOrgId); if (!plan.auditLogStreams) { throw new BadRequestError({ @@ -62,9 +61,8 @@ export const auditLogStreamServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); - if (appCfg.isCloud) { - blockLocalAndPrivateIpAddresses(url); - } + const appCfg = getConfig(); + if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); if (totalStreams.length >= plan.auditLogStreamLimit) { @@ -135,9 +133,8 @@ export const auditLogStreamServiceFactory = ({ const { orgId } = logStream; const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (url && appCfg.isCloud) blockLocalAndPrivateIpAddresses(url); + if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); // testing connection first const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 3b860864f..295923ef5 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -1,8 +1,10 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { ActionProjectType } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; @@ -81,8 +83,12 @@ export const auditLogServiceFactory = ({ if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must specify either project id or org id" }); } - - return auditLogQueue.pushToLog(data); + const el = { ...data }; + if (el.actor.type === ActorType.USER || el.actor.type === ActorType.IDENTITY) { + const permissionMetadata = requestContext.get("identityPermissionMetadata"); + el.actor.metadata.permission = permissionMetadata; + } + return auditLogQueue.pushToLog(el); }; return { diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 9244e569e..1214cc5a0 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -290,6 +290,7 @@ interface UserActorMetadata { userId: string; email?: string | null; username: string; + permission?: Record; } interface ServiceActorMetadata { @@ -300,6 +301,7 @@ interface ServiceActorMetadata { interface IdentityActorMetadata { identityId: string; name: string; + permission?: Record; } interface ScimClientActorMetadata {} @@ -966,6 +968,7 @@ interface LoginIdentityOidcAuthEvent { identityId: string; identityOidcAuthId: string; identityAccessTokenId: string; + oidcClaimsReceived: Record; }; } diff --git a/backend/src/ee/services/certificate-est/certificate-est-service.ts b/backend/src/ee/services/certificate-est/certificate-est-service.ts index 5790c8d5a..627cc58c6 100644 --- a/backend/src/ee/services/certificate-est/certificate-est-service.ts +++ b/backend/src/ee/services/certificate-est/certificate-est-service.ts @@ -1,5 +1,6 @@ import * as x509 from "@peculiar/x509"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { isCertChainValid } from "@app/services/certificate/certificate-fns"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; @@ -67,9 +68,7 @@ export const certificateEstServiceFactory = ({ const certTemplate = await certificateTemplateDAL.findById(certificateTemplateId); - const leafCertificate = decodeURIComponent(sslClientCert).match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g - )?.[0]; + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; if (!leafCertificate) { throw new UnauthorizedError({ message: "Missing client certificate" }); @@ -88,10 +87,7 @@ export const certificateEstServiceFactory = ({ const verifiedChains = await Promise.all( caCertChains.map((chain) => { const caCert = new x509.X509Certificate(chain.certificate); - const caChain = - chain.certificateChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((c) => new x509.X509Certificate(c)) || []; + const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || []; return isCertChainValid([cert, caCert, ...caChain]); }) @@ -172,19 +168,15 @@ export const certificateEstServiceFactory = ({ } if (!estConfig.disableBootstrapCertValidation) { - const caCerts = estConfig.caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => { - return new x509.X509Certificate(cert); - }); + const caCerts = extractX509CertFromChain(estConfig.caChain)?.map((cert) => { + return new x509.X509Certificate(cert); + }); if (!caCerts) { throw new BadRequestError({ message: "Failed to parse certificate chain" }); } - const leafCertificate = decodeURIComponent(sslClientCert).match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g - )?.[0]; + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; if (!leafCertificate) { throw new BadRequestError({ message: "Missing client certificate" }); @@ -250,13 +242,7 @@ export const certificateEstServiceFactory = ({ kmsService }); - const certificates = caCertChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); - - if (!certificates) { - throw new BadRequestError({ message: "Failed to parse certificate chain" }); - } + const certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); const caCertificate = new x509.X509Certificate(caCert); return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 43f6197bf..8feee1830 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -183,7 +183,7 @@ export const dynamicSecretLeaseServiceFactory = ({ }); const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); - if (!dynamicSecretLease) { + if (!dynamicSecretLease || dynamicSecretLease.dynamicSecret.folderId !== folder.id) { throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); } @@ -256,7 +256,7 @@ export const dynamicSecretLeaseServiceFactory = ({ }); const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); - if (!dynamicSecretLease) + if (!dynamicSecretLease || dynamicSecretLease.dynamicSecret.folderId !== folder.id) throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` }); const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 3bd409faf..02b83c0f9 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -1,31 +1,51 @@ -import crypto from "node:crypto"; +import dns from "node:dns/promises"; +import net from "node:net"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { isPrivateIp } from "@app/lib/ip/ipRange"; import { getDbConnectionHost } from "@app/lib/knex"; -export const verifyHostInputValidity = (host: string, isGateway = false) => { +export const verifyHostInputValidity = async (host: string, isGateway = false) => { const appCfg = getConfig(); - const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); - // no need for validation when it's dev - if (appCfg.NODE_ENV === "development") return; + // if (appCfg.NODE_ENV === "development") return ["host.docker.internal"]; // incase you want to remove this check in dev - if (host === "host.docker.internal") throw new BadRequestError({ message: "Invalid db host" }); + const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat( + (appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)), + getDbConnectionHost(appCfg.REDIS_URL) + ); - if ( - appCfg.isCloud && - !isGateway && - // localhost - // internal ips - (host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) - ) - throw new BadRequestError({ message: "Invalid db host" }); - - if ( - host === "localhost" || - host === "127.0.0.1" || - (dbHost?.length === host.length && crypto.timingSafeEqual(Buffer.from(dbHost || ""), Buffer.from(host))) - ) { - throw new BadRequestError({ message: "Invalid db host" }); + // get host db ip + const exclusiveIps: string[] = []; + for await (const el of reservedHosts) { + if (el) { + if (net.isIPv4(el)) { + exclusiveIps.push(el); + } else { + const resolvedIps = await dns.resolve4(el); + exclusiveIps.push(...resolvedIps); + } + } } + + const normalizedHost = host.split(":")[0]; + const inputHostIps: string[] = []; + if (net.isIPv4(host)) { + inputHostIps.push(host); + } else { + if (normalizedHost === "localhost" || normalizedHost === "host.docker.internal") { + throw new BadRequestError({ message: "Invalid db host" }); + } + const resolvedIps = await dns.resolve4(host); + inputHostIps.push(...resolvedIps); + } + + if (!isGateway) { + const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); + if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" }); + } + + const isAppUsedIps = inputHostIps.some((el) => exclusiveIps.includes(el)); + if (isAppUsedIps) throw new BadRequestError({ message: "Invalid db host" }); + return inputHostIps; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index 534a5c8b2..f2907f7dc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -13,6 +13,7 @@ import { customAlphabet } from "nanoid"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; @@ -144,6 +145,14 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { // We can't return the parsed statements here because we need to use the handlebars template to generate the username and password, before we can use the parsed statements. CreateElastiCacheUserSchema.parse(JSON.parse(providerInputs.creationStatement)); DeleteElasticCacheUserSchema.parse(JSON.parse(providerInputs.revocationStatement)); + validateHandlebarTemplate("AWS ElastiCache creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) + }); + if (providerInputs.revocationStatement) { + validateHandlebarTemplate("AWS ElastiCache revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + } return providerInputs; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index b2f1f8c35..0b6d50146 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -3,9 +3,10 @@ import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; -import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { @@ -20,14 +21,28 @@ const generateUsername = () => { export const CassandraProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); - if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") { - throw new BadRequestError({ message: "Invalid db host" }); + const hostIps = await Promise.all( + providerInputs.host + .split(",") + .filter(Boolean) + .map((el) => verifyHostInputValidity(el).then((ip) => ip[0])) + ); + validateHandlebarTemplate("Cassandra creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "keyspace"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("Cassandra renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration", "keyspace"].includes(val) + }); } + validateHandlebarTemplate("Cassandra revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); - return providerInputs; + return { ...providerInputs, hostIps }; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer & { hostIps: string[] }) => { const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const client = new cassandra.Client({ sslOptions, @@ -40,7 +55,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { }, keyspace: providerInputs.keyspace, localDataCenter: providerInputs?.localDataCenter, - contactPoints: providerInputs.host.split(",").filter(Boolean) + contactPoints: providerInputs.hostIps }); return client; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index e91363629..6c1affa39 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -19,15 +19,14 @@ const generateUsername = () => { export const ElasticSearchProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); - - return providerInputs; + const [hostIp] = await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { const connection = new ElasticSearchClient({ node: { - url: new URL(`${providerInputs.host}:${providerInputs.port}`), + url: new URL(`${providerInputs.hostIp}:${providerInputs.port}`), ...(providerInputs.ca && { ssl: { rejectUnauthorized: false, diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index 84dec4d68..bee29bfc4 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -19,15 +19,15 @@ const generateUsername = () => { export const MongoDBProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); - return providerInputs; + const [hostIp] = await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { const isSrv = !providerInputs.port; const uri = isSrv - ? `mongodb+srv://${providerInputs.host}` - : `mongodb://${providerInputs.host}:${providerInputs.port}`; + ? `mongodb+srv://${providerInputs.hostIp}` + : `mongodb://${providerInputs.hostIp}:${providerInputs.port}`; const client = new MongoClient(uri, { auth: { diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 32264ecfa..f6c73ba54 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -3,7 +3,6 @@ import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; -import { removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -79,14 +78,13 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa export const RabbitMqProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); - - return providerInputs; + const [hostIp] = await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { const axiosInstance = axios.create({ - baseURL: `${removeTrailingSlash(providerInputs.host)}:${providerInputs.port}/api`, + baseURL: `${providerInputs.hostIp}:${providerInputs.port}/api`, auth: { username: providerInputs.username, password: providerInputs.password diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 306b8c59c..f180dd607 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; @@ -51,16 +52,28 @@ const executeTransactions = async (connection: Redis, commands: string[]): Promi export const RedisDatabaseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); - return providerInputs; + const [hostIp] = await verifyHostInputValidity(providerInputs.host); + validateHandlebarTemplate("Redis creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("Redis renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration"].includes(val) + }); + } + validateHandlebarTemplate("Redis revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + + return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { let connection: Redis | null = null; try { connection = new Redis({ username: providerInputs.username, - host: providerInputs.host, + host: providerInputs.hostIp, port: providerInputs.port, password: providerInputs.password, ...(providerInputs.ca && { diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index f349d36bd..c832e9867 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models"; @@ -27,14 +28,25 @@ export const SapAseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); - return providerInputs; + const [hostIp] = await verifyHostInputValidity(providerInputs.host); + validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password"].includes(val) + }); + if (providerInputs.revocationStatement) { + validateHandlebarTemplate("SAP ASE revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + } + return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer, useMaster?: boolean) => { + const $getClient = async ( + providerInputs: z.infer & { hostIp: string }, + useMaster?: boolean + ) => { const connectionString = `DRIVER={FreeTDS};` + - `SERVER=${providerInputs.host};` + + `SERVER=${providerInputs.hostIp};` + `PORT=${providerInputs.port};` + `DATABASE=${useMaster ? "master" : providerInputs.database};` + `UID=${providerInputs.username};` + @@ -83,7 +95,7 @@ export const SapAseProvider = (): TDynamicProviderFns => { password }); - const queries = creationStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); for await (const query of queries) { // If it's an adduser query, we need to first call sp_addlogin on the MASTER database. @@ -104,7 +116,7 @@ export const SapAseProvider = (): TDynamicProviderFns => { username }); - const queries = revokeStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); const client = await $getClient(providerInputs); const masterClient = await $getClient(providerInputs, true); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index ecd7ba3d3..1ad24473c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSapHanaSchema, TDynamicProviderFns } from "./models"; @@ -28,13 +29,24 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host); - return providerInputs; + const [hostIp] = await verifyHostInputValidity(providerInputs.host); + validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("SAP Hana renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration"].includes(val) + }); + } + validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { const client = hdb.createClient({ - host: providerInputs.host, + host: providerInputs.hostIp, port: providerInputs.port, user: providerInputs.username, password: providerInputs.password, diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index 3550b146d..bea7eca89 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models"; @@ -31,6 +32,18 @@ const getDaysToExpiry = (expiryDate: Date) => { export const SnowflakeProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSnowflakeSchema.parseAsync(inputs); + validateHandlebarTemplate("Snowflake creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("Snowflake renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration"].includes(val) + }); + } + validateHandlebarTemplate("Snowflake revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + return providerInputs; }; 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 eea9fef94..178ca4ef9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; @@ -117,8 +118,21 @@ type TSqlDatabaseProviderDTO = { export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); - verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.projectGatewayId)); - return providerInputs; + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.projectGatewayId)); + validateHandlebarTemplate("SQL creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("SQL renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration", "database"].includes(val) + }); + } + validateHandlebarTemplate("SQL revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username", "database"].includes(val) + }); + + return { ...providerInputs, hostIp }; }; const $getClient = async (providerInputs: z.infer) => { @@ -144,7 +158,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) } : undefined }, - acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { min: 0, max: 7 } }); return db; }; @@ -178,7 +193,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); let isConnected = false; - const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); // oracle needs from keyword const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 27e847896..b9206771e 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -3,8 +3,7 @@ import slugify from "@sindresorhus/slugify"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -14,7 +13,8 @@ import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TGroupDALFactory } from "./group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns"; @@ -67,14 +67,14 @@ export const groupServiceFactory = ({ const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); if (!plan.groups) @@ -87,14 +87,26 @@ export const groupServiceFactory = ({ actorOrgId ); const isCustomRole = Boolean(customRole); + if (role !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups, + permission, + rolePermission + ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); - if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to create a more privileged group", - details: { missingPermissions: permissionBoundary.missingPermissions } - }); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create group", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const group = await groupDAL.transaction(async (tx) => { const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); @@ -133,14 +145,15 @@ export const groupServiceFactory = ({ }: TUpdateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); if (!plan.groups) @@ -161,11 +174,21 @@ export const groupServiceFactory = ({ ); const isCustomRole = Boolean(customOrgRole); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update a more privileged group", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update group", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); if (isCustomRole) customRole = customOrgRole; @@ -215,7 +238,7 @@ export const groupServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); @@ -242,7 +265,7 @@ export const groupServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findById(id); if (!group) { @@ -275,7 +298,7 @@ export const groupServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findOne({ orgId: actorOrgId, @@ -303,14 +326,14 @@ export const groupServiceFactory = ({ const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists const group = await groupDAL.findOne({ @@ -338,11 +361,22 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.AddMembers, + OrgPermissionSubjects.Groups, + permission, + groupRolePermission + ); + if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to add user to more privileged group", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to add user to more privileged group", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.AddMembers, + OrgPermissionSubjects.Groups + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -374,14 +408,14 @@ export const groupServiceFactory = ({ }: TRemoveUserFromGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists const group = await groupDAL.findOne({ @@ -409,11 +443,21 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.RemoveMembers, + OrgPermissionSubjects.Groups, + permission, + groupRolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to delete user from more privileged group", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to delete user from more privileged group", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.RemoveMembers, + OrgPermissionSubjects.Groups + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts index 0e8a7fbf6..bf75ce5cd 100644 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts @@ -2,16 +2,17 @@ import { ForbiddenError, subject } from "@casl/ability"; import { packRules } from "@casl/ability/extra"; import { ActionProjectType, TableName } from "@app/db/schemas"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; import { ActorType } from "@app/services/auth/auth-type"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "../permission/project-permission"; import { TIdentityProjectAdditionalPrivilegeV2DALFactory } from "./identity-project-additional-privilege-v2-dal"; import { IdentityProjectAdditionalPrivilegeTemporaryMode, @@ -64,10 +65,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); - const { permission: targetIdentityPermission } = await permissionService.getProjectPermission({ + const { permission: targetIdentityPermission, membership } = await permissionService.getProjectPermission({ actor: ActorType.IDENTITY, actorId: identityId, projectId: identityProjectMembership.projectId, @@ -79,13 +80,26 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + targetIdentityPermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged identity", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); + validateHandlebarTemplate("Identity Additional Privilege Create", JSON.stringify(customPermission || []), { + allowedExpressions: (val) => val.includes("identity.") + }); const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -150,10 +164,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) ); - const { permission: targetIdentityPermission } = await permissionService.getProjectPermission({ + const { permission: targetIdentityPermission, membership } = await permissionService.getProjectPermission({ actor: ActorType.IDENTITY, actorId: identityProjectMembership.identityId, projectId: identityProjectMembership.projectId, @@ -165,14 +179,28 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + targetIdentityPermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged identity", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); + validateHandlebarTemplate("Identity Additional Privilege Update", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + if (data?.slug) { const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug: data.slug, @@ -227,7 +255,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ message: `Failed to find identity with membership ${identityPrivilege.projectMembershipId}` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId: identityProjectMembership.projectId, @@ -236,7 +264,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) ); const { permission: identityRolePermission } = await permissionService.getProjectPermission({ @@ -247,11 +275,21 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + identityRolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged identity", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -287,7 +325,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionIdentityActions.Read, subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) ); @@ -322,7 +360,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionIdentityActions.Read, subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) ); @@ -358,7 +396,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionIdentityActions.Read, subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) ); diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 4b259ef55..cbfcc4670 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -2,16 +2,21 @@ import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability" import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { ActionProjectType } from "@app/db/schemas"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { ActorType } from "@app/services/auth/auth-type"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; +import { + ProjectPermissionIdentityActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "../permission/project-permission"; import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; import { IdentityProjectAdditionalPrivilegeTemporaryMode, @@ -63,7 +68,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ if (!identityProjectMembership) throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId: identityProjectMembership.projectId, @@ -71,8 +76,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); + ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); @@ -88,11 +94,21 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + targetIdentityPermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged identity", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -102,6 +118,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + validateHandlebarTemplate("Identity Additional Privilege Create", JSON.stringify(customPermission || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + const packedPermission = JSON.stringify(packRules(customPermission)); if (!dto.isTemporary) { const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ @@ -150,7 +170,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ if (!identityProjectMembership) throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId: identityProjectMembership.projectId, @@ -160,7 +180,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); @@ -176,11 +196,21 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + targetIdentityPermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged identity", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -203,6 +233,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ } const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; + validateHandlebarTemplate("Identity Additional Privilege Update", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); const packedPermission = data.permissions ? JSON.stringify(packRules(data.permissions)) : undefined; if (isTemporary) { @@ -255,7 +288,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ if (!identityProjectMembership) throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId: identityProjectMembership.projectId, @@ -264,7 +297,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); @@ -276,11 +309,21 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + identityRolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to edit more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to edit more privileged identity", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -327,7 +370,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionIdentityActions.Read, subject(ProjectPermissionSub.Identity, { identityId }) ); @@ -371,7 +414,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionIdentityActions.Read, subject(ProjectPermissionSub.Identity, { identityId }) ); diff --git a/backend/src/ee/services/kmip/kmip-service.ts b/backend/src/ee/services/kmip/kmip-service.ts index dc0a8e85d..82ff1a9aa 100644 --- a/backend/src/ee/services/kmip/kmip-service.ts +++ b/backend/src/ee/services/kmip/kmip-service.ts @@ -4,8 +4,9 @@ import crypto, { KeyObject } from "crypto"; import { ActionProjectType } from "@app/db/schemas"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; -import { isValidHostname, isValidIp } from "@app/lib/ip"; +import { isValidIp } from "@app/lib/ip"; import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -665,7 +666,7 @@ export const kmipServiceFactory = ({ .split(",") .map((name) => name.trim()) .map((altName) => { - if (isValidHostname(altName)) { + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index 99b0d8d9b..44af718ed 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -97,12 +97,14 @@ export const searchGroups = async ( res.on("searchEntry", (entry) => { const dn = entry.dn.toString(); - const regex = /cn=([^,]+)/; - const match = dn.match(regex); - // parse the cn from the dn - const cn = (match && match[1]) as string; + const cnStartIndex = dn.indexOf("cn="); - groups.push({ dn, cn }); + if (cnStartIndex !== -1) { + const valueStartIndex = cnStartIndex + 3; + const commaIndex = dn.indexOf(",", valueStartIndex); + const cn = dn.substring(valueStartIndex, commaIndex === -1 ? undefined : commaIndex); + groups.push({ dn, cn }); + } }); res.on("error", (error) => { ldapClient.unbind(); diff --git a/backend/src/ee/services/license/licence-enums.ts b/backend/src/ee/services/license/licence-enums.ts new file mode 100644 index 000000000..047eb0a38 --- /dev/null +++ b/backend/src/ee/services/license/licence-enums.ts @@ -0,0 +1,24 @@ +export const BillingPlanRows = { + MemberLimit: { name: "Organization member limit", field: "memberLimit" }, + IdentityLimit: { name: "Organization identity limit", field: "identityLimit" }, + WorkspaceLimit: { name: "Project limit", field: "workspaceLimit" }, + EnvironmentLimit: { name: "Environment limit", field: "environmentLimit" }, + SecretVersioning: { name: "Secret versioning", field: "secretVersioning" }, + PitRecovery: { name: "Point in time recovery", field: "pitRecovery" }, + Rbac: { name: "RBAC", field: "rbac" }, + CustomRateLimits: { name: "Custom rate limits", field: "customRateLimits" }, + CustomAlerts: { name: "Custom alerts", field: "customAlerts" }, + AuditLogs: { name: "Audit logs", field: "auditLogs" }, + SamlSSO: { name: "SAML SSO", field: "samlSSO" }, + Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" }, + OidcSSO: { name: "OIDC SSO", field: "oidcSSO" }, + SecretApproval: { name: "Secret approvals", field: "secretApproval" }, + SecretRotation: { name: "Secret rotation", field: "secretRotation" }, + InstanceUserManagement: { name: "Instance User Management", field: "instanceUserManagement" }, + ExternalKms: { name: "External KMS", field: "externalKms" } +} as const; + +export const BillingPlanTableHead = { + Allowed: { name: "Allowed" }, + Used: { name: "Used" } +} as const; diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 7835ccfae..29c36c7fe 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -12,10 +12,13 @@ import { getConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; +import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums"; import { TLicenseDALFactory } from "./license-dal"; import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns"; import { @@ -28,6 +31,7 @@ import { TFeatureSet, TGetOrgBillInfoDTO, TGetOrgTaxIdDTO, + TOfflineLicense, TOfflineLicenseContents, TOrgInvoiceDTO, TOrgLicensesDTO, @@ -39,10 +43,12 @@ import { } from "./license-types"; type TLicenseServiceFactoryDep = { - orgDAL: Pick; + orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; keyStore: Pick; + identityOrgMembershipDAL: TIdentityOrgDALFactory; + projectDAL: TProjectDALFactory; }; export type TLicenseServiceFactory = ReturnType; @@ -57,11 +63,14 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL, - keyStore + keyStore, + identityOrgMembershipDAL, + projectDAL }: TLicenseServiceFactoryDep) => { let isValidLicense = false; let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); + let selfHostedLicense: TOfflineLicense | null = null; const appCfg = getConfig(); const licenseServerCloudApi = setupLicenseRequestWithStore( @@ -125,6 +134,7 @@ export const licenseServiceFactory = ({ instanceType = InstanceType.EnterpriseOnPremOffline; logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); isValidLicense = true; + selfHostedLicense = contents.license; return; } } @@ -348,10 +358,21 @@ export const licenseServiceFactory = ({ message: `Organization with ID '${orgId}' not found` }); } - const { data } = await licenseServerCloudApi.request.get( - `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` - ); - return data; + if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { + const { data } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` + ); + return data; + } + + return { + currentPeriodStart: selfHostedLicense?.issuedAt ? Date.parse(selfHostedLicense?.issuedAt) / 1000 : undefined, + currentPeriodEnd: selfHostedLicense?.expiresAt ? Date.parse(selfHostedLicense?.expiresAt) / 1000 : undefined, + interval: "month", + intervalCount: 1, + amount: 0, + quantity: 1 + }; }; // returns org current plan feature table @@ -365,10 +386,41 @@ export const licenseServiceFactory = ({ message: `Organization with ID '${orgId}' not found` }); } - const { data } = await licenseServerCloudApi.request.get( - `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` + if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) { + const { data } = await licenseServerCloudApi.request.get( + `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` + ); + return data; + } + + const mappedRows = await Promise.all( + Object.values(BillingPlanRows).map(async ({ name, field }: { name: string; field: string }) => { + const allowed = onPremFeatures[field as keyof TFeatureSet]; + let used = "-"; + + if (field === BillingPlanRows.MemberLimit.field) { + const orgMemberships = await orgDAL.countAllOrgMembers(orgId); + used = orgMemberships.toString(); + } else if (field === BillingPlanRows.WorkspaceLimit.field) { + const projects = await projectDAL.find({ orgId }); + used = projects.length.toString(); + } else if (field === BillingPlanRows.IdentityLimit.field) { + const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId }); + used = identities.toString(); + } + + return { + name, + allowed, + used + }; + }) ); - return data; + + return { + head: Object.values(BillingPlanTableHead), + rows: mappedRows + }; }; const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 8a2646227..17b4e7f6c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -44,6 +44,28 @@ export enum OrgPermissionGatewayActions { DeleteGateways = "delete-gateways" } +export enum OrgPermissionIdentityActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + GrantPrivileges = "grant-privileges", + RevokeAuth = "revoke-auth", + CreateToken = "create-token", + GetToken = "get-token", + DeleteToken = "delete-token" +} + +export enum OrgPermissionGroupActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + GrantPrivileges = "grant-privileges", + AddMembers = "add-members", + RemoveMembers = "remove-members" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -80,10 +102,10 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] - | [OrgPermissionActions, OrgPermissionSubjects.Groups] + | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] - | [OrgPermissionActions, OrgPermissionSubjects.Identity] + | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] @@ -256,20 +278,28 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); - can(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.GrantPrivileges, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.GetToken, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.DeleteToken, OrgPermissionSubjects.Identity); can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Create, OrgPermissionSubjects.Kms); @@ -316,7 +346,7 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); @@ -327,10 +357,10 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index ca46442e9..9b23d6113 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -49,6 +49,7 @@ export const permissionDALFactory = (db: TDbClient) => { .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) .select( selectAllTableCols(TableName.OrgMembership), + db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization), db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), @@ -70,7 +71,8 @@ export const permissionDALFactory = (db: TDbClient) => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), - customRoleSlug: z.string().optional().nullable() + customRoleSlug: z.string().optional().nullable(), + shouldUseNewPrivilegeSystem: z.boolean() }).parse(el), childrenMapper: [ { @@ -118,7 +120,9 @@ export const permissionDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.IdentityOrgMembership)) .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced")) .select("permissions") + .select(db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization)) .first(); + return membership; } catch (error) { throw new DatabaseError({ error, name: "GetOrgIdentityPermission" }); @@ -668,7 +672,8 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), db.ref("type").withSchema(TableName.Project).as("projectType"), - db.ref("id").withSchema(TableName.Project).as("projectId") + db.ref("id").withSchema(TableName.Project).as("projectId"), + db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization) ); const [userPermission] = sqlNestRelationships({ @@ -684,7 +689,8 @@ export const permissionDALFactory = (db: TDbClient) => { groupMembershipCreatedAt, groupMembershipUpdatedAt, membershipUpdatedAt, - projectType + projectType, + shouldUseNewPrivilegeSystem }) => ({ orgId, orgAuthEnforced, @@ -694,7 +700,8 @@ export const permissionDALFactory = (db: TDbClient) => { projectType, id: membershipId || groupMembershipId, createdAt: membershipCreatedAt || groupMembershipCreatedAt, - updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt + updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt, + shouldUseNewPrivilegeSystem }), childrenMapper: [ { @@ -995,6 +1002,7 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id` ) + .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder .on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) @@ -1012,6 +1020,7 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization), db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), db @@ -1045,7 +1054,8 @@ export const permissionDALFactory = (db: TDbClient) => { membershipUpdatedAt, orgId, identityName, - projectType + projectType, + shouldUseNewPrivilegeSystem }) => ({ id: membershipId, identityId, @@ -1055,6 +1065,7 @@ export const permissionDALFactory = (db: TDbClient) => { updatedAt: membershipUpdatedAt, orgId, projectType, + shouldUseNewPrivilegeSystem, // just a prefilled value orgAuthEnforced: false }), diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 5a6cf3077..88bace1f0 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -3,9 +3,11 @@ import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/abilit import { z } from "zod"; import { TOrganizations } from "@app/db/schemas"; +import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; +import { OrgPermissionSet } from "./org-permission"; import { ProjectPermissionSecretActions, ProjectPermissionSet, @@ -145,4 +147,57 @@ const escapeHandlebarsMissingDict = (obj: Record, key: string) = return new Proxy(obj, handler); }; -export { escapeHandlebarsMissingDict, isAuthMethodSaml, validateOrgSSO }; +// This function serves as a transition layer between the old and new privilege management system +// the old privilege management system is based on the actor having more privileges than the managed permission +// the new privilege management system is based on the actor having the appropriate permission to perform the privilege change, +// regardless of the actor's privilege level. +const validatePrivilegeChangeOperation = ( + shouldUseNewPrivilegeSystem: boolean, + opAction: OrgPermissionSet[0] | ProjectPermissionSet[0], + opSubject: OrgPermissionSet[1] | ProjectPermissionSet[1], + actorPermission: MongoAbility, + managedPermission: MongoAbility +) => { + if (shouldUseNewPrivilegeSystem) { + if (actorPermission.can(opAction, opSubject)) { + return { + isValid: true, + missingPermissions: [] + }; + } + + return { + isValid: false, + missingPermissions: [ + { + action: opAction, + subject: opSubject + } + ] + }; + } + + // if not, we check if the actor is indeed more privileged than the managed permission - this is the old system + return validatePermissionBoundary(actorPermission, managedPermission); +}; + +const constructPermissionErrorMessage = ( + baseMessage: string, + shouldUseNewPrivilegeSystem: boolean, + opAction: OrgPermissionSet[0] | ProjectPermissionSet[0], + opSubject: OrgPermissionSet[1] | ProjectPermissionSet[1] +) => { + return `${baseMessage}${ + shouldUseNewPrivilegeSystem + ? `. Actor is missing permission ${opAction as string} on ${opSubject as string}` + : ". Actor privilege level is not high enough to perform this action" + }`; +}; + +export { + constructPermissionErrorMessage, + escapeHandlebarsMissingDict, + isAuthMethodSaml, + validateOrgSSO, + validatePrivilegeChangeOperation +}; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index dc68db1d0..299f509d7 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -244,22 +244,20 @@ export const permissionServiceFactory = ({ const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const metadataKeyValuePair = escapeHandlebarsMissingDict( - objectify( - userProjectPermission.metadata, - (i) => i.key, - (i) => i.value - ), - "identity.metadata" + const unescapedMetadata = objectify( + userProjectPermission.metadata, + (i) => i.key, + (i) => i.value ); - const templateValue = { - id: userProjectPermission.userId, - username: userProjectPermission.username, - metadata: metadataKeyValuePair - }; + const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); + requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata }); const interpolateRules = templatedRules( { - identity: templateValue + identity: { + id: userProjectPermission.userId, + username: userProjectPermission.username, + metadata: metadataKeyValuePair + } }, { data: false } ); @@ -331,15 +329,16 @@ export const permissionServiceFactory = ({ ? escapeHandlebarsMissingDict(unescapedIdentityAuthInfo as never, "identity.auth") : {}; const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); - const templateValue = { - id: identityProjectPermission.identityId, - username: identityProjectPermission.username, - metadata: metadataKeyValuePair, - auth: identityAuthInfo - }; + + requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata, auth: unescapedIdentityAuthInfo }); const interpolateRules = templatedRules( { - identity: templateValue + identity: { + id: identityProjectPermission.identityId, + username: identityProjectPermission.username, + metadata: metadataKeyValuePair, + auth: identityAuthInfo + } }, { data: false } ); @@ -398,14 +397,18 @@ export const permissionServiceFactory = ({ const scopes = ServiceTokenScopes.parse(serviceToken.scopes || []); return { permission: buildServiceTokenProjectPermission(scopes, serviceToken.permissions), - membership: undefined + membership: { + shouldUseNewPrivilegeSystem: true + } }; }; type TProjectPermissionRT = T extends ActorType.SERVICE ? { permission: MongoAbility; - membership: undefined; + membership: { + shouldUseNewPrivilegeSystem: boolean; + }; hasRole: (arg: string) => boolean; } // service token doesn't have both membership and roles : { @@ -414,6 +417,7 @@ export const permissionServiceFactory = ({ orgAuthEnforced: boolean | null | undefined; orgId: string; roles: Array<{ role: string }>; + shouldUseNewPrivilegeSystem: boolean; }; hasRole: (role: string) => boolean; }; @@ -440,14 +444,13 @@ export const permissionServiceFactory = ({ ), "identity.metadata" ); - const templateValue = { - id: userProjectPermission.userId, - username: userProjectPermission.username, - metadata: metadataKeyValuePair - }; const interpolateRules = templatedRules( { - identity: templateValue + identity: { + id: userProjectPermission.userId, + username: userProjectPermission.username, + metadata: metadataKeyValuePair + } }, { data: false } ); @@ -487,14 +490,13 @@ export const permissionServiceFactory = ({ ), "identity.metadata" ); - const templateValue = { - id: identityProjectPermission.identityId, - username: identityProjectPermission.username, - metadata: metadataKeyValuePair - }; const interpolateRules = templatedRules( { - identity: templateValue + identity: { + id: identityProjectPermission.identityId, + username: identityProjectPermission.username, + metadata: metadataKeyValuePair + } }, { data: false } ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index c622e65a7..2637db994 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -43,6 +43,30 @@ export enum ProjectPermissionDynamicSecretActions { Lease = "lease" } +export enum ProjectPermissionIdentityActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + GrantPrivileges = "grant-privileges" +} + +export enum ProjectPermissionMemberActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + GrantPrivileges = "grant-privileges" +} + +export enum ProjectPermissionGroupActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + GrantPrivileges = "grant-privileges" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -150,8 +174,8 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] - | [ProjectPermissionActions, ProjectPermissionSub.Member] - | [ProjectPermissionActions, ProjectPermissionSub.Groups] + | [ProjectPermissionMemberActions, ProjectPermissionSub.Member] + | [ProjectPermissionGroupActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] @@ -162,7 +186,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ - ProjectPermissionActions, + ProjectPermissionIdentityActions, ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] @@ -290,13 +314,13 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.Member).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionMemberActions).describe( "Describe what action an entity can take." ) }), z.object({ subject: z.literal(ProjectPermissionSub.Groups).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionGroupActions).describe( "Describe what action an entity can take." ) }), @@ -510,7 +534,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ z.object({ subject: z.literal(ProjectPermissionSub.Identity).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(ProjectPermissionIdentityActions).describe( "Describe what action an entity can take." ), conditions: IdentityManagementConditionSchema.describe( @@ -531,12 +555,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretApproval, ProjectPermissionSub.SecretRotation, - ProjectPermissionSub.Member, - ProjectPermissionSub.Groups, ProjectPermissionSub.Role, ProjectPermissionSub.Integrations, ProjectPermissionSub.Webhooks, - ProjectPermissionSub.Identity, ProjectPermissionSub.ServiceTokens, ProjectPermissionSub.Settings, ProjectPermissionSub.Environments, @@ -563,6 +584,39 @@ const buildAdminPermissionRules = () => { ); }); + can( + [ + ProjectPermissionMemberActions.Create, + ProjectPermissionMemberActions.Edit, + ProjectPermissionMemberActions.Delete, + ProjectPermissionMemberActions.Read, + ProjectPermissionMemberActions.GrantPrivileges + ], + ProjectPermissionSub.Member + ); + + can( + [ + ProjectPermissionGroupActions.Create, + ProjectPermissionGroupActions.Edit, + ProjectPermissionGroupActions.Delete, + ProjectPermissionGroupActions.Read, + ProjectPermissionGroupActions.GrantPrivileges + ], + ProjectPermissionSub.Groups + ); + + can( + [ + ProjectPermissionIdentityActions.Create, + ProjectPermissionIdentityActions.Edit, + ProjectPermissionIdentityActions.Delete, + ProjectPermissionIdentityActions.Read, + ProjectPermissionIdentityActions.GrantPrivileges + ], + ProjectPermissionSub.Identity + ); + can( [ ProjectPermissionSecretActions.DescribeAndReadValue, @@ -677,9 +731,9 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); - can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.Member); + can([ProjectPermissionMemberActions.Read, ProjectPermissionMemberActions.Create], ProjectPermissionSub.Member); - can([ProjectPermissionActions.Read], ProjectPermissionSub.Groups); + can([ProjectPermissionGroupActions.Read], ProjectPermissionSub.Groups); can( [ @@ -703,10 +757,10 @@ const buildMemberPermissionRules = () => { can( [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete + ProjectPermissionIdentityActions.Read, + ProjectPermissionIdentityActions.Edit, + ProjectPermissionIdentityActions.Create, + ProjectPermissionIdentityActions.Delete ], ProjectPermissionSub.Identity ); @@ -820,12 +874,12 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + can(ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity); can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index 58a2f892b..965e25344 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -2,15 +2,20 @@ import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { ActionProjectType, TableName } from "@app/db/schemas"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { ActorType } from "@app/services/auth/auth-type"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; +import { + ProjectPermissionMemberActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode, @@ -63,8 +68,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); - const { permission: targetUserPermission } = await permissionService.getProjectPermission({ + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); + const { permission: targetUserPermission, membership } = await permissionService.getProjectPermission({ actor: ActorType.USER, actorId: projectMembership.userId, projectId: projectMembership.projectId, @@ -76,11 +81,21 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetUserPermission.update(targetUserPermission.rules.concat(customPermission)); - const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member, + permission, + targetUserPermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged user", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged user", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -92,6 +107,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ if (existingSlug) throw new BadRequestError({ message: `Additional privilege with provided slug ${slug} already exists` }); + validateHandlebarTemplate("User Additional Privilege Create", JSON.stringify(customPermission || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + const packedPermission = JSON.stringify(packRules(customPermission)); if (!dto.isTemporary) { const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ @@ -146,7 +165,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ message: `Project membership for user with ID '${userPrivilege.userId}' not found in project with ID '${userPrivilege.projectId}'` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId: projectMembership.projectId, @@ -154,7 +173,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); const { permission: targetUserPermission } = await permissionService.getProjectPermission({ actor: ActorType.USER, actorId: projectMembership.userId, @@ -167,11 +186,21 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ // we need to validate that the privilege given is not higher than the assigning users permission // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || [])); - const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member, + permission, + targetUserPermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged user", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -185,6 +214,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ throw new BadRequestError({ message: `Additional privilege with provided slug ${dto.slug} already exists` }); } + validateHandlebarTemplate("User Additional Privilege Update", JSON.stringify(dto.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary; const packedPermission = dto.permissions && JSON.stringify(packRules(dto.permissions)); @@ -244,7 +277,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); return { @@ -281,7 +314,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); return { ...userPrivilege, @@ -308,7 +341,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); const userPrivileges = await projectUserAdditionalPrivilegeDAL.find( { diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index 3ade1a117..d3af24f61 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -29,15 +29,9 @@ export const parseScimFilter = (filterToParse: string | undefined) => { attributeName = "name"; } - return { [attributeName]: parsedValue.replace(/"/g, "") }; + return { [attributeName]: parsedValue.replaceAll('"', "") }; }; -export function extractScimValueFromPath(path: string): string | null { - const regex = /members\[value eq "([^"]+)"\]/; - const match = path.match(regex); - return match ? match[1] : null; -} - export const buildScimUser = ({ orgMembershipId, username, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 189ba5c1f..4c212e6cd 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -62,7 +62,8 @@ export const secretApprovalPolicyServiceFactory = ({ projectId, secretPath, environment, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }: TCreateSapDTO) => { const groupApprovers = approvers ?.filter((approver) => approver.type === ApproverType.Group) @@ -113,7 +114,8 @@ export const secretApprovalPolicyServiceFactory = ({ approvals, secretPath, name, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }, tx ); @@ -172,7 +174,8 @@ export const secretApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, secretPolicyId, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }: TUpdateSapDTO) => { const groupApprovers = approvers ?.filter((approver) => approver.type === ApproverType.Group) @@ -218,7 +221,8 @@ export const secretApprovalPolicyServiceFactory = ({ approvals, secretPath, name, - enforcementLevel + enforcementLevel, + allowedSelfApprovals }, tx ); diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index 863f1c926..a6fea6956 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -10,6 +10,7 @@ export type TCreateSapDTO = { projectId: string; name: string; enforcementLevel: EnforcementLevel; + allowedSelfApprovals: boolean; } & Omit; export type TUpdateSapDTO = { @@ -19,6 +20,7 @@ export type TUpdateSapDTO = { approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; name?: string; enforcementLevel?: EnforcementLevel; + allowedSelfApprovals?: boolean; } & Omit; export type TDeleteSapDTO = { diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 5fc869d12..be4a7ab3b 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -112,6 +112,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), + tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") ); @@ -150,7 +151,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, envId: el.policyEnvId, - deletedAt: el.policyDeletedAt + deletedAt: el.policyDeletedAt, + allowedSelfApprovals: el.policyAllowedSelfApprovals } }), childrenMapper: [ @@ -336,6 +338,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { ), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), + db.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), @@ -364,7 +367,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + allowedSelfApprovals: el.policyAllowedSelfApprovals }, committerUser: { userId: el.committerUserId, @@ -482,6 +486,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank` ), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), + db.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), @@ -511,7 +516,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + allowedSelfApprovals: el.policyAllowedSelfApprovals }, committerUser: { userId: el.committerUserId, 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 98cb13865..879065ef5 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 @@ -352,6 +352,11 @@ export const secretApprovalRequestServiceFactory = ({ message: "The policy associated with this secret approval request has been deleted." }); } + if (!policy.allowedSelfApprovals && actorId === secretApprovalRequest.committerUserId) { + throw new BadRequestError({ + message: "Failed to review secret approval request. Users are not authorized to review their own request." + }); + } const { hasRole } = await permissionService.getProjectPermission({ actor: ActorType.USER, diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts index 46c519d58..e3c6b6b5c 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts @@ -8,23 +8,49 @@ import axios from "axios"; import jmespath from "jmespath"; import knex from "knex"; -import { getConfig } from "@app/lib/config/env"; -import { getDbConnectionHost } from "@app/lib/knex"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; import { TAssignOp, TDbProviderClients, TDirectAssignOp, THttpProviderFunction } from "../templates/types"; import { TSecretRotationData, TSecretRotationDbFn } from "./secret-rotation-queue-types"; -const REGEX = /\${([^}]+)}/g; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; +const replaceTemplateVariables = (str: string, getValue: (key: string) => unknown) => { + // Use array to collect pieces and join at the end (more efficient for large strings) + const parts: string[] = []; + let pos = 0; + + while (pos < str.length) { + const start = str.indexOf("${", pos); + if (start === -1) { + parts.push(str.slice(pos)); + break; + } + + parts.push(str.slice(pos, start)); + const end = str.indexOf("}", start + 2); + + if (end === -1) { + parts.push(str.slice(start)); + break; + } + + const varName = str.slice(start + 2, end); + parts.push(String(getValue(varName))); + pos = end + 1; + } + + return parts.join(""); +}; + export const interpolate = (data: any, getValue: (key: string) => unknown) => { if (!data) return; if (typeof data === "number") return data; if (typeof data === "string") { - return data.replace(REGEX, (_a, b) => getValue(b) as string); + return replaceTemplateVariables(data, getValue); } if (typeof data === "object" && Array.isArray(data)) { @@ -88,32 +114,14 @@ export const secretRotationDbFn = async ({ variables, options }: TSecretRotationDbFn) => { - const appCfg = getConfig(); - const ssl = ca ? { rejectUnauthorized: false, ca } : undefined; - const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not - const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); - - if ( - isCloud && - // internal ips - (host === "host.docker.internal" || host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) - ) - throw new Error("Invalid db host"); - if ( - host === "localhost" || - host === "127.0.0.1" || - // database infisical uses - dbHost === host - ) - throw new Error("Invalid db host"); - + const [hostIp] = await verifyHostInputValidity(host); const db = knex({ client, connection: { database, port, - host, + host: hostIp, user: username, password, connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, diff --git a/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts b/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts index 51cb9c056..cd5372c8d 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts @@ -8,7 +8,18 @@ type GetFullFolderPath = { export const getFullFolderPath = async ({ folderDAL, folderId, envId }: GetFullFolderPath): Promise => { // Helper function to remove duplicate slashes - const removeDuplicateSlashes = (path: string) => path.replace(/\/{2,}/g, "/"); + const removeDuplicateSlashes = (path: string) => { + const chars = []; + let lastWasSlash = false; + + for (let i = 0; i < path.length; i += 1) { + const char = path[i]; + if (char !== "/" || !lastWasSlash) chars.push(char); + lastWasSlash = char === "/"; + } + + return chars.join(""); + }; // Fetch all folders at once based on environment ID to avoid multiple queries const folders = await folderDAL.find({ envId }); diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts index 373bbc640..48d793970 100644 --- a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts @@ -1,14 +1,34 @@ +import { isIP } from "net"; + +import { isFQDN } from "@app/lib/validator/validate-url"; + // Validates usernames or wildcard (*) export const isValidUserPattern = (value: string): boolean => { - // Matches valid Linux usernames or a wildcard (*) - const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + // Length check before regex to prevent ReDoS + if (typeof value !== "string") return false; + if (value.length > 32) return false; // Maximum Linux username length + if (value === "*") return true; // Handle wildcard separately + + // Simpler, more specific pattern for usernames + const userRegex = /^[a-z_][a-z0-9_-]*$/i; return userRegex.test(value); }; // Validates hostnames, wildcard domains, or IP addresses export const isValidHostPattern = (value: string): boolean => { - // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses - const hostRegex = - /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; - return hostRegex.test(value); + // Input validation + if (typeof value !== "string") return false; + + // Length check + if (value.length > 255) return false; + + // Handle the wildcard case separately + if (value === "*") return true; + + // Check for IP addresses using Node.js built-in functions + if (isIP(value)) return true; + + return isFQDN(value, { + allow_wildcard: true + }); }; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index f51f8d639..deb77cecc 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -8,6 +8,7 @@ import { promisify } from "util"; import { TSshCertificateTemplates } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { @@ -18,6 +19,7 @@ import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-type const execFileAsync = promisify(execFile); +const EXEC_TIMEOUT_MS = 10000; // 10 seconds /* eslint-disable no-bitwise */ export const createSshCertSerialNumber = () => { const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits @@ -64,7 +66,9 @@ export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { // Generate the SSH key pair // The "-N ''" sets an empty passphrase // The keys are created in the temporary directory - await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""]); + await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""], { + timeout: EXEC_TIMEOUT_MS + }); // Read the generated keys const publicKey = await fs.readFile(publicKeyFile, "utf8"); @@ -87,7 +91,10 @@ export const getSshPublicKey = async (privateKey: string) => { await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); // Run ssh-keygen to extract the public key - const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" }); + const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { + encoding: "utf8", + timeout: EXEC_TIMEOUT_MS + }); return stdout.trim(); } finally { // Ensure that files and the temporary directory are cleaned up @@ -143,7 +150,14 @@ export const validateSshCertificatePrincipals = ( } // restrict allowed characters to letters, digits, dot, underscore, and hyphen - if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) { + if ( + !characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Period, + CharacterType.Underscore, + CharacterType.Hyphen + ])(sanitized) + ) { throw new BadRequestError({ message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.` }); @@ -266,8 +280,8 @@ export const validateSshCertificateTtl = (template: TSshCertificateTemplates, tt * that it only contains alphanumeric characters with no spaces. */ export const validateSshCertificateKeyId = (keyId: string) => { - const regex = /^[A-Za-z0-9-]+$/; - if (!regex.test(keyId)) { + const regex = characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen]); + if (!regex(keyId)) { throw new BadRequestError({ message: "Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces." @@ -298,7 +312,7 @@ const validateSshPublicKey = async (publicKey: string) => { try { await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 }); - await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]); + await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile], { timeout: EXEC_TIMEOUT_MS }); } catch (error) { throw new BadRequestError({ message: "Failed to validate SSH public key format: could not be parsed." @@ -363,7 +377,7 @@ export const createSshCert = async ({ await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 }); // Execute the signing process - await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" }); + await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }); // Read the signed public key from the generated cert file const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8338492a4..ec855168e 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -244,7 +244,7 @@ export const KUBERNETES_AUTH = { kubernetesHost: "The host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: - "The long-lived service account JWT token for Infisical to access the TokenReview API to validate other service account JWT tokens submitted by applications/pods.", + "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The comma-separated list of trusted service account names that can authenticate with Infisical.", @@ -260,7 +260,7 @@ export const KUBERNETES_AUTH = { kubernetesHost: "The new host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: - "The new long-lived service account JWT token for Infisical to access the TokenReview API to validate other service account JWT tokens submitted by applications/pods.", + "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The new comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The new comma-separated list of trusted service account names that can authenticate with Infisical.", @@ -631,7 +631,8 @@ export const FOLDERS = { workspaceId: "The ID of the project to list folders from.", environment: "The slug of the environment to list folders from.", path: "The path to list folders from.", - directory: "The directory to list folders from. (Deprecated in favor of path)" + directory: "The directory to list folders from. (Deprecated in favor of path)", + recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories." }, GET_BY_ID: { folderId: "The ID of the folder to get details." @@ -815,7 +816,8 @@ export const DASHBOARD = { search: "The text string to filter secret keys and folder names by.", includeSecrets: "Whether to include project secrets in the response.", includeFolders: "Whether to include project folders in the response.", - includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", + includeImports: "Whether to include project secret imports in the response." }, SECRET_DETAILS_LIST: { projectId: "The ID of the project to list secrets/folders from.", diff --git a/backend/src/lib/axios/digest-auth.ts b/backend/src/lib/axios/digest-auth.ts index ee9dbd79b..449c471fd 100644 --- a/backend/src/lib/axios/digest-auth.ts +++ b/backend/src/lib/axios/digest-auth.ts @@ -28,8 +28,8 @@ export const createDigestAuthRequestInterceptor = ( nc += 1; const nonceCount = nc.toString(16).padStart(8, "0"); const cnonce = crypto.randomBytes(24).toString("hex"); - const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1].replace(/"/g, ""); - const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1].replace(/"/g, ""); + const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1]?.replaceAll('"', "") || ""; + const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1]?.replaceAll('"', "") || ""; const ha1 = crypto.createHash("md5").update(`${username}:${realm}:${password}`).digest("hex"); const path = opts.url; diff --git a/backend/src/lib/base64/index.ts b/backend/src/lib/base64/index.ts index cfc0fde3f..2bffef3fc 100644 --- a/backend/src/lib/base64/index.ts +++ b/backend/src/lib/base64/index.ts @@ -1,26 +1,35 @@ -// Credit: https://github.com/miguelmota/is-base64 -export const isBase64 = ( - v: string, - opts = { allowEmpty: false, mimeRequired: false, allowMime: true, paddingRequired: false } -) => { - if (opts.allowEmpty === false && v === "") { - return false; +type Base64Options = { + urlSafe?: boolean; + padding?: boolean; +}; + +const base64WithPadding = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; +const base64WithoutPadding = /^[A-Za-z0-9+/]+$/; +const base64UrlWithPadding = /^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2}==|[A-Za-z0-9_-]{3}=|[A-Za-z0-9_-]{4})$/; +const base64UrlWithoutPadding = /^[A-Za-z0-9_-]+$/; + +export const isBase64 = (str: string, options: Base64Options = {}): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); } - let regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+/]{3}=)?"; - const mimeRegex = "(data:\\w+\\/[a-zA-Z\\+\\-\\.]+;base64,)"; + // Default padding to true unless urlSafe is true + const opts: Base64Options = { + urlSafe: false, + padding: options.urlSafe === undefined ? true : !options.urlSafe, + ...options + }; - if (opts.mimeRequired === true) { - regex = mimeRegex + regex; - } else if (opts.allowMime === true) { - regex = `${mimeRegex}?${regex}`; + if (str === "") return true; + + let regex; + if (opts.urlSafe) { + regex = opts.padding ? base64UrlWithPadding : base64UrlWithoutPadding; + } else { + regex = opts.padding ? base64WithPadding : base64WithoutPadding; } - if (opts.paddingRequired === false) { - regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}(==)?|[A-Za-z0-9+\\/]{3}=?)?"; - } - - return new RegExp(`^${regex}$`, "gi").test(v); + return (!opts.padding || str.length % 4 === 0) && regex.test(str); }; export const getBase64SizeInBytes = (base64String: string) => { diff --git a/backend/src/lib/certificates/extract-certificate.test.ts b/backend/src/lib/certificates/extract-certificate.test.ts new file mode 100644 index 000000000..0d0fc2be6 --- /dev/null +++ b/backend/src/lib/certificates/extract-certificate.test.ts @@ -0,0 +1,42 @@ +import { extractX509CertFromChain } from "./extract-certificate"; + +describe("Extract Certificate Payload", () => { + test("Single chain", () => { + const payload = `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz +-----END CERTIFICATE-----`; + const result = extractX509CertFromChain(payload); + expect(result).toBeDefined(); + expect(result?.length).toBe(1); + expect(result?.[0]).toEqual(payload); + }); + + test("Multiple chain", () => { + const payload = `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE-----`; + const result = extractX509CertFromChain(payload); + expect(result).toBeDefined(); + expect(result?.length).toBe(3); + expect(result).toEqual([ + `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz +-----END CERTIFICATE-----`, + `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE-----`, + `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE-----` + ]); + }); +}); diff --git a/backend/src/lib/certificates/extract-certificate.ts b/backend/src/lib/certificates/extract-certificate.ts new file mode 100644 index 000000000..782ceee19 --- /dev/null +++ b/backend/src/lib/certificates/extract-certificate.ts @@ -0,0 +1,51 @@ +import { BadRequestError } from "../errors"; + +export const extractX509CertFromChain = (certificateChain: string): string[] => { + if (!certificateChain) { + throw new BadRequestError({ + message: "Certificate chain is empty or undefined" + }); + } + + const certificates: string[] = []; + let currentPosition = 0; + const chainLength = certificateChain.length; + + while (currentPosition < chainLength) { + // Find the start of a certificate + const beginMarker = "-----BEGIN CERTIFICATE-----"; + const startIndex = certificateChain.indexOf(beginMarker, currentPosition); + + if (startIndex === -1) { + break; // No more certificates found + } + + // Find the end of the certificate + const endMarker = "-----END CERTIFICATE-----"; + const endIndex = certificateChain.indexOf(endMarker, startIndex); + + if (endIndex === -1) { + throw new BadRequestError({ + message: "Malformed certificate chain: Found BEGIN marker without matching END marker" + }); + } + + // Extract the complete certificate including markers + const completeEndIndex = endIndex + endMarker.length; + const certificate = certificateChain.substring(startIndex, completeEndIndex); + + // Add the extracted certificate to our results + certificates.push(certificate); + + // Move position to after this certificate + currentPosition = completeEndIndex; + } + + if (certificates.length === 0) { + throw new BadRequestError({ + message: "No valid certificates found in the chain" + }); + } + + return certificates; +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 0d13939a2..4ca9c1b12 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -56,6 +56,7 @@ const envSchema = z // TODO(akhilmhdh): will be changed to one ENCRYPTION_KEY: zpStr(z.string().optional()), ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), + QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), HTTPS_ENABLED: zodStrBool, // smtp options SMTP_HOST: zpStr(z.string().optional()), diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index 950bfd29f..a5df64caf 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -68,6 +68,23 @@ export class ForbiddenRequestError extends Error { } } +export class PermissionBoundaryError extends ForbiddenRequestError { + constructor({ + message, + name, + error, + details + }: { + message?: string; + name?: string; + error?: unknown; + details?: unknown; + }) { + super({ message, name, error, details }); + this.name = "PermissionBoundaryError"; + } +} + export class BadRequestError extends Error { name: string; diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 118283f64..84d801dda 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -93,6 +93,7 @@ export const pingGatewayAndVerify = async ({ let lastError: Error | null = null; const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { throw new BadRequestError({ + message: (err as Error)?.message, error: err as Error }); }); diff --git a/backend/src/lib/ip/index.ts b/backend/src/lib/ip/index.ts index 1e247dcde..0b35a2759 100644 --- a/backend/src/lib/ip/index.ts +++ b/backend/src/lib/ip/index.ts @@ -107,12 +107,6 @@ export const isValidIp = (ip: string) => { return net.isIPv4(ip) || net.isIPv6(ip); }; -export const isValidHostname = (name: string) => { - const hostnameRegex = /^(?!:\/\/)(\*\.)?([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/; - - return hostnameRegex.test(name); -}; - export type TIp = { ipAddress: string; type: IPType; diff --git a/backend/src/lib/ip/ipRange.ts b/backend/src/lib/ip/ipRange.ts new file mode 100644 index 000000000..c2a77d6f9 --- /dev/null +++ b/backend/src/lib/ip/ipRange.ts @@ -0,0 +1,61 @@ +import { BlockList } from "node:net"; + +import { BadRequestError } from "../errors"; +// Define BlockList instances for each range type +const ipv4RangeLists: Record = { + unspecified: new BlockList(), + broadcast: new BlockList(), + multicast: new BlockList(), + linkLocal: new BlockList(), + loopback: new BlockList(), + carrierGradeNat: new BlockList(), + private: new BlockList(), + reserved: new BlockList() +}; + +// Add IPv4 CIDR ranges to each BlockList +ipv4RangeLists.unspecified.addSubnet("0.0.0.0", 8); +ipv4RangeLists.broadcast.addAddress("255.255.255.255"); +ipv4RangeLists.multicast.addSubnet("224.0.0.0", 4); +ipv4RangeLists.linkLocal.addSubnet("169.254.0.0", 16); +ipv4RangeLists.loopback.addSubnet("127.0.0.0", 8); +ipv4RangeLists.carrierGradeNat.addSubnet("100.64.0.0", 10); + +// IPv4 Private ranges +ipv4RangeLists.private.addSubnet("10.0.0.0", 8); +ipv4RangeLists.private.addSubnet("172.16.0.0", 12); +ipv4RangeLists.private.addSubnet("192.168.0.0", 16); + +// IPv4 Reserved ranges +ipv4RangeLists.reserved.addSubnet("192.0.0.0", 24); +ipv4RangeLists.reserved.addSubnet("192.0.2.0", 24); +ipv4RangeLists.reserved.addSubnet("192.88.99.0", 24); +ipv4RangeLists.reserved.addSubnet("198.18.0.0", 15); +ipv4RangeLists.reserved.addSubnet("198.51.100.0", 24); +ipv4RangeLists.reserved.addSubnet("203.0.113.0", 24); +ipv4RangeLists.reserved.addSubnet("240.0.0.0", 4); + +/** + * Checks if an IP address (IPv4) is private or public + * inspired by: https://github.com/whitequark/ipaddr.js/blob/main/lib/ipaddr.js + */ +export const getIpRange = (ip: string): string => { + try { + const rangeLists = ipv4RangeLists; + // Check each range type + for (const rangeName in rangeLists) { + if (Object.hasOwn(rangeLists, rangeName)) { + if (rangeLists[rangeName].check(ip)) { + return rangeName; + } + } + } + + // If no range matched, it's a public address + return "unicast"; + } catch (error) { + throw new BadRequestError({ message: "Invalid IP address", error }); + } +}; + +export const isPrivateIp = (ip: string) => getIpRange(ip) !== "unicast"; diff --git a/backend/src/lib/template/validate-handlebars.ts b/backend/src/lib/template/validate-handlebars.ts new file mode 100644 index 000000000..a83c9efc2 --- /dev/null +++ b/backend/src/lib/template/validate-handlebars.ts @@ -0,0 +1,21 @@ +import handlebars from "handlebars"; + +import { BadRequestError } from "../errors"; +import { logger } from "../logger"; + +type SanitizationArg = { + allowedExpressions?: (arg: string) => boolean; +}; + +export const validateHandlebarTemplate = (templateName: string, template: string, dto: SanitizationArg) => { + const parsedAst = handlebars.parse(template); + parsedAst.body.forEach((el) => { + if (el.type === "ContentStatement") return; + if (el.type === "MustacheStatement" && "path" in el) { + const { path } = el as { type: "MustacheStatement"; path: { type: "PathExpression"; original: string } }; + if (path.type === "PathExpression" && dto?.allowedExpressions?.(path.original)) return; + } + logger.error(el, "Template sanitization failed"); + throw new BadRequestError({ message: `Template sanitization failed: ${templateName}` }); + }); +}; diff --git a/backend/src/lib/validator/validate-folder-name.ts b/backend/src/lib/validator/validate-folder-name.ts index 1fce780f0..e357dbf9b 100644 --- a/backend/src/lib/validator/validate-folder-name.ts +++ b/backend/src/lib/validator/validate-folder-name.ts @@ -1,5 +1,11 @@ +import { CharacterType, characterValidator } from "./validate-string"; + // regex to allow only alphanumeric, dash, underscore -export const isValidFolderName = (name: string) => /^[a-zA-Z0-9-_]+$/.test(name); +export const isValidFolderName = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Underscore +]); export const isValidSecretPath = (path: string) => path diff --git a/backend/src/lib/validator/validate-string.test.ts b/backend/src/lib/validator/validate-string.test.ts new file mode 100644 index 000000000..73e172896 --- /dev/null +++ b/backend/src/lib/validator/validate-string.test.ts @@ -0,0 +1,23 @@ +import { CharacterType, characterValidator } from "./validate-string"; + +describe("validate-string", () => { + test("Check alphabets", () => { + expect(characterValidator([CharacterType.Alphabets])("hello")).toBeTruthy(); + expect(characterValidator([CharacterType.Alphabets])("hello world")).toBeFalsy(); + expect(characterValidator([CharacterType.Alphabets, CharacterType.Spaces])("hello world")).toBeTruthy(); + }); + + test("Check numbers", () => { + expect(characterValidator([CharacterType.Numbers])("1234567890")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric])("helloWORLD1234567890")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric])("helloWORLD1234567890-")).toBeFalsy(); + }); + + test("Check special characters", () => { + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])("Hello-World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Plus])("Hello+World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Underscore])("Hello_World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Colon])("Hello:World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Underscore])("Hello World")).toBeFalsy(); + }); +}); diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts new file mode 100644 index 000000000..57bc052f8 --- /dev/null +++ b/backend/src/lib/validator/validate-string.ts @@ -0,0 +1,101 @@ +export enum CharacterType { + Alphabets = "alphabets", + Numbers = "numbers", + AlphaNumeric = "alpha-numeric", + Spaces = "spaces", + SpecialCharacters = "specialCharacters", + Punctuation = "punctuation", + Period = "period", // . + Underscore = "underscore", // _ + Colon = "colon", // : + ForwardSlash = "forwardSlash", // / + Equals = "equals", // = + Plus = "plus", // + + Hyphen = "hyphen", // - + At = "at", // @ + // Additional individual characters that might be useful + Asterisk = "asterisk", // * + Ampersand = "ampersand", // & + Question = "question", // ? + Hash = "hash", // # + Percent = "percent", // % + Dollar = "dollar", // $ + Caret = "caret", // ^ + Backtick = "backtick", // ` + Pipe = "pipe", // | + Backslash = "backslash", // \ + OpenParen = "openParen", // ( + CloseParen = "closeParen", // ) + OpenBracket = "openBracket", // [ + CloseBracket = "closeBracket", // ] + OpenBrace = "openBrace", // { + CloseBrace = "closeBrace", // } + LessThan = "lessThan", // < + GreaterThan = "greaterThan", // > + SingleQuote = "singleQuote", // ' + DoubleQuote = "doubleQuote", // " + Comma = "comma", // , + Semicolon = "semicolon", // ; + Exclamation = "exclamation" // ! +} + +/** + * Validates if a string contains only specific types of characters + */ +export const characterValidator = (allowedCharacters: CharacterType[]) => { + // Create a regex pattern based on allowed character types + const patternMap: Record = { + [CharacterType.Alphabets]: "a-zA-Z", + [CharacterType.Numbers]: "0-9", + [CharacterType.AlphaNumeric]: "a-zA-Z0-9", + [CharacterType.Spaces]: "\\s", + [CharacterType.SpecialCharacters]: "!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?\\\\", + [CharacterType.Punctuation]: "\\.\\,\\;\\:\\!\\?", + [CharacterType.Colon]: "\\:", + [CharacterType.ForwardSlash]: "\\/", + [CharacterType.Underscore]: "_", + [CharacterType.Hyphen]: "\\-", + [CharacterType.Period]: "\\.", + [CharacterType.Equals]: "=", + [CharacterType.Plus]: "\\+", + [CharacterType.At]: "@", + [CharacterType.Asterisk]: "\\*", + [CharacterType.Ampersand]: "&", + [CharacterType.Question]: "\\?", + [CharacterType.Hash]: "#", + [CharacterType.Percent]: "%", + [CharacterType.Dollar]: "\\$", + [CharacterType.Caret]: "\\^", + [CharacterType.Backtick]: "`", + [CharacterType.Pipe]: "\\|", + [CharacterType.Backslash]: "\\\\", + [CharacterType.OpenParen]: "\\(", + [CharacterType.CloseParen]: "\\)", + [CharacterType.OpenBracket]: "\\[", + [CharacterType.CloseBracket]: "\\]", + [CharacterType.OpenBrace]: "\\{", + [CharacterType.CloseBrace]: "\\}", + [CharacterType.LessThan]: "<", + [CharacterType.GreaterThan]: ">", + [CharacterType.SingleQuote]: "'", + [CharacterType.DoubleQuote]: '\\"', + [CharacterType.Comma]: ",", + [CharacterType.Semicolon]: ";", + [CharacterType.Exclamation]: "!" + }; + + // Combine patterns from allowed characters + const combinedPattern = allowedCharacters.map((char) => patternMap[char]).join(""); + + // Create a regex that matches only the allowed characters + const regex = new RegExp(`^[${combinedPattern}]+$`); + + /** + * Validates if the input string contains only the allowed character types + * @param input String to validate + * @returns Boolean indicating if the string is valid + */ + return function validate(input: string): boolean { + return regex.test(input); + }; +}; diff --git a/backend/src/lib/validator/validate-url.test.ts b/backend/src/lib/validator/validate-url.test.ts new file mode 100644 index 000000000..3ed5cb446 --- /dev/null +++ b/backend/src/lib/validator/validate-url.test.ts @@ -0,0 +1,15 @@ +import { isFQDN } from "./validate-url"; + +describe("isFQDN", () => { + test("Non wildcard", () => { + expect(isFQDN("www.example.com")).toBeTruthy(); + }); + + test("Wildcard", () => { + expect(isFQDN("*.example.com", { allow_wildcard: true })).toBeTruthy(); + }); + + test("Wildcard FQDN fails on option allow_wildcard false", () => { + expect(isFQDN("*.example.com")).toBeFalsy(); + }); +}); diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index fccebf47b..6feab9036 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -1,18 +1,117 @@ -import { getConfig } from "../config/env"; +import dns from "node:dns/promises"; + +import { isIPv4 } from "net"; + import { BadRequestError } from "../errors"; +import { isPrivateIp } from "../ip/ipRange"; -export const blockLocalAndPrivateIpAddresses = (url: string) => { +export const blockLocalAndPrivateIpAddresses = async (url: string) => { const validUrl = new URL(url); - const appCfg = getConfig(); - // on cloud local ips are not allowed - if ( - appCfg.isCloud && - (validUrl.host === "host.docker.internal" || - validUrl.host.match(/^10\.\d+\.\d+\.\d+/) || - validUrl.host.match(/^192\.168\.\d+\.\d+/)) - ) - throw new BadRequestError({ message: "Local IPs not allowed as URL" }); - - if (validUrl.host === "localhost" || validUrl.host === "127.0.0.1") - throw new BadRequestError({ message: "Localhost not allowed" }); + const inputHostIps: string[] = []; + if (isIPv4(validUrl.host)) { + inputHostIps.push(validUrl.host); + } else { + if (validUrl.host === "localhost" || validUrl.host === "host.docker.internal") { + throw new BadRequestError({ message: "Local IPs not allowed as URL" }); + } + const resolvedIps = await dns.resolve4(validUrl.host); + inputHostIps.push(...resolvedIps); + } + const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); + if (isInternalIp) throw new BadRequestError({ message: "Local IPs not allowed as URL" }); +}; + +type FQDNOptions = { + require_tld?: boolean; + allow_underscores?: boolean; + allow_trailing_dot?: boolean; + allow_numeric_tld?: boolean; + allow_wildcard?: boolean; + ignore_max_length?: boolean; +}; + +const defaultFqdnOptions: FQDNOptions = { + require_tld: true, + allow_underscores: false, + allow_trailing_dot: false, + allow_numeric_tld: false, + allow_wildcard: false, + ignore_max_length: false +}; + +// credits: https://github.com/validatorjs/validator.js/blob/f5da7fb6ed59b94695e6fcb2e970c80029509919/src/lib/isFQDN.js#L13 +export const isFQDN = (str: string, options: FQDNOptions = {}): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + // Apply default options + const opts: FQDNOptions = { + ...defaultFqdnOptions, + ...options + }; + + let testStr = str; + /* Remove the optional trailing dot before checking validity */ + if (opts.allow_trailing_dot && str[str.length - 1] === ".") { + testStr = testStr.substring(0, str.length - 1); + } + + /* Remove the optional wildcard before checking validity */ + if (opts.allow_wildcard === true && str.indexOf("*.") === 0) { + testStr = testStr.substring(2); + } + + const parts = testStr.split("."); + const tld = parts[parts.length - 1]; + + if (opts.require_tld) { + // disallow fqdns without tld + if (parts.length < 2) { + return false; + } + + if ( + !opts.allow_numeric_tld && + !/^([a-z\u00A1-\u00A8\u00AA-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}|xn[a-z0-9-]{2,})$/i.test(tld) + ) { + return false; + } + + // disallow spaces + if (/\s/.test(tld)) { + return false; + } + } + + // reject numeric TLDs + if (!opts.allow_numeric_tld && /^\d+$/.test(tld)) { + return false; + } + + return parts.every((part) => { + if (part.length > 63 && !opts.ignore_max_length) { + return false; + } + + if (!/^[a-z_\u00a1-\uffff0-9-]+$/i.test(part)) { + return false; + } + + // disallow full-width chars + if (/[\uff01-\uff5e]/.test(part)) { + return false; + } + + // disallow parts starting or ending with hyphen + if (/^-|-$/.test(part)) { + return false; + } + + if (!opts.allow_underscores && /_/.test(part)) { + return false; + } + + return true; + }); }; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 5d6b8b60b..94006b5a5 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -272,10 +272,13 @@ export const queueServiceFactory = ( connection }); - workerContainer[name] = new Worker(name, jobFn, { - ...queueSettings, - connection - }); + const appCfg = getConfig(); + if (appCfg.QUEUE_WORKERS_ENABLED) { + workerContainer[name] = new Worker(name, jobFn, { + ...queueSettings, + connection + }); + } }; const startPg = async ( @@ -307,6 +310,11 @@ export const queueServiceFactory = ( event: U, listener: WorkerListener[U] ) => { + const appCfg = getConfig(); + if (!appCfg.QUEUE_WORKERS_ENABLED) { + return; + } + const worker = workerContainer[name]; worker.on(event, listener); }; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 0d8eae848..43a30760e 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -1,6 +1,8 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + interface SlugSchemaInputs { min?: number; max?: number; @@ -27,4 +29,13 @@ export const GenericResourceNameSchema = z .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"); + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Underscore, + CharacterType.Spaces + ])(val), + "Name can only contain alphanumeric characters, dashes, underscores, and spaces" + ); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 0fd18bc29..c8170a023 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -13,6 +13,7 @@ import { InternalServerError, NotFoundError, OidcAuthError, + PermissionBoundaryError, RateLimitError, ScimRequestError, UnauthorizedError @@ -117,7 +118,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider conditions: el.conditions })) }); - } else if (error instanceof ForbiddenRequestError) { + } else if (error instanceof ForbiddenRequestError || error instanceof PermissionBoundaryError) { void res.status(HttpStatusCodes.Forbidden).send({ reqId: req.id, statusCode: HttpStatusCodes.Forbidden, diff --git a/backend/src/server/plugins/secret-scanner.ts b/backend/src/server/plugins/secret-scanner.ts index 96ad5c72c..65dbbb87f 100644 --- a/backend/src/server/plugins/secret-scanner.ts +++ b/backend/src/server/plugins/secret-scanner.ts @@ -65,7 +65,7 @@ export const registerSecretScannerGhApp = async (server: FastifyZodProvider) => payload: JSON.stringify(req.body), signature: signatureSHA256 }); - void res.send("ok"); + return res.send("ok"); } }); } diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index 6bcc59d00..9f91d9774 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -34,7 +34,7 @@ export const registerServeUI = async ( TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED }; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; - void res.send(js); + return res.send(js); } }); @@ -57,7 +57,7 @@ export const registerServeUI = async ( reply.callNotFound(); return; } - void reply.sendFile("index.html"); + return reply.sendFile("index.html"); } }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0f1303263..dac88791f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1,5 +1,6 @@ import { CronJob } from "cron"; import { Knex } from "knex"; +import { monitorEventLoopDelay } from "perf_hooks"; import { z } from "zod"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; @@ -96,6 +97,7 @@ import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal" import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, TEnvConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; @@ -246,6 +248,9 @@ import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; +const histogram = monitorEventLoopDelay({ resolution: 20 }); +histogram.enable(); + export const registerRoutes = async ( server: FastifyZodProvider, { @@ -408,7 +413,14 @@ export const registerRoutes = async ( serviceTokenDAL, projectDAL }); - const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); + const licenseService = licenseServiceFactory({ + permissionService, + orgDAL, + licenseDAL, + keyStore, + identityOrgMembershipDAL, + projectDAL + }); const hsmService = hsmServiceFactory({ hsmModule, @@ -1630,6 +1642,18 @@ export const registerRoutes = async ( const cfg = getConfig(); const serverCfg = await getServerCfg(); + const meanLagMs = histogram.mean / 1e6; + const maxLagMs = histogram.max / 1e6; + const p99LagMs = histogram.percentile(99) / 1e6; + + logger.info( + `Event loop stats - Mean: ${meanLagMs.toFixed(2)}ms, Max: ${maxLagMs.toFixed(2)}ms, p99: ${p99LagMs.toFixed( + 2 + )}ms` + ); + + logger.info(`Raw event loop stats: ${JSON.stringify(histogram, null, 2)}`); + // try { // await db.raw("SELECT NOW()"); // } catch (err) { diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 77149eb1b..67bd26552 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -70,6 +70,19 @@ export const DefaultResponseErrorsSchema = { }) }; +export const booleanSchema = z + .union([z.boolean(), z.string().trim()]) + .transform((value) => { + if (typeof value === "string") { + // ie if not empty, 0 or false, return true + return Boolean(value) && Number(value) !== 0 && value.toLowerCase() !== "false"; + } + + return value; + }) + .optional() + .default(true); + export const sapPubSchema = SecretApprovalPoliciesSchema.merge( z.object({ environment: z.object({ diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index c8c5275d7..fc33bcafe 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -16,7 +16,12 @@ 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, SanitizedTagSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; +import { + booleanSchema, + 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"; @@ -24,20 +29,6 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; const MAX_DEEP_SEARCH_LIMIT = 500; // arbitrary limit to prevent excessive results -// handle querystring boolean values -const booleanSchema = z - .union([z.boolean(), z.string().trim()]) - .transform((value) => { - if (typeof value === "string") { - // ie if not empty, 0 or false, return true - return Boolean(value) && Number(value) !== 0 && value.toLowerCase() !== "false"; - } - - return value; - }) - .optional() - .default(true); - const parseSecretPathSearch = (search?: string) => { if (!search) return { @@ -109,6 +100,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(), includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), + includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) }), response: { @@ -124,9 +116,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }) .array() .optional(), + imports: SecretImportsSchema.omit({ importEnv: true }) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }), + environment: z.string() + }) + .array() + .optional(), totalFolderCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), + totalImportCount: z.number().optional(), totalCount: z.number() }) } @@ -143,6 +143,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { orderDirection, includeFolders, includeSecrets, + includeImports, includeDynamicSecrets } = req.query; @@ -159,6 +160,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let remainingLimit = limit; let adjustedOffset = offset; + let imports: Awaited> | undefined; let folders: Awaited> | undefined; let secrets: Awaited> | undefined; let dynamicSecrets: @@ -168,6 +170,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; let totalSecretCount: number | undefined; + let totalImportCount: number | undefined; + + if (includeImports) { + totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environments, + path: secretPath, + search + }); + + if (remainingLimit > 0 && totalImportCount > adjustedOffset) { + imports = await server.services.secretImport.getImportsMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environments, + path: secretPath, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_SECRET_IMPORTS, + metadata: { + environment: environments.join(","), + folderId: imports?.[0]?.folderId, + numberOfImports: imports.length + } + } + }); + + remainingLimit -= imports.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalImportCount); + } + } if (includeFolders) { // this is the unique count, ie duplicate folders across envs only count as 1 @@ -345,10 +394,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { folders, dynamicSecrets, secrets, + imports, totalFolderCount, totalDynamicSecretCount, + totalImportCount, totalSecretCount, - totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + totalCount: + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0) }; } }); diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 952cfcdaf..0105afd76 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -24,7 +24,7 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick( allowedAudience: true }).extend({ caCert: z.string(), - tokenReviewerJwt: z.string() + tokenReviewerJwt: z.string().optional().nullable() }); export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => { @@ -98,7 +98,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .object({ kubernetesHost: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.kubernetesHost), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), - tokenReviewerJwt: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt), + tokenReviewerJwt: z.string().trim().optional().describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt), allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames), allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience), @@ -195,7 +195,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .object({ kubernetesHost: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.kubernetesHost), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), - tokenReviewerJwt: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt), + tokenReviewerJwt: z.string().trim().nullable().optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt), allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames), allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience), diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 4f1f75f5c..4a7b66146 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -32,6 +32,8 @@ const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.pick({ caCert: z.string() }); +const MAX_OIDC_CLAIM_SIZE = 32_768; + export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -55,7 +57,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg, oidcTokenData } = await server.services.identityOidcAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -69,7 +71,11 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) metadata: { identityId: identityOidcAuth.identityId, identityAccessTokenId: identityAccessToken.id, - identityOidcAuthId: identityOidcAuth.id + identityOidcAuthId: identityOidcAuth.id, + oidcClaimsReceived: + Buffer.from(JSON.stringify(oidcTokenData), "utf8").byteLength < MAX_OIDC_CLAIM_SIZE + ? oidcTokenData + : { payload: "Error: Payload exceeds 32KB, provided oidc claim not recorded in audit log." } } } }); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 21723ef58..67b938ae4 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -4,7 +4,6 @@ import { AuditLogsSchema, GroupsSchema, IncidentContactsSchema, - OrganizationsSchema, OrgMembershipsSchema, OrgRolesSchema, UsersSchema @@ -57,7 +56,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: OrganizationsSchema + organization: sanitizedOrganizationSchema }) } }, @@ -263,7 +262,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ message: z.string(), - organization: OrganizationsSchema + organization: sanitizedOrganizationSchema }) } }, diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index b0a22548a..5f8b553f5 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -9,6 +9,8 @@ import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { booleanSchema } from "../sanitizedSchemas"; + export const registerSecretFolderRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -347,11 +349,14 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) .transform(removeTrailingSlash) - .describe(FOLDERS.LIST.directory) + .describe(FOLDERS.LIST.directory), + recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive) }), response: { 200: z.object({ - folders: SecretFoldersSchema.array() + folders: SecretFoldersSchema.extend({ + relativePath: z.string().optional() + }).array() }) } }, diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 326b8a497..90b500a5a 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { - OrganizationsSchema, OrgMembershipsSchema, ProjectMembershipsSchema, ProjectsSchema, @@ -15,6 +14,7 @@ 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"; +import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ @@ -335,7 +335,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: OrganizationsSchema + organization: sanitizedOrganizationSchema }) } }, @@ -365,7 +365,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: OrganizationsSchema, + organization: sanitizedOrganizationSchema, accessToken: z.string() }) } @@ -396,4 +396,30 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { organization, accessToken: tokens.accessToken }; } }); + + server.route({ + method: "POST", + url: "/privilege-system-upgrade", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + organization: sanitizedOrganizationSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const organization = await server.services.org.upgradePrivilegeSystem({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + orgId: req.permission.orgId + }); + + return { organization }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 3dfaa6532..f03b5db4c 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -7,9 +7,11 @@ import { z } from "zod"; import { ActionProjectType, ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -58,7 +60,6 @@ import { TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; -import { hostnameRegex } from "./certificate-authority-validators"; type TCertificateAuthorityServiceFactoryDep = { certificateAuthorityDAL: Pick< @@ -1017,9 +1018,7 @@ export const certificateAuthorityServiceFactory = ({ const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; // validate imported certificate and certificate chain - const certificates = certificateChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + const certificates = extractX509CertFromChain(certificateChain)?.map((cert) => new x509.X509Certificate(cert)); if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); @@ -1325,7 +1324,7 @@ export const certificateAuthorityServiceFactory = ({ } // check if the altName is a valid hostname - if (hostnameRegex.test(altName)) { + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName @@ -1702,7 +1701,7 @@ export const certificateAuthorityServiceFactory = ({ } // check if the altName is a valid hostname - if (hostnameRegex.test(altName)) { + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 1840652ca..979a3b9c5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { isValidIp } from "@app/lib/ip"; +import { isFQDN } from "@app/lib/validator/validate-url"; const isValidDate = (dateString: string) => { const date = new Date(dateString); @@ -9,7 +10,6 @@ const isValidDate = (dateString: string) => { export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); -export const hostnameRegex = /^(?!:\/\/)(\*\.)?([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/; export const validateAltNamesField = z .string() .trim() @@ -27,7 +27,7 @@ export const validateAltNamesField = z if (data === "") return true; // Split and validate each alt name return data.split(", ").every((name) => { - return hostnameRegex.test(name) || z.string().email().safeParse(name).success || isValidIp(name); + return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); }); }, { diff --git a/backend/src/services/certificate-template/certificate-template-fns.ts b/backend/src/services/certificate-template/certificate-template-fns.ts index 0a19ac92f..fa8055f69 100644 --- a/backend/src/services/certificate-template/certificate-template-fns.ts +++ b/backend/src/services/certificate-template/certificate-template-fns.ts @@ -11,6 +11,7 @@ export const validateCertificateDetailsAgainstTemplate = ( }, template: TCertificateTemplates ) => { + // these are validated in router using validateTemplateRegexField const commonNameRegex = new RegExp(template.commonName); if (!commonNameRegex.test(cert.commonName)) { throw new BadRequestError({ diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 9a5cd9269..04bf76f5c 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -6,6 +6,7 @@ import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -281,9 +282,7 @@ export const certificateTemplateServiceFactory = ({ }); // validate CA chain - const certificates = caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); if (!certificates) { throw new BadRequestError({ message: "Failed to parse certificate chain" }); @@ -379,9 +378,7 @@ export const certificateTemplateServiceFactory = ({ }; if (caChain) { - const certificates = caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); if (!certificates) { throw new BadRequestError({ message: "Failed to parse certificate chain" }); diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts index 41a06b05d..60694b598 100644 --- a/backend/src/services/certificate-template/certificate-template-validators.ts +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -1,13 +1,27 @@ import safe from "safe-regex"; import z from "zod"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + export const validateTemplateRegexField = z .string() .min(1) .max(100) - .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { - message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - }) + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, // (space) + CharacterType.Asterisk, // * + CharacterType.At, // @ + CharacterType.Hyphen, // - + CharacterType.Period, // . + CharacterType.Backslash // \ + ])(val), + { + message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + } + ) // we ensure that the inputted pattern is computationally safe by limiting star height to 1 .refine((v) => safe(v), { message: "Unsafe REGEX pattern" diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 986d88fb9..1ff2c78d2 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -1,12 +1,15 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} 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 { validatePermissionBoundary } from "@app/lib/casl/boundary"; +import { ProjectPermissionGroupActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; import { isUuidV4 } from "@app/lib/validator"; @@ -70,7 +73,7 @@ export const groupProjectServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); if (project.version < 2) throw new BadRequestError({ message: `Failed to add group to E2EE project` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -78,7 +81,7 @@ export const groupProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Create, ProjectPermissionSub.Groups); let group: TGroups | null = null; if (isUuidV4(groupIdOrName)) { @@ -102,11 +105,21 @@ export const groupProjectServiceFactory = ({ project.id ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to assign group to a more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to assign group to role", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); } @@ -248,7 +261,7 @@ export const groupProjectServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -256,7 +269,7 @@ export const groupProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Edit, ProjectPermissionSub.Groups); const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId }); if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` }); @@ -269,11 +282,21 @@ export const groupProjectServiceFactory = ({ requestedRoleChange, project.id ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to assign group to a more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to assign group to role", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); } @@ -360,7 +383,7 @@ export const groupProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Delete, ProjectPermissionSub.Groups); const deletedProjectGroup = await groupProjectDAL.transaction(async (tx) => { const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx); @@ -405,7 +428,7 @@ export const groupProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); const groupMemberships = await groupProjectDAL.findByProjectId(project.id); return groupMemberships; @@ -433,7 +456,7 @@ export const groupProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); const [groupMembership] = await groupProjectDAL.findByProjectId(project.id, { groupId diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 5be3dfe4c..7f0aadfca 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -5,11 +5,14 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -39,6 +42,31 @@ type TIdentityAwsAuthServiceFactoryDep = { export type TIdentityAwsAuthServiceFactory = ReturnType; +const awsRegionFromHeader = (authorizationHeader: string): string | null => { + // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html + // The Authorization header takes the following form. + // Authorization: AWS4-HMAC-SHA256 + // Credential=AKIAIOSFODNN7EXAMPLE/20230719/us-east-1/sts/aws4_request, + // SignedHeaders=content-length;content-type;host;x-amz-date, + // Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024 + // + // The credential is in the form of "////aws4_request" + try { + const fields = authorizationHeader.split(" "); + for (const field of fields) { + if (field.startsWith("Credential=")) { + const parts = field.split("/"); + if (parts.length >= 3) { + return parts[2]; + } + } + } + } catch { + return null; + } + return null; +}; + export const identityAwsAuthServiceFactory = ({ identityAccessTokenDAL, identityAwsAuthDAL, @@ -57,6 +85,9 @@ export const identityAwsAuthServiceFactory = ({ const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); + const region = headers.Authorization ? awsRegionFromHeader(headers.Authorization) : null; + const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; + const { data: { GetCallerIdentityResponse: { @@ -65,7 +96,7 @@ export const identityAwsAuthServiceFactory = ({ } }: { data: TGetCallerIdentityResponse } = await axios({ method: iamHttpRequestMethod, - url: headers?.Host ? `https://${headers.Host}` : identityAwsAuth.stsEndpoint, + url, headers, data: body }); @@ -93,7 +124,8 @@ export const identityAwsAuthServiceFactory = ({ .some((principalArn) => { // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" // considers exact matches + wildcard matches - const regex = new RegExp(`^${principalArn.replace(/\*/g, ".*")}$`); + // heavily validated in router + const regex = new RegExp(`^${principalArn.replaceAll("*", ".*")}$`); return regex.test(extractPrincipalArn(Arn)); }); @@ -175,7 +207,7 @@ export const identityAwsAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -254,7 +286,7 @@ export const identityAwsAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -308,7 +340,7 @@ export const identityAwsAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; }; @@ -326,14 +358,14 @@ export const identityAwsAuthServiceFactory = ({ message: "The identity does not have aws auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -343,11 +375,22 @@ export const identityAwsAuthServiceFactory = ({ actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke aws auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke aws auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts index 2cc736f1e..b3f3ccc94 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -1,6 +1,8 @@ +import safe from "safe-regex"; import { z } from "zod"; const twelveDigitRegex = /^\d{12}$/; +// akhilmhdh: change this to a normal function later. Checked no redosable at the moment const arnRegex = /^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/; export const validateAccountIds = z @@ -42,7 +44,8 @@ export const validatePrincipalArns = z // Split the string by commas to check each supposed ARN const arns = data.split(","); // Return true only if every item matches one of the allowed ARN formats - return arns.every((arn) => arnRegex.test(arn.trim())); + // and checks whether the provided regex is safe + return arns.map((el) => el.trim()).every((arn) => safe(`^${arn.replaceAll("*", ".*")}$`) && arnRegex.test(arn)); }, { message: diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 87e076e93..5baa00652 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -3,11 +3,14 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -147,7 +150,7 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -225,7 +228,7 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -281,7 +284,7 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; }; @@ -300,14 +303,14 @@ export const identityAzureAuthServiceFactory = ({ message: "The identity does not have azure auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -316,11 +319,21 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke azure auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke azure auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 321f633ff..7420b61cf 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -3,11 +3,14 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -188,7 +191,7 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -268,7 +271,7 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -326,7 +329,7 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; }; @@ -346,14 +349,14 @@ export const identityGcpAuthServiceFactory = ({ message: "The identity does not have gcp auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -362,11 +365,21 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke gcp auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke gcp auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); 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 988785d7a..39fc28ad2 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 @@ -5,11 +5,20 @@ import { JwksClient } from "jwks-rsa"; import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { getStringValueByDot } from "@app/lib/template/dot-access"; @@ -278,7 +287,7 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -381,7 +390,7 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -470,7 +479,7 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); @@ -504,7 +513,7 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, @@ -512,7 +521,7 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -522,11 +531,21 @@ export const identityJwtAuthServiceFactory = ({ actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke jwt auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke jwt auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 8e4e46bc7..a5e036d35 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -5,11 +5,14 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -84,6 +87,9 @@ export const identityKubernetesAuthServiceFactory = ({ tokenReviewerJwt = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt }).toString(); + } else { + // if no token reviewer is provided means the incoming token has to act as reviewer + tokenReviewerJwt = serviceAccountJwt; } const { data } = await axios @@ -254,7 +260,7 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -291,7 +297,9 @@ export const identityKubernetesAuthServiceFactory = ({ accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - encryptedKubernetesTokenReviewerJwt: encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob, + encryptedKubernetesTokenReviewerJwt: tokenReviewerJwt + ? encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob + : null, encryptedKubernetesCaCertificate: encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob }, tx @@ -345,7 +353,7 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -387,10 +395,12 @@ export const identityKubernetesAuthServiceFactory = ({ updateQuery.encryptedKubernetesCaCertificate = encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob; } - if (tokenReviewerJwt !== undefined) { + if (tokenReviewerJwt) { updateQuery.encryptedKubernetesTokenReviewerJwt = encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob; + } else if (tokenReviewerJwt === null) { + updateQuery.encryptedKubernetesTokenReviewerJwt = null; } const updatedKubernetesAuth = await identityKubernetesAuthDAL.updateById(identityKubernetesAuth.id, updateQuery); @@ -439,7 +449,7 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -476,14 +486,14 @@ export const identityKubernetesAuthServiceFactory = ({ message: "The identity does not have kubernetes auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -492,11 +502,21 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke kubernetes auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke kubernetes auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index c66ec8480..b3bbcb49e 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -9,7 +9,7 @@ export type TAttachKubernetesAuthDTO = { identityId: string; kubernetesHost: string; caCert: string; - tokenReviewerJwt: string; + tokenReviewerJwt?: string; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -24,7 +24,7 @@ export type TUpdateKubernetesAuthDTO = { identityId: string; kubernetesHost?: string; caCert?: string; - tokenReviewerJwt?: string; + tokenReviewerJwt?: string | null; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 78d5cd33a..d54a49f38 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -6,11 +6,20 @@ import { JwksClient } from "jwks-rsa"; import { IdentityAuthMethod, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { getStringValueByDot } from "@app/lib/template/dot-access"; @@ -204,7 +213,7 @@ export const identityOidcAuthServiceFactory = ({ } ); - return { accessToken, identityOidcAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityOidcAuth, identityAccessToken, identityMembershipOrg, oidcTokenData: tokenData }; }; const attachOidcAuth = async ({ @@ -249,7 +258,7 @@ export const identityOidcAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -341,7 +350,7 @@ export const identityOidcAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -414,7 +423,7 @@ export const identityOidcAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); @@ -442,7 +451,7 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, @@ -450,7 +459,7 @@ export const identityOidcAuthServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -460,11 +469,22 @@ export const identityOidcAuthServiceFactory = ({ actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke oidc auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke oidc auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index ff97ae7b6..ba63f7afd 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -1,14 +1,16 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} 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 { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; -import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; @@ -54,7 +56,7 @@ export const identityProjectServiceFactory = ({ projectId, roles }: TCreateProjectIdentityDTO) => { - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -63,7 +65,7 @@ export const identityProjectServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionIdentityActions.Create, subject(ProjectPermissionSub.Identity, { identityId }) @@ -91,11 +93,21 @@ export const identityProjectServiceFactory = ({ projectId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to assign to a more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to assign to role", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); } @@ -162,7 +174,7 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId }: TUpdateProjectIdentityDTO) => { - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -171,7 +183,7 @@ export const identityProjectServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); @@ -187,11 +199,22 @@ export const identityProjectServiceFactory = ({ projectId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to change to a more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to change role", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); } @@ -271,26 +294,10 @@ export const identityProjectServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionIdentityActions.Delete, subject(ProjectPermissionSub.Identity, { identityId }) ); - const { permission: identityRolePermission } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); - if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to remove more privileged identity", - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); return deletedIdentity; }; @@ -315,7 +322,10 @@ export const identityProjectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); const identityMemberships = await identityProjectDAL.findByProjectId(projectId, { limit, @@ -348,7 +358,7 @@ export const identityProjectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionIdentityActions.Read, subject(ProjectPermissionSub.Identity, { identityId }) ); diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index 1d327fff3..549512452 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -3,11 +3,14 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -85,7 +88,7 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -161,7 +164,7 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -215,7 +218,7 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId }; }; @@ -245,9 +248,9 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( + const { permission: rolePermission, membership } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, identityMembershipOrg.orgId, @@ -255,11 +258,21 @@ export const identityTokenAuthServiceFactory = ({ actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke token auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke token auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -301,20 +314,31 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( + const { permission: rolePermission, membership } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to create token for identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create token for identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -383,7 +407,7 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const tokens = await identityAccessTokenDAL.find( { @@ -429,20 +453,30 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( + const { permission: rolePermission, membership } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update token for identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update token for identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -496,7 +530,7 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const [revokedToken] = await identityAccessTokenDAL.update( { diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 848b536ba..8ab499e65 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -6,11 +6,14 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -64,14 +67,22 @@ export const identityUaServiceFactory = ({ ipAddress: ip, trustedIps: identityUa.clientSecretTrustedIps as TIp[] }); + const clientSecretPrefix = clientSecret.slice(0, 4); const clientSecrtInfo = await identityUaClientSecretDAL.find({ identityUAId: identityUa.id, - isClientSecretRevoked: false + isClientSecretRevoked: false, + clientSecretPrefix }); - const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) => - bcrypt.compareSync(clientSecret, clientSecretHash) - ); + let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null; + for await (const info of clientSecrtInfo) { + const isMatch = await bcrypt.compare(clientSecret, info.clientSecretHash); + if (isMatch) { + validClientSecretInfo = info; + break; + } + } + if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; @@ -104,7 +115,7 @@ export const identityUaServiceFactory = ({ } const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx); + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); const newToken = await identityAccessTokenDAL.create( { identityId: identityUa.identityId, @@ -176,7 +187,7 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { @@ -245,14 +256,17 @@ export const identityUaServiceFactory = ({ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); + if (!uaIdentityAuth) { + throw new NotFoundError({ message: `Failed to find universal auth for identity with ID ${identityId}` }); + } + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { throw new BadRequestError({ message: "The identity does not have universal auth" }); } - const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); - if ( (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) > 0 && (accessTokenTTL || uaIdentityAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) @@ -267,7 +281,7 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map((clientSecretTrustedIp) => { @@ -321,14 +335,17 @@ export const identityUaServiceFactory = ({ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); + if (!uaIdentityAuth) { + throw new NotFoundError({ message: `Failed to find universal auth for identity with ID ${identityId}` }); + } + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { throw new BadRequestError({ message: "The identity does not have universal auth" }); } - const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -336,7 +353,7 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId }; }; @@ -362,20 +379,30 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( + const { permission: rolePermission, membership } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke universal auth of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke universal auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -405,14 +432,14 @@ export const identityUaServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -421,11 +448,21 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to create client secret for a more privileged identity.", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create client secret for identity.", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -434,6 +471,7 @@ export const identityUaServiceFactory = ({ const clientSecretHash = await bcrypt.hash(clientSecret, appCfg.SALT_ROUNDS); const identityUaAuth = await identityUaDAL.findOne({ identityId: identityMembershipOrg.identityId }); + if (!identityUaAuth) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); const identityUaClientSecret = await identityUaClientSecretDAL.create({ identityUAId: identityUaAuth.id, @@ -467,14 +505,14 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -484,11 +522,21 @@ export const identityUaServiceFactory = ({ actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to get identity client secret with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to get identity client secret with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -520,14 +568,20 @@ export const identityUaServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const identityUa = await identityUaDAL.findOne({ identityId }); + if (!identityUa) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); + if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -536,15 +590,24 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to read identity client secret of identity with more privileged role", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to read identity client secret of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); - const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId); return { ...clientSecret, identityId, orgId: identityMembershipOrg.orgId }; }; @@ -565,14 +628,20 @@ export const identityUaServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const identityUa = await identityUaDAL.findOne({ identityId }); + if (!identityUa) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); + if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityMembershipOrg.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, @@ -581,18 +650,31 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); - if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to revoke identity client secret with more privileged role", + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.DeleteToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) { + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke identity client secret with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.DeleteToken, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); + } - const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { + const updatedClientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { isClientSecretRevoked: true }); - return { ...clientSecret, identityId, orgId: identityMembershipOrg.orgId }; + + return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId }; }; return { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index bd6c8d30c..f9185ba9d 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -2,13 +2,15 @@ import { ForbiddenError } from "@casl/ability"; import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; -import { ActorType } from "../auth/auth-type"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityDALFactory } from "./identity-dal"; import { TIdentityMetadataDALFactory } from "./identity-metadata-dal"; @@ -51,19 +53,35 @@ export const identityServiceFactory = ({ actorOrgId, metadata }: TCreateIdentityDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( role, orgId ); const isCustomRole = Boolean(customRole); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to create a more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create identity", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); @@ -121,29 +139,14 @@ export const identityServiceFactory = ({ const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission, membership } = await permissionService.getOrgPermission( actor, actorId, identityOrgMembership.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: identityRolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - id, - identityOrgMembership.orgId, - actorAuthMethod, - actorOrgId - ); - const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); - if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to update a more privileged identity", - details: { missingPermissions: permissionBoundary.missingPermissions } - }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); let customRole: TOrgRoles | undefined; if (role) { @@ -153,11 +156,21 @@ export const identityServiceFactory = ({ ); const isCustomRole = Boolean(customOrgRole); - const appliedRolePermissionBoundary = validatePermissionBoundary(permission, rolePermission); + const appliedRolePermissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); if (!appliedRolePermissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to create a more privileged identity", + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update identity", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions } }); if (isCustomRole) customRole = customOrgRole; @@ -209,7 +222,7 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return identity; }; @@ -233,21 +246,8 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); - const { permission: identityRolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - id, - identityOrgMembership.orgId, - actorAuthMethod, - actorOrgId - ); - const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); - if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: "Failed to delete more privileged identity", - details: { missingPermissions: permissionBoundary.missingPermissions } - }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); const deletedIdentity = await identityDAL.deleteById(id); @@ -269,7 +269,7 @@ export const identityServiceFactory = ({ search }: TListOrgIdentitiesByOrgIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityMemberships = await identityOrgMembershipDAL.find({ [`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId, @@ -305,7 +305,7 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityMemberships = await identityProjectDAL.findByIdentityId(identityId); return identityMemberships; diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 8b48e20d4..4a5fd8231 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -923,16 +923,14 @@ const getAppsCodefresh = async ({ accessToken }: { accessToken: string }) => { /** * Return list of projects for Windmill integration */ -const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { - const { data } = await request.get<{ id: string; name: string }[]>( - `${IntegrationUrls.WINDMILL_API_URL}/workspaces/list`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } +const getAppsWindmill = async ({ accessToken, url }: { accessToken: string; url?: string | null }) => { + const apiUrl = url ? `${url}/api` : IntegrationUrls.WINDMILL_API_URL; + const { data } = await request.get<{ id: string; name: string }[]>(`${apiUrl}/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); // check for write access of secrets in windmill workspaces const writeAccessCheck = data.map(async (app) => { @@ -941,7 +939,7 @@ const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { const folderPath = "f/folder/variable"; const { data: writeUser } = await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/create`, + `${apiUrl}/w/${app.id}/variables/create`, { path: userPath, value: "variable", @@ -957,7 +955,7 @@ const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { ); const { data: writeFolder } = await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/create`, + `${apiUrl}/w/${app.id}/variables/create`, { path: folderPath, value: "variable", @@ -974,14 +972,14 @@ const getAppsWindmill = async ({ accessToken }: { accessToken: string }) => { // is write access is allowed then delete the created secrets from workspace if (writeUser && writeFolder) { - await request.delete(`${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/delete/${userPath}`, { + await request.delete(`${apiUrl}/w/${app.id}/variables/delete/${userPath}`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" } }); - await request.delete(`${IntegrationUrls.WINDMILL_API_URL}/w/${app.id}/variables/delete/${folderPath}`, { + await request.delete(`${apiUrl}/w/${app.id}/variables/delete/${folderPath}`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -1316,7 +1314,8 @@ export const getApps = async ({ case Integrations.WINDMILL: return getAppsWindmill({ - accessToken + accessToken, + url }); case Integrations.DIGITAL_OCEAN_APP_PLATFORM: diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 93318762b..e5c9d4eb0 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -584,7 +584,7 @@ const syncSecretsAzureKeyVault = async ({ }[] = []; Object.keys(secrets).forEach((key) => { - const hyphenatedKey = key.replace(/_/g, "-"); + const hyphenatedKey = key.replaceAll("_", "-"); if (!(hyphenatedKey in res)) { // case: secret has been created setSecrets.push({ @@ -603,7 +603,7 @@ const syncSecretsAzureKeyVault = async ({ const deleteSecrets: AzureKeyVaultSecret[] = []; Object.keys(res).forEach((key) => { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); if (!(underscoredKey in secrets)) { deleteSecrets.push(res[key]); } @@ -617,7 +617,7 @@ const syncSecretsAzureKeyVault = async ({ if (!integration.lastUsed) { Object.keys(res).forEach((key) => { // first time using integration - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); // -> apply initial sync behavior switch (metadata.initialSyncBehavior) { @@ -3578,7 +3578,7 @@ const syncSecretsTeamCity = async ({ .filter((parameter) => !parameter.inherited) .reduce( (obj, secret) => { - const secretName = secret.name.replace(/^env\./, ""); + const secretName = secret.name.startsWith(".env") ? secret.name.slice(4) : secret.name; return { ...obj, [secretName]: secret.value @@ -3635,7 +3635,7 @@ const syncSecretsTeamCity = async ({ ) ).data.property.reduce( (obj, secret) => { - const secretName = secret.name.replace(/^env\./, ""); + const secretName = secret.name.startsWith("env.") ? secret.name.slice(4) : secret.name; return { ...obj, [secretName]: secret.value @@ -4127,10 +4127,10 @@ const syncSecretsWindmill = async ({ is_secret: boolean; description?: string; } - + const apiUrl = integration.url ? `${integration.url}/api` : IntegrationUrls.WINDMILL_API_URL; // get secrets stored in windmill workspace const res = ( - await request.get(`${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/list`, { + await request.get(`${apiUrl}/w/${integration.appId}/variables/list`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -4146,7 +4146,6 @@ const syncSecretsWindmill = async ({ // eslint-disable-next-line const pattern = new RegExp("^(u/|f/)[a-zA-Z0-9_-]+/([a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]*[^/]$"); - for await (const key of Object.keys(secrets)) { if ((key.startsWith("u/") || key.startsWith("f/")) && pattern.test(key)) { if (!(key in res)) { @@ -4154,7 +4153,7 @@ const syncSecretsWindmill = async ({ // -> create secret await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/create`, + `${apiUrl}/w/${integration.appId}/variables/create`, { path: key, value: secrets[key].value, @@ -4171,7 +4170,7 @@ const syncSecretsWindmill = async ({ } else { // -> update secret await request.post( - `${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/update/${res[key].path}`, + `${apiUrl}/w/${integration.appId}/variables/update/${res[key].path}`, { path: key, value: secrets[key].value, @@ -4192,16 +4191,13 @@ const syncSecretsWindmill = async ({ for await (const key of Object.keys(res)) { if (!(key in secrets)) { // -> delete secret - await request.delete( - `${IntegrationUrls.WINDMILL_API_URL}/w/${integration.appId}/variables/delete/${res[key].path}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "Accept-Encoding": "application/json" - } + await request.delete(`${apiUrl}/w/${integration.appId}/variables/delete/${res[key].path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Accept-Encoding": "application/json" } - ); + }); } } }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 221840e1b..85d348854 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -43,7 +43,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Organization)) .select( db.raw(` - CASE + CASE WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}' WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}' ELSE '' @@ -80,7 +80,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Organization)) .select( db.raw(` - CASE + CASE WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}' WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}' ELSE '' @@ -119,7 +119,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Organization)) .select( db.raw(` - CASE + CASE WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN 'saml' WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN 'oidc' ELSE '' diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 8f5b85403..ef49d8178 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -12,5 +12,9 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ kmsDefaultKeyId: true, defaultMembershipRole: true, enforceMfa: true, - selectedMfaMethod: true + selectedMfaMethod: true, + allowSecretSharingOutsideOrganization: true, + shouldUseNewPrivilegeSystem: true, + privilegeUpgradeInitiatedByUsername: true, + privilegeUpgradeInitiatedAt: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 4ef52a551..1868871f3 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -21,18 +21,29 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { OrgPermissionActions, + OrgPermissionGroupActions, OrgPermissionSecretShareAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} 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 { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; @@ -76,6 +87,7 @@ import { TResendOrgMemberInvitationDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, + TUpgradePrivilegeSystemDTO, TVerifyUserToOrgDTO } from "./org-types"; @@ -187,7 +199,7 @@ export const orgServiceFactory = ({ const getOrgGroups = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TGetOrgGroupsDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const groups = await groupDAL.findByOrgId(orgId); return groups; }; @@ -281,6 +293,45 @@ export const orgServiceFactory = ({ }; }; + const upgradePrivilegeSystem = async ({ + actorId, + actorOrgId, + actorAuthMethod, + orgId + }: TUpgradePrivilegeSystemDTO) => { + const { membership } = await permissionService.getUserOrgPermission(actorId, orgId, actorAuthMethod, actorOrgId); + + if (membership.role !== OrgMembershipRole.Admin) { + throw new ForbiddenRequestError({ + message: "Insufficient privileges - only the organization admin can upgrade the privilege system." + }); + } + + return orgDAL.transaction(async (tx) => { + const org = await orgDAL.findById(actorOrgId, tx); + if (org.shouldUseNewPrivilegeSystem) { + throw new BadRequestError({ + message: "Privilege system already upgraded" + }); + } + + const user = await userDAL.findById(actorId, tx); + if (!user) { + throw new NotFoundError({ message: `User with ID '${actorId}' not found` }); + } + + return orgDAL.updateById( + actorOrgId, + { + shouldUseNewPrivilegeSystem: true, + privilegeUpgradeInitiatedAt: new Date(), + privilegeUpgradeInitiatedByUsername: user.username + }, + tx + ); + }); + }; + /* * Update organization details * */ @@ -856,7 +907,7 @@ export const orgServiceFactory = ({ // if there exist no project membership we set is as given by the request for await (const project of projectsToInvite) { const projectId = project.id; - const { permission: projectPermission } = await permissionService.getProjectPermission({ + const { permission: projectPermission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -865,7 +916,7 @@ export const orgServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(projectPermission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member ); const existingMembers = await projectMembershipDAL.find( @@ -888,6 +939,34 @@ export const orgServiceFactory = ({ ProjectMembershipRole.Member ]; + for await (const invitedRole of invitedProjectRoles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + invitedRole, + projectId + ); + + if (invitedRole !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member, + projectPermission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to invite user to the project", + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + const customProjectRoles = invitedProjectRoles.filter( (role) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); @@ -1021,9 +1100,9 @@ export const orgServiceFactory = ({ const sanitizedProjectMembershipRoles: TProjectUserMembershipRolesInsert[] = []; invitedProjectRoles.forEach((projectRole) => { const isCustomRole = Boolean(customRolesGroupBySlug?.[projectRole]?.[0]); - projectMemberships.forEach((membership) => { + projectMemberships.forEach((membershipEntry) => { sanitizedProjectMembershipRoles.push({ - projectMembershipId: membership.id, + projectMembershipId: membershipEntry.id, role: isCustomRole ? ProjectMembershipRole.Custom : projectRole, customRoleId: customRolesGroupBySlug[projectRole] ? customRolesGroupBySlug[projectRole][0].id : null }); @@ -1304,6 +1383,7 @@ export const orgServiceFactory = ({ getOrgGroups, listProjectMembershipsByOrgMembershipId, findOrgBySlug, - resendOrgMemberInvitation + resendOrgMemberInvitation, + upgradePrivilegeSystem }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 3c6faeae9..9d14b092e 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -76,6 +76,8 @@ export type TUpdateOrgDTO = { }>; } & TOrgPermission; +export type TUpgradePrivilegeSystemDTO = Omit; + export type TGetOrgGroupsDTO = TOrgPermission; export type TListProjectMembershipsByOrgMembershipIdDTO = { diff --git a/backend/src/services/project-key/project-key-service.ts b/backend/src/services/project-key/project-key-service.ts index b56c0a43e..8ce2569a0 100644 --- a/backend/src/services/project-key/project-key-service.ts +++ b/backend/src/services/project-key/project-key-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; 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 { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; @@ -40,7 +40,7 @@ export const projectKeyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); const receiverMembership = await projectMembershipDAL.findOne({ userId: receiverId, @@ -89,7 +89,7 @@ export const projectKeyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); return projectKeyDAL.findAllProjectUserPubKeys(projectId); }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 48b65e8f8..f452e381f 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -3,12 +3,15 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectMembershipRole, ProjectVersion, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} 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 { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; -import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; @@ -86,7 +89,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); @@ -130,7 +133,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); const [membership] = await projectMembershipDAL.findAllProjectMembers(projectId, { username }); if (!membership) throw new NotFoundError({ message: `Project membership not found for user '${username}'` }); @@ -153,7 +156,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); const [membership] = await projectMembershipDAL.findAllProjectMembers(projectId, { id }); if (!membership) throw new NotFoundError({ message: `Project membership not found for user ${id}` }); @@ -180,7 +183,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member); const orgMembers = await orgDAL.findMembership({ [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId, $in: { @@ -253,7 +256,7 @@ export const projectMembershipServiceFactory = ({ membershipId, roles }: TUpdateProjectMembershipDTO) => { - const { permission } = await permissionService.getProjectPermission({ + const { permission, membership } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -261,7 +264,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) { @@ -274,11 +277,21 @@ export const projectMembershipServiceFactory = ({ projectId ); - const permissionBoundary = validatePermissionBoundary(permission, rolePermission); + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member, + permission, + rolePermission + ); if (!permissionBoundary.isValid) - throw new ForbiddenRequestError({ - name: "PermissionBoundaryError", - message: `Failed to change to a more privileged role ${requestedRoleChange}`, + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + `Failed to change role ${requestedRoleChange}`, + membership.shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member + ), details: { missingPermissions: permissionBoundary.missingPermissions } }); } @@ -361,7 +374,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Delete, ProjectPermissionSub.Member); const member = await userDAL.findUserByProjectMembershipId(membershipId); @@ -397,7 +410,7 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Delete, ProjectPermissionSub.Member); const project = await projectDAL.findById(projectId); diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 1d695a5ff..3dfe11d2c 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -9,6 +9,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { ActorAuthMethod } from "../auth/auth-type"; @@ -72,6 +73,9 @@ export const projectRoleServiceFactory = ({ throw new BadRequestError({ name: "Create Role", message: "Project role with same slug already exists" }); } + validateHandlebarTemplate("Project Role Create", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); const role = await projectRoleDAL.create({ ...data, projectId @@ -134,7 +138,9 @@ export const projectRoleServiceFactory = ({ if (existingRole && existingRole.id !== roleId) throw new BadRequestError({ name: "Update Role", message: "Project role with the same slug already exists" }); } - + validateHandlebarTemplate("Project Role Update", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); const updatedRole = await projectRoleDAL.updateById(projectRole.id, { ...data, permissions: data.permissions ? data.permissions : undefined diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index 434f5f713..e136c5a50 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -1,20 +1,22 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate } from "@app/db/schemas"; +import { TableName, TSecretFolders, TSecretFoldersUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { groupBy, removeTrailingSlash } from "@app/lib/fn"; import { ormify, selectAllTableCols } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { isValidSecretPath } from "@app/lib/validator"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { TFindFoldersDeepByParentIdsDTO } from "./secret-folder-types"; -export const validateFolderName = (folderName: string) => { - const validNameRegex = /^[a-zA-Z0-9-_]+$/; - return validNameRegex.test(folderName); -}; +export const validateFolderName = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Underscore +]); const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: string; secretPath: string }>) => { // this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2 @@ -41,12 +43,12 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str void baseQb .select({ depth: 1, - // latestFolderVerId: db.raw("NULL::uuid"), path: db.raw("'/'") }) .from(TableName.SecretFolder) .where({ - parentId: null + parentId: null, + name: "root" }) .whereIn( "envId", @@ -69,9 +71,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str .where((wb) => formatedQuery.map(({ secretPath }) => wb.orWhereRaw( - `depth = array_position(ARRAY[${secretPath.map(() => "?").join(",")}]::varchar[], ${ - TableName.SecretFolder - }.name,depth)`, + `secret_folders.name = (ARRAY[${secretPath.map(() => "?").join(",")}]::varchar[])[depth]`, [...secretPath] ) ) @@ -107,7 +107,6 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str void baseQb .select({ depth: 1, - // latestFolderVerId: db.raw("NULL::uuid"), path: db.raw("'/'") }) .from(TableName.SecretFolder) @@ -117,6 +116,11 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str parentId: null }) .whereIn(`${TableName.Environment}.slug`, environments) + .select( + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("projectId").withSchema(TableName.Environment) + ) .select(selectAllTableCols(TableName.SecretFolder)) .union( (qb) => @@ -128,21 +132,20 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str depth: db.raw("parent.depth + 1"), path: db.raw( "CONCAT((CASE WHEN parent.path = '/' THEN '' ELSE parent.path END),'/', secret_folders.name)" - ) + ), + envSlug: db.ref("envSlug").withSchema("parent"), + envName: db.ref("envName").withSchema("parent"), + projectId: db.ref("projectId").withSchema("parent") }) .select(selectAllTableCols(TableName.SecretFolder)) - .whereRaw( - `depth = array_position(ARRAY[${pathSegments - .map(() => "?") - .join(",")}]::varchar[], secret_folders.name,depth)`, - [...pathSegments] - ) + .whereRaw(`secret_folders.name = (ARRAY[${pathSegments.map(() => "?").join(",")}]::varchar[])[depth]`, [ + ...pathSegments + ]) .from(TableName.SecretFolder) .join("parent", "parent.id", `${TableName.SecretFolder}.parentId`) ); }) .from("parent") - .leftJoin(TableName.Environment, `${TableName.Environment}.id`, "parent.envId") .select< (TSecretFolders & { depth: number; @@ -152,13 +155,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: str envName: string; projectId: string; })[] - >( - selectAllTableCols("parent" as TableName.SecretFolder), - db.ref("id").withSchema(TableName.Environment).as("envId"), - db.ref("slug").withSchema(TableName.Environment).as("envSlug"), - db.ref("name").withSchema(TableName.Environment).as("envName"), - db.ref("projectId").withSchema(TableName.Environment) - ); + >(selectAllTableCols("parent" as TableName.SecretFolder)); }; const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: string[]) => @@ -193,9 +190,9 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str // the root folder check is used to avoid last / and also root name in folders depth: db.raw("parent.depth + 1"), path: db.raw( - `CONCAT( CASE - WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' - ELSE CONCAT('/', secret_folders.name) + `CONCAT( CASE + WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' + ELSE CONCAT('/', secret_folders.name) END, parent.path )` ), child: db.raw("COALESCE(parent.child, parent.id)"), @@ -220,19 +217,12 @@ export const secretFolderDALFactory = (db: TDbClient) => { throw new BadRequestError({ message: "Invalid secret path. Only alphanumeric characters, dashes, and underscores are allowed." }); - + const formatedPath = removeTrailingSlash(path); try { - const folder = await sqlFindFolderByPathQuery( - tx || db.replicaNode(), - projectId, - [environment], - removeTrailingSlash(path) - ) - .orderBy("depth", "desc") + const query = sqlFindFolderByPathQuery(tx || db.replicaNode(), projectId, [environment], formatedPath) + .where("path", formatedPath) .first(); - if (folder && folder.path !== removeTrailingSlash(path)) { - return; - } + const folder = await query; if (!folder) return; const { envId: id, envName: name, envSlug: slug, ...el } = folder; return { ...el, envId: id, environment: { id, name, slug } }; @@ -250,22 +240,13 @@ export const secretFolderDALFactory = (db: TDbClient) => { }); try { - const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1; - + const formatedPath = removeTrailingSlash(path); const folders = await sqlFindFolderByPathQuery( tx || db.replicaNode(), projectId, environments, - removeTrailingSlash(path) - ) - .orderBy("depth", "desc") - .where("depth", pathDepth); - - const firstFolder = folders[0]; - - if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) { - return []; - } + formatedPath + ).where("path", removeTrailingSlash(path)); return folders.map((folder) => { const { envId: id, envName: name, envSlug: slug, ...el } = folder; @@ -323,7 +304,6 @@ export const secretFolderDALFactory = (db: TDbClient) => { const findSecretPathByFolderIds = async (projectId: string, folderIds: string[], tx?: Knex) => { try { const folders = await sqlFindSecretPathByFolderId(tx || db.replicaNode(), projectId, folderIds); - // travelling all the way from leaf node to root contains real path const rootFolders = groupBy( folders.filter(({ parentId }) => parentId === null), @@ -486,7 +466,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { db.raw("parents.depth + 1 as depth"), db.raw( `CONCAT( - CASE WHEN parents.path = '/' THEN '' ELSE parents.path END, + CASE WHEN parents.path = '/' THEN '' ELSE parents.path END, CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' ELSE CONCAT('/', secret_folders.name) END )` ), diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 7afbb290f..d4f58a1d4 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -401,7 +401,8 @@ export const secretFolderServiceFactory = ({ orderBy, orderDirection, limit, - offset + offset, + recursive }: TGetFolderDTO) => { // folder list is allowed to be read by anyone // permission to check does user has access @@ -420,6 +421,17 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!parentFolder) return []; + if (recursive) { + const recursiveFolders = await folderDAL.findByEnvsDeep({ parentIds: [parentFolder.id] }); + // remove the parent folder + return recursiveFolders + .filter((folder) => folder.id !== parentFolder.id) + .map((folder) => ({ + ...folder, + relativePath: folder.path + })); + } + const folders = await folderDAL.find( { envId: env.id, diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index bbe4c7223..2a5681b5b 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -45,6 +45,7 @@ export type TGetFolderDTO = { orderDirection?: OrderByDirection; limit?: number; offset?: number; + recursive?: boolean; } & TProjectPermission; export type TGetFolderByIdDTO = { diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index b8e8b2fa0..fdd326b5f 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -469,6 +469,58 @@ export const secretImportServiceFactory = ({ return count; }; + const getProjectImportMultiEnvCount = async ({ + path: secretPath, + environments, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search + }: Omit & { environments: string[] }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + const filteredEnvironments = []; + for (const environment of environments) { + if ( + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ) + ) { + filteredEnvironments.push(environment); + } + } + if (filteredEnvironments.length === 0) { + return 0; + } + + for (const environment of filteredEnvironments) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ); + } + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!folders?.length) + throw new NotFoundError({ + message: `Folder with path '${secretPath}' not found on environments with slugs '${environments.join(", ")}'` + }); + const counts = await Promise.all( + folders.map((folder) => secretImportDAL.getProjectImportCount({ folderId: folder.id, search })) + ); + + return counts.reduce((sum, count) => sum + count, 0); + }; + const getImports = async ({ path: secretPath, environment, @@ -688,6 +740,59 @@ export const secretImportServiceFactory = ({ })); }; + const getImportsMultiEnv = async ({ + path: secretPath, + environments, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search, + limit, + offset + }: Omit & { environments: string[] }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + const filteredEnvironments = []; + for (const environment of environments) { + if ( + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ) + ) { + filteredEnvironments.push(environment); + } + } + if (filteredEnvironments.length === 0) { + return []; + } + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, filteredEnvironments, secretPath); + if (!folders?.length) + throw new NotFoundError({ + message: `Folder with path '${secretPath}' not found on environments with slugs '${environments.join(", ")}'` + }); + + const secImportsArrays = await Promise.all( + folders.map(async (folder) => { + const imports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset }); + return imports.map((importItem) => ({ + ...importItem, + environment: folder.environment.slug + })); + }) + ); + return secImportsArrays.flat(); + }; + return { createImport, updateImport, @@ -698,6 +803,8 @@ export const secretImportServiceFactory = ({ getRawSecretsFromImports, resyncSecretImportReplication, getProjectImportCount, - fnSecretsFromImports + fnSecretsFromImports, + getProjectImportMultiEnvCount, + getImportsMultiEnv }; }; diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts index 44296c306..324b78130 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { @@ -10,6 +11,25 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +const tagFieldCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Period, + CharacterType.Underscore, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Equals, + CharacterType.Plus, + CharacterType.Hyphen, + CharacterType.At +]); + +const pathCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen +]); + const AwsParameterStoreSyncDestinationConfigSchema = z.object({ region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.region), path: z @@ -17,35 +37,54 @@ const AwsParameterStoreSyncDestinationConfigSchema = z.object({ .trim() .min(1, "Parameter Store Path required") .max(2048, "Cannot exceed 2048 characters") - .regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format') + .refine( + (val) => + val.startsWith("/") && + val.endsWith("/") && + val + .split("/") + .filter(Boolean) + .every((el) => pathCharacterValidator(el)), + 'Invalid path - must follow "/example/path/" format' + ) .describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.path) }); const AwsParameterStoreSyncOptionsSchema = z.object({ keyId: z .string() - .regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID") .min(1, "Invalid KMS Key ID") .max(256, "Invalid KMS Key ID") + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Underscore, + CharacterType.Hyphen + ])(val), + "Invalid KMS Key ID" + ) .optional() .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.keyId), tags: z .object({ key: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, - "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" - ) .min(1, "Resource tag key required") - .max(128, "Resource tag key cannot exceed 128 characters"), + .max(128, "Resource tag key cannot exceed 128 characters") + .refine( + (val) => tagFieldCharacterValidator(val), + "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ), value: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + .max(256, "Resource tag value cannot exceed 256 characters") + .refine( + (val) => tagFieldCharacterValidator(val), "Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" ) - .max(256, "Resource tag value cannot exceed 256 characters") }) .array() .max(50) diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts index 5e8ce2bad..e80964721 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -24,12 +25,23 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z .describe(SecretSyncs.DESTINATION_CONFIG.AWS_SECRETS_MANAGER.mappingBehavior), secretName: z .string() - .regex( - /^[a-zA-Z0-9/_+=.@-]+$/, - "Secret name must contain only alphanumeric characters and the characters /_+=.@-" - ) + .min(1, "Secret name is required") .max(256, "Secret name cannot exceed 256 characters") + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.ForwardSlash, + CharacterType.Underscore, + CharacterType.Plus, + CharacterType.Equals, + CharacterType.Period, + CharacterType.At, + CharacterType.Hyphen + ])(val), + "Secret name must contain only alphanumeric characters and the characters /_+=.@-" + ) .describe(SecretSyncs.DESTINATION_CONFIG.AWS_SECRETS_MANAGER.secretName) }) ]) @@ -39,31 +51,54 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z }) ); +const tagFieldCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Period, + CharacterType.Underscore, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Equals, + CharacterType.Plus, + CharacterType.Hyphen, + CharacterType.At +]); + const AwsSecretsManagerSyncOptionsSchema = z.object({ keyId: z .string() - .regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID") .min(1, "Invalid KMS Key ID") .max(256, "Invalid KMS Key ID") + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Underscore, + CharacterType.Hyphen + ])(val), + "Invalid KMS Key ID" + ) .optional() .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.keyId), tags: z .object({ key: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, - "Invalid tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" - ) .min(1, "Tag key required") - .max(128, "Tag key cannot exceed 128 characters"), + .max(128, "Tag key cannot exceed 128 characters") + .refine( + (val) => tagFieldCharacterValidator(val), + "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ), value: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, - "Invalid tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" - ) .max(256, "Tag value cannot exceed 256 characters") + .refine( + (val) => tagFieldCharacterValidator(val), + "Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) }) .array() .max(50) diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index b50d78bb2..12f1f2aff 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -100,7 +100,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur const deleteSecrets: string[] = []; Object.keys(secretMap).forEach((infisicalKey) => { - const hyphenatedKey = infisicalKey.replace(/_/g, "-"); + const hyphenatedKey = infisicalKey.replaceAll("_", "-"); if (!(hyphenatedKey in vaultSecrets)) { // case: secret has been created setSecrets.push({ @@ -117,7 +117,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur }); Object.keys(vaultSecrets).forEach((key) => { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); if (!(underscoredKey in secretMap)) { deleteSecrets.push(key); } @@ -211,7 +211,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur ); for await (const [key] of Object.entries(vaultSecrets)) { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); if (underscoredKey in secretMap) { if (!disabledAzureKeyVaultSecretKeys.includes(underscoredKey)) { @@ -237,7 +237,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur Object.keys(vaultSecrets).forEach((key) => { if (!disabledAzureKeyVaultSecretKeys.includes(key)) { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); secretMap[underscoredKey] = { value: vaultSecrets[key].value }; 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 a5b47ca57..1544e963c 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 @@ -463,7 +463,7 @@ export const recursivelyGetSecretPaths = async ({ const formatMultiValueEnv = (val?: string) => { if (!val) return ""; if (!val.match("\n")) return val; - return `"${val.replace(/\n/g, "\\n")}"`; + return `"${val.replaceAll("\n", "\\n")}"`; }; type TSecretReferenceTraceNode = { diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index aa8cec2bc..2574dc13e 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -207,7 +207,7 @@ export const recursivelyGetSecretPaths = ({ const formatMultiValueEnv = (val?: string) => { if (!val) return ""; if (!val.match("\n")) return val; - return `"${val.replace(/\n/g, "\\n")}"`; + return `"${val.replaceAll("\n", "\\n")}"`; }; type TInterpolateSecretArg = { @@ -218,7 +218,7 @@ type TInterpolateSecretArg = { }; const MAX_SECRET_REFERENCE_DEPTH = 5; -const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; +const INTERPOLATION_SYNTAX_REG = /\${([a-zA-Z0-9-_.]+)}/g; export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderDAL }: TInterpolateSecretArg) => { const secretCache: Record> = {}; const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index 786afb9d2..2f92978b2 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -15,7 +15,9 @@ export enum PostHogEventTypes { UserOrgInvitation = "User Org Invitation", TelemetryInstanceStats = "Self Hosted Instance Stats", SecretRequestCreated = "Secret Request Created", - SecretRequestDeleted = "Secret Request Deleted" + SecretRequestDeleted = "Secret Request Deleted", + SignSshKey = "Sign SSH Key", + IssueSshCreds = "Issue SSH Credentials" } export type TSecretModifiedEvent = { @@ -139,6 +141,24 @@ export type TSecretRequestDeletedEvent = { }; }; +export type TSignSshKeyEvent = { + event: PostHogEventTypes.SignSshKey; + properties: { + certificateTemplateId: string; + principals: string[]; + userAgent?: string; + }; +}; + +export type TIssueSshCredsEvent = { + event: PostHogEventTypes.IssueSshCreds; + properties: { + certificateTemplateId: string; + principals: string[]; + userAgent?: string; + }; +}; + export type TPostHogEvent = { distinctId: string } & ( | TSecretModifiedEvent | TAdminInitEvent @@ -151,4 +171,6 @@ export type TPostHogEvent = { distinctId: string } & ( | TTelemetryInstanceStatsEvent | TSecretRequestCreatedEvent | TSecretRequestDeletedEvent + | TSignSshKeyEvent + | TIssueSshCredsEvent ); diff --git a/cli/go.mod b/cli/go.mod index b2d0c83ad..5f3992e17 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -12,7 +12,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.4.8 + github.com/infisical/go-sdk v0.5.1 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a @@ -34,6 +34,7 @@ require ( golang.org/x/sys v0.31.0 golang.org/x/term v0.30.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -125,7 +126,6 @@ require ( google.golang.org/grpc v1.64.1 // indirect google.golang.org/protobuf v1.36.1 // indirect gopkg.in/ini.v1 v1.62.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/cli/go.sum b/cli/go.sum index 5f1f369bb..da221fd6f 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -277,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.4.8 h1:aphRnaauC5//PkP1ZbY9RSK2RiT1LjPS5o4CbX0x5OQ= -github.com/infisical/go-sdk v0.4.8/go.mod h1:bMO9xSaBeXkDBhTIM4FkkREAfw2V8mv5Bm7lvo4+uDk= +github.com/infisical/go-sdk v0.5.1 h1:bl0D4A6CmvfL8RwEQTcZh39nsxC6q3HSs76/4J8grWY= +github.com/infisical/go-sdk v0.5.1/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= @@ -858,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= \ No newline at end of file +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index f4fe94a6e..b8fc6ed7b 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -29,7 +29,6 @@ import ( "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/spf13/cobra" ) @@ -514,7 +513,10 @@ type NewAgentMangerOptions struct { } func NewAgentManager(options NewAgentMangerOptions) *AgentManager { - + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } return &AgentManager{ filePaths: options.FileDeposits, templates: options.Templates, @@ -529,6 +531,7 @@ func NewAgentManager(options NewAgentMangerOptions) *AgentManager { SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, // ? Should we perhaps use a different user agent for the Agent for better analytics? AutoTokenRefresh: false, + CustomHeaders: customHeaders, }), } @@ -716,7 +719,11 @@ func (tm *AgentManager) FetchNewAccessToken() error { // Refreshes the existing access token func (tm *AgentManager) RefreshAccessToken() error { - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return err + } + httpClient.SetRetryCount(10000). SetRetryMaxWaitTime(20 * time.Second). SetRetryWaitTime(5 * time.Second) diff --git a/cli/packages/cmd/bootstrap.go b/cli/packages/cmd/bootstrap.go index 008debbac..4582cb001 100644 --- a/cli/packages/cmd/bootstrap.go +++ b/cli/packages/cmd/bootstrap.go @@ -10,7 +10,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -70,8 +69,12 @@ var bootstrapCmd = &cobra.Command{ return } - httpClient := resty.New(). - SetHeader("Accept", "application/json") + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + log.Error().Msgf("Failed to get resty client with custom headers: %v", err) + return + } + httpClient.SetHeader("Accept", "application/json") bootstrapResponse, err := api.CallBootstrapInstance(httpClient, api.BootstrapInstanceRequest{ Domain: util.AppendAPIEndpoint(domain), diff --git a/cli/packages/cmd/dynamic_secrets.go b/cli/packages/cmd/dynamic_secrets.go index 9e0ea1334..60f356185 100644 --- a/cli/packages/cmd/dynamic_secrets.go +++ b/cli/packages/cmd/dynamic_secrets.go @@ -14,7 +14,6 @@ import ( // "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" // "github.com/Infisical/infisical-merge/packages/visualize" - "github.com/go-resty/resty/v2" "github.com/posthog/posthog-go" "github.com/spf13/cobra" @@ -56,7 +55,10 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { } var infisicalToken string - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -85,10 +87,16 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { httpClient.SetAuthToken(infisicalToken) + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) @@ -164,7 +172,10 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } var infisicalToken string - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -193,10 +204,16 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { httpClient.SetAuthToken(infisicalToken) + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) @@ -286,7 +303,10 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } var infisicalToken string - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -315,10 +335,16 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { httpClient.SetAuthToken(infisicalToken) + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) @@ -384,7 +410,10 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } var infisicalToken string - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -413,10 +442,16 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { httpClient.SetAuthToken(infisicalToken) + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) @@ -481,7 +516,10 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } var infisicalToken string - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -510,10 +548,16 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { httpClient.SetAuthToken(infisicalToken) + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index 81baf3910..51565b6fd 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -18,10 +18,10 @@ import ( ) var gatewayCmd = &cobra.Command{ - 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= + 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, @@ -148,6 +148,28 @@ var gatewayInstallCmd = &cobra.Command{ }, } +var gatewayUninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall and remove systemd service for the gateway (requires sudo)", + Long: "Uninstall and remove systemd service for the gateway. Must be run with sudo on Linux.", + Example: "sudo infisical gateway uninstall", + 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")) + } + + if err := gateway.UninstallGatewaySystemdService(); err != nil { + util.HandleError(err, "Failed to uninstall systemd service") + } + }, +} + var gatewayRelayCmd = &cobra.Command{ Example: `infisical gateway relay`, Short: "Used to run infisical gateway relay", @@ -183,6 +205,7 @@ func init() { gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path") gatewayCmd.AddCommand(gatewayInstallCmd) + gatewayCmd.AddCommand(gatewayUninstallCmd) gatewayCmd.AddCommand(gatewayRelayCmd) rootCmd.AddCommand(gatewayCmd) } diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index df6bfcc60..e10a11c06 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -10,7 +10,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" @@ -50,7 +49,10 @@ var initCmd = &cobra.Command{ util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") } - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) organizationResponse, err := api.CallGetAllOrganizations(httpClient) @@ -81,7 +83,10 @@ var initCmd = &cobra.Command{ for i < 6 { mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod) - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } httpClient.SetAuthToken(tokenResponse.Token) verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ Email: userCreds.UserCredentials.Email, diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 81bb0c754..b1c868d8c 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -27,7 +27,6 @@ import ( "github.com/Infisical/infisical-merge/packages/srp" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" - "github.com/go-resty/resty/v2" "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" "github.com/rs/cors" @@ -178,10 +177,16 @@ var loginCmd = &cobra.Command{ return } + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) loginMethod, err := cmd.Flags().GetString("method") @@ -359,7 +364,10 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) { for i < 6 { mfaVerifyCode := askForMFACode("email") - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } httpClient.SetAuthToken(loginTwoResponse.Token) verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ Email: email, @@ -726,7 +734,10 @@ func askForLoginCredentials() (email string, password string, err error) { func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2Response, *api.GetLoginTwoV2Response, error) { log.Debug().Msg(fmt.Sprint("getFreshUserCredentials: ", "email", email, "password: ", password)) - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return nil, nil, err + } httpClient.SetRetryCount(5) params := srp.GetParams(4096) @@ -776,7 +787,10 @@ func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2R func GetJwtTokenWithOrganizationId(oldJwtToken string, email string) string { log.Debug().Msg(fmt.Sprint("GetJwtTokenWithOrganizationId: ", "oldJwtToken", oldJwtToken)) - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } httpClient.SetAuthToken(oldJwtToken) organizationResponse, err := api.CallGetAllOrganizations(httpClient) @@ -811,7 +825,10 @@ func GetJwtTokenWithOrganizationId(oldJwtToken string, email string) string { for i < 6 { mfaVerifyCode := askForMFACode(selectedOrgRes.MfaMethod) - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } httpClient.SetAuthToken(selectedOrgRes.Token) verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ Email: email, @@ -913,7 +930,14 @@ func askToPasteJwtToken(success chan models.UserCredentials, failure chan error) } // verify JTW - httpClient := resty.New(). + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + failure <- err + fmt.Println("Error getting resty client with custom headers", err) + os.Exit(1) + } + + httpClient. SetAuthToken(userCredentials.JTWToken). SetHeader("Accept", "application/json") diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 8e9f5d63b..fdee3e7c0 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "os" "regexp" "sort" "strings" @@ -13,7 +14,6 @@ import ( "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/packages/visualize" - "github.com/go-resty/resty/v2" "github.com/posthog/posthog-go" "github.com/spf13/cobra" ) @@ -139,7 +139,7 @@ var secretsGenerateExampleEnvCmd = &cobra.Command{ } var secretsSetCmd = &cobra.Command{ - Example: `secrets set ..."`, + Example: `secrets set ..."`, Short: "Used set secrets", Use: "set [secrets]", DisableFlagsInUseLine: true, @@ -185,6 +185,30 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse secret type") } + processedArgs := []string{} + for _, arg := range args { + splitKeyValue := strings.SplitN(arg, "=", 2) + if len(splitKeyValue) != 2 { + util.HandleError(fmt.Errorf("invalid argument format: %s. Expected format: key=value or key=@filepath", arg), "") + } + + key := splitKeyValue[0] + value := splitKeyValue[1] + + if strings.HasPrefix(value, "\\@") { + value = "@" + value[2:] + } else if strings.HasPrefix(value, "@") { + filePath := strings.TrimPrefix(value, "@") + content, err := os.ReadFile(filePath) + if err != nil { + util.HandleError(err, fmt.Sprintf("Unable to read file %s", filePath)) + } + value = string(content) + } + + processedArgs = append(processedArgs, fmt.Sprintf("%s=%s", key, value)) + } + file, err := cmd.Flags().GetString("file") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -216,7 +240,7 @@ var secretsSetCmd = &cobra.Command{ util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") } - secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ + secretOperations, err = util.SetRawSecrets(processedArgs, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ Type: "", Token: loggedInUserDetails.UserCredentials.JTWToken, }, file) @@ -274,8 +298,12 @@ var secretsDeleteCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - httpClient := resty.New(). - SetHeader("Accept", "application/json") + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + + httpClient.SetHeader("Accept", "application/json") if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index d7c1f1e26..a3d10fc91 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -315,10 +315,16 @@ func issueCredentials(cmd *cobra.Command, args []string) { } } + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) @@ -555,10 +561,16 @@ func signKey(cmd *cobra.Command, args []string) { signedKeyPath = outFilePath } + customHeaders, err := util.GetInfisicalCustomHeadersMap() + if err != nil { + util.HandleError(err, "Unable to get custom headers") + } + infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, AutoTokenRefresh: false, + CustomHeaders: customHeaders, }) infisicalClient.Auth().SetAccessToken(infisicalToken) diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go index 531e622e9..386a7eda5 100644 --- a/cli/packages/cmd/tokens.go +++ b/cli/packages/cmd/tokens.go @@ -13,7 +13,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/util" - "github.com/go-resty/resty/v2" "github.com/spf13/cobra" ) @@ -136,7 +135,11 @@ var tokensCreateCmd = &cobra.Command{ } // make a call to the api to save the encrypted symmetric key details - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "Unable to get resty client with custom headers") + } + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). SetHeader("Accept", "application/json") diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index 7a5403a12..d0a25ca9c 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -13,6 +13,7 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/systemd" + "github.com/Infisical/infisical-merge/packages/util" "github.com/go-resty/resty/v2" "github.com/pion/dtls/v3" "github.com/pion/logging" @@ -40,7 +41,11 @@ type Gateway struct { } func NewGateway(identityToken string) (Gateway, error) { - httpClient := resty.New() + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return Gateway{}, fmt.Errorf("unable to get client with custom headers [err=%v]", err) + } + httpClient.SetAuthToken(identityToken) return Gateway{ @@ -89,7 +94,7 @@ func (g *Gateway) ConnectWithRelay() error { turnClientCfg.Conn = turn.NewSTUNConn(conn) } else { log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort) - conn, err := net.ListenPacket("udp4", turnAddr.String()) + conn, err := net.ListenPacket("udp4", "0.0.0.0:0") if err != nil { return fmt.Errorf("Failed to connect with relay server: %w", err) } @@ -342,7 +347,9 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) e case <-ticker.C: log.Debug().Msg("Performing relay connection health check") err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp) - if err != nil && !strings.Contains(err.Error(), "tls:") { + // try again error message from server happens to avoid congestion + // https://github.com/pion/turn/blob/master/internal/client/udp_conn.go#L382 + if err != nil && !strings.Contains(err.Error(), "try again") { failures++ log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions") if failures >= maxFailures { @@ -351,6 +358,7 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) e } continue } + failures = 0 // reset } } }() diff --git a/cli/packages/gateway/systemd.go b/cli/packages/gateway/systemd.go index 601cb9e90..ac6663dff 100644 --- a/cli/packages/gateway/systemd.go +++ b/cli/packages/gateway/systemd.go @@ -15,7 +15,8 @@ Description=Infisical Gateway Service After=network.target [Service] -Type=simple +Type=notify +NotifyAccess=all EnvironmentFile=/etc/infisical/gateway.conf ExecStart=infisical gateway Restart=on-failure @@ -50,8 +51,6 @@ func InstallGatewaySystemdService(token string, domain string) error { 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") @@ -60,11 +59,6 @@ func InstallGatewaySystemdService(token string, domain string) error { } 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) } @@ -80,3 +74,48 @@ func InstallGatewaySystemdService(token string, domain string) error { return nil } + +func UninstallGatewaySystemdService() error { + if runtime.GOOS != "linux" { + log.Info().Msg("Skipping systemd service uninstallation - not on Linux") + return nil + } + + if os.Geteuid() != 0 { + log.Info().Msg("Skipping systemd service uninstallation - not running as root/sudo") + return nil + } + + // Stop the service if it's running + stopCmd := exec.Command("systemctl", "stop", "infisical-gateway") + if err := stopCmd.Run(); err != nil { + log.Warn().Msgf("Failed to stop service: %v", err) + } + + // Disable the service + disableCmd := exec.Command("systemctl", "disable", "infisical-gateway") + if err := disableCmd.Run(); err != nil { + log.Warn().Msgf("Failed to disable service: %v", err) + } + + // Remove the service file + servicePath := "/etc/systemd/system/infisical-gateway.service" + if err := os.Remove(servicePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove systemd service file: %v", err) + } + + // Remove the configuration file + configPath := "/etc/infisical/gateway.conf" + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove config file: %v", err) + } + + // Reload systemd to apply changes + 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 uninstalled Infisical Gateway systemd service") + return nil +} diff --git a/cli/packages/util/common.go b/cli/packages/util/common.go index 55907da9d..07618ae87 100644 --- a/cli/packages/util/common.go +++ b/cli/packages/util/common.go @@ -4,8 +4,11 @@ import ( "fmt" "net/http" "os" + "strings" + "unicode" "github.com/Infisical/infisical-merge/packages/config" + "github.com/go-resty/resty/v2" ) func GetHomeDir() (string, error) { @@ -27,3 +30,88 @@ func ValidateInfisicalAPIConnection() (ok bool) { _, err := http.Get(fmt.Sprintf("%v/status", config.INFISICAL_URL)) return err == nil } + +func GetRestyClientWithCustomHeaders() (*resty.Client, error) { + httpClient := resty.New() + customHeaders := os.Getenv("INFISICAL_CUSTOM_HEADERS") + if customHeaders != "" { + headers, err := GetInfisicalCustomHeadersMap() + if err != nil { + return nil, err + } + + httpClient.SetHeaders(headers) + } + return httpClient, nil +} + +func GetInfisicalCustomHeadersMap() (map[string]string, error) { + customHeaders := os.Getenv("INFISICAL_CUSTOM_HEADERS") + if customHeaders == "" { + return nil, nil + } + + headers := map[string]string{} + + pos := 0 + for pos < len(customHeaders) { + for pos < len(customHeaders) && unicode.IsSpace(rune(customHeaders[pos])) { + pos++ + } + + if pos >= len(customHeaders) { + break + } + + keyStart := pos + for pos < len(customHeaders) && customHeaders[pos] != '=' && !unicode.IsSpace(rune(customHeaders[pos])) { + pos++ + } + + if pos >= len(customHeaders) || customHeaders[pos] != '=' { + return nil, fmt.Errorf("invalid custom header format. Expected \"headerKey1=value1 headerKey2=value2 ....\" but got %v", customHeaders) + } + + key := customHeaders[keyStart:pos] + pos++ + + for pos < len(customHeaders) && unicode.IsSpace(rune(customHeaders[pos])) { + pos++ + } + + var value string + + if pos < len(customHeaders) { + if customHeaders[pos] == '"' || customHeaders[pos] == '\'' { + quoteChar := customHeaders[pos] + pos++ + valueStart := pos + + for pos < len(customHeaders) && + (customHeaders[pos] != quoteChar || + (pos > 0 && customHeaders[pos-1] == '\\')) { + pos++ + } + + if pos < len(customHeaders) { + value = customHeaders[valueStart:pos] + pos++ + } else { + value = customHeaders[valueStart:] + } + } else { + valueStart := pos + for pos < len(customHeaders) && !unicode.IsSpace(rune(customHeaders[pos])) { + pos++ + } + value = customHeaders[valueStart:pos] + } + } + + if key != "" && !strings.EqualFold(key, "User-Agent") && !strings.EqualFold(key, "Accept") { + headers[key] = value + } + } + + return headers, nil +} diff --git a/cli/packages/util/config.go b/cli/packages/util/config.go index 02030e1fa..8d44c84d1 100644 --- a/cli/packages/util/config.go +++ b/cli/packages/util/config.go @@ -56,6 +56,7 @@ func WriteInitalConfig(userCredentials *models.UserCredentials) error { LoggedInUsers: existingConfigFile.LoggedInUsers, VaultBackendType: existingConfigFile.VaultBackendType, VaultBackendPassphrase: existingConfigFile.VaultBackendPassphrase, + Domains: existingConfigFile.Domains, } configFileMarshalled, err := json.Marshal(configFile) diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index 03722dc41..cd73e47ca 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -9,7 +9,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" - "github.com/go-resty/resty/v2" "github.com/zalando/go-keyring" ) @@ -85,7 +84,12 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails } // check to to see if the JWT is still valid - httpClient := resty.New(). + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return LoggedInUserDetails{}, fmt.Errorf("getCurrentLoggedInUserDetails: unable to get client with custom headers [err=%s]", err) + } + + httpClient. SetAuthToken(userCreds.JTWToken). SetHeader("Accept", "application/json") diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 4715c71c3..6bba05842 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -6,7 +6,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" ) @@ -65,7 +64,11 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder func GetFoldersViaJTW(JTWToken string, workspaceId string, environmentName string, foldersPath string) ([]models.SingleFolder, error) { // set up resty client - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, err + } + httpClient.SetAuthToken(JTWToken). SetHeader("Accept", "application/json") @@ -100,7 +103,10 @@ func GetFoldersViaServiceToken(fullServiceToken string, workspaceId string, envi serviceToken := fmt.Sprintf("%v.%v.%v", serviceTokenParts[0], serviceTokenParts[1], serviceTokenParts[2]) - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err) + } httpClient.SetAuthToken(serviceToken). SetHeader("Accept", "application/json") @@ -143,7 +149,11 @@ func GetFoldersViaServiceToken(fullServiceToken string, workspaceId string, envi } func GetFoldersViaMachineIdentity(accessToken string, workspaceId string, envSlug string, foldersPath string) ([]models.SingleFolder, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, err + } + httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") @@ -191,9 +201,12 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er } // set up resty client - httpClient := resty.New() - httpClient. - SetAuthToken(params.InfisicalToken). + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return models.SingleFolder{}, err + } + + httpClient.SetAuthToken(params.InfisicalToken). SetHeader("Accept", "application/json"). SetHeader("Content-Type", "application/json") @@ -238,9 +251,12 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, } // set up resty client - httpClient := resty.New() - httpClient. - SetAuthToken(params.InfisicalToken). + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, err + } + + httpClient.SetAuthToken(params.InfisicalToken). SetHeader("Accept", "application/json"). SetHeader("Content-Type", "application/json") diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 11a1e3e0a..346122a64 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -16,7 +16,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" - "github.com/go-resty/resty/v2" "github.com/spf13/cobra" ) @@ -120,7 +119,11 @@ func GetInfisicalToken(cmd *cobra.Command) (token *models.TokenDetails, err erro } func UniversalAuthLogin(clientId string, clientSecret string) (api.UniversalAuthLoginResponse, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return api.UniversalAuthLoginResponse{}, err + } + httpClient.SetRetryCount(10000). SetRetryMaxWaitTime(20 * time.Second). SetRetryWaitTime(5 * time.Second) @@ -135,7 +138,11 @@ func UniversalAuthLogin(clientId string, clientSecret string) (api.UniversalAuth func RenewMachineIdentityAccessToken(accessToken string) (string, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return "", err + } + httpClient.SetRetryCount(10000). SetRetryMaxWaitTime(20 * time.Second). SetRetryWaitTime(5 * time.Second) @@ -245,8 +252,9 @@ func getCurrentBranch() (string, error) { } func AppendAPIEndpoint(address string) string { + // if it's empty return as it is // Ensure the address does not already end with "/api" - if strings.HasSuffix(address, "/api") { + if address == "" || strings.HasSuffix(address, "/api") { return address } diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 02079d656..53f97f4c9 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -14,7 +14,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/models" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" "gopkg.in/yaml.v3" @@ -28,7 +27,10 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str serviceToken := fmt.Sprintf("%v.%v.%v", serviceTokenParts[0], serviceTokenParts[1], serviceTokenParts[2]) - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err) + } httpClient.SetAuthToken(serviceToken). SetHeader("Accept", "application/json") @@ -79,7 +81,11 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str } func GetPlainTextSecretsV3(accessToken string, workspaceId string, environmentName string, secretsPath string, includeImports bool, recursive bool, tagSlugs string, expandSecretReferences bool) (models.PlaintextSecretResult, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return models.PlaintextSecretResult{}, err + } + httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") @@ -122,7 +128,11 @@ func GetPlainTextSecretsV3(accessToken string, workspaceId string, environmentNa } func GetSinglePlainTextSecretByNameV3(accessToken string, workspaceId string, environmentName string, secretsPath string, secretName string) (models.SingleEnvironmentVariable, string, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return models.SingleEnvironmentVariable{}, "", err + } + httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") @@ -153,7 +163,11 @@ func GetSinglePlainTextSecretByNameV3(accessToken string, workspaceId string, en } func CreateDynamicSecretLease(accessToken string, projectSlug string, environmentName string, secretsPath string, slug string, ttl string) (models.DynamicSecretLease, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return models.DynamicSecretLease{}, err + } + httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") @@ -525,7 +539,11 @@ func GetEnvelopmentBasedOnGitBranch(workspaceFile models.WorkspaceConfigFile) st } func GetPlainTextWorkspaceKey(authenticationToken string, receiverPrivateKey string, workspaceId string) ([]byte, error) { - httpClient := resty.New() + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: unable to get client with custom headers [err=%v]", err) + } + httpClient.SetAuthToken(authenticationToken). SetHeader("Accept", "application/json") @@ -672,9 +690,12 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin getAllEnvironmentVariablesRequest.InfisicalToken = tokenDetails.Token } - httpClient := resty.New(). - SetAuthToken(tokenDetails.Token). - SetHeader("Accept", "application/json") + httpClient, err := GetRestyClientWithCustomHeaders() + if err != nil { + return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err) + } + + httpClient.SetHeader("Accept", "application/json") // pull current secrets secrets, err := GetAllEnvironmentVariables(getAllEnvironmentVariablesRequest, "") diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index d1f4baa9f..1bb575ebd 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -186,12 +186,28 @@ This command allows you to set or update secrets in your environment. If the sec If the secret key does not exist, a new secret will be created using both the key and value provided. ```bash -$ infisical secrets set ... +$ infisical secrets set ... ## Example -$ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe +$ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe SECRET_PEM_KEY=@secret.pem ``` + + When setting secret values: + - Use `secretName=@path/to/file` to load the secret value from a file + - Use `secretName=\@value` if you need the literal '@' character at the beginning of your value + + Example: + + ```bash + # Set a secret with the value loaded from a certificate file + $ secrets set CERTIFICATE=@/path/to/certificate.pem + + # Set a secret with the literal value "@example.com" + $ secrets set email="\@example.com" + ``` + + ### Flags diff --git a/docs/cli/faq.mdx b/docs/cli/faq.mdx index 47e89a48f..02c539e61 100644 --- a/docs/cli/faq.mdx +++ b/docs/cli/faq.mdx @@ -33,3 +33,28 @@ Yes. This is simply a configuration file and contains no sensitive data. https://app.infisical.com/project//settings ``` + + + + The Infisical CLI supports custom HTTP headers for requests to servers that require additional authentication. Set these headers using the `INFISICAL_CUSTOM_HEADERS` environment variable: + + ```bash + export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret" + ``` + + After setting this environment variable, run your Infisical commands as usual. + + + + + Custom headers are necessary when your Infisical server is protected by services like Cloudflare Access or other reverse proxies that require specific authentication headers. Without this feature, you would need to implement security workarounds that might compromise your security posture. + + + + + Custom headers should be specified in the format `headername1=headervalue1 headername2=headervalue2`, with spaces separating each header-value pair. For example: + + ```bash + export INFISICAL_CUSTOM_HEADERS="Header1=value1 Header2=value2 Header3=value3" + ``` + \ No newline at end of file diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index d5b7acb4a..a77a648d0 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -120,6 +120,22 @@ The CLI is designed for a variety of secret management applications ranging from + + ## Custom Request Headers + + The Infisical CLI supports custom HTTP headers for requests to servers protected by authentication services such as Cloudflare Access. Configure these headers using the `INFISICAL_CUSTOM_HEADERS` environment variable: + + ```bash + # Syntax: headername1=headervalue1 headername2=headervalue2 + export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret" + + # Execute Infisical commands after setting the environment variable + infisical secrets ls + ``` + + This functionality enables secure interaction with Infisical instances that require specific authentication headers. + + ## History Your terminal keeps a history with the commands you run. When you create Infisical secrets directly from your terminal, they'll stay there for a while. diff --git a/docs/documentation/platform/audit-logs.mdx b/docs/documentation/platform/audit-logs.mdx index 594c1f707..ea6b09fa3 100644 --- a/docs/documentation/platform/audit-logs.mdx +++ b/docs/documentation/platform/audit-logs.mdx @@ -1,6 +1,6 @@ --- title: "Overview" -description: "Track evert event action performed within Infisical projects." +description: "Track all actions performed within Infisical" --- @@ -9,20 +9,76 @@ description: "Track evert event action performed within Infisical projects." If you're using Infisical Cloud, then it is available under the **Pro**, and **Enterprise Tier** with varying retention periods. If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. + Infisical provides audit logs for security and compliance teams to monitor information access. With the Audit Log functionality, teams can: + - **Track** 40+ different events; - **Filter** audit logs by event, actor, source, date or any combination of these filters; - **Inspect** extensive metadata in the event of any suspicious activity or incident review. ![Audit logs](../../images/platform/audit-logs/audit-logs-table.png) +## Audit Log Structure + Each log contains the following data: -- **Event**: The underlying action such as create, list, read, update, or delete secret(s). -- **Actor**: The entity responsible for performing or causing the event; this can be a user or service. -- **Timestamp**: The date and time at which point the event occurred. -- **Source** (User agent + IP): The software (user agent) and network address (IP) from which the event was initiated. -- **Metadata**: Additional data to provide context for each event. For example, this could be the path at which a secret was fetched from etc. +| Field | Type | Description | Purpose | +| ------------------------- | -------- | --------------------------------------------------------- | ------------------------------------------------------------- | +| **event** | Object | Contains details about the action performed | Captures what happened | +| event.type | String | The specific action that occurred (e.g., "create-secret") | Identifies the exact operation | +| event.metadata | Object | Context-specific details about the event | Provides detailed information relevant to the specific action | +| **actor** | Object | Information about who performed the action | Identifies the responsible entity | +| actor.type | String | Category of actor (user, service, identity, etc.) | Distinguishes between human and non-human actors | +| actor.metadata | Object | Details about the specific actor | Provides identity information | +| actor.metadata.userId | String | Unique identifier for user actors | Links to specific user account | +| actor.metadata.email | String | Email address for user actors | Email of the executing user | +| actor.metadata.username | String | Username for user actors | Username of the executing user | +| actor.metadata.serviceId | String | Identifier for service actors | ID of specific service token | +| actor.metadata.identityId | String | Identifier for identity actors | ID to specific identity | +| actor.metadata.permission | Object | Permission context for the action | Shows permission template data when action was performed | +| **orgId** | String | Organization identifier | Indicates which organization the action occurred in | +| **projectId** | String | Project identifier | Indicates which project the action affected | +| **ipAddress** | String | Source IP address | Shows where the request originated from | +| **userAgent** | String | Client application information | Identifies browser or application used | +| **userAgentType** | String | Category of client (web, CLI, SDK, etc.) | Classifies the access method | +| **timestamp** | DateTime | When the action occurred | Records the exact time of the event | + + +```json +{ + "id": "[UUID]", + "ipAddress": "[IP_ADDRESS]", + "userAgent": "[USER_AGENT_STRING]", + "userAgentType": "web", + "expiresAt": "[TIMESTAMP]", + "createdAt": "[TIMESTAMP]", + "updatedAt": "[TIMESTAMP]", + "orgId": "[ORGANIZATION_UUID]", + "projectId": "[PROJECT_UUID]", + "projectName": "[PROJECT_NAME]", + "event": { + "type": "get-secrets", + "metadata": { + "secretPath": "[PATH]", + "environment": "[ENVIRONMENT_NAME]", + "numberOfSecrets": [NUMBER] + } + }, + "actor": { + "type": "user", + "metadata": { + "email": "[EMAIL]", + "userId": "[USER_UUID]", + "username": "[USERNAME]", + "permission": { + "metadata": {}, + "auth": {} + } + } + } +} +``` + diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index b4d7cc1ac..58069f09e 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -37,7 +37,8 @@ then Infisical returns a short-lived access token that can be used to make authe To be more specific: 1. The application deployed on Kubernetes retrieves its [service account credential](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#opt-out-of-api-credential-automounting) that is a JWT token at the `/var/run/secrets/kubernetes.io/serviceaccount/token` pod path. -2. The application sends the JWT token to Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint after which Infisical forwards the JWT token to the Kubernetes API Server at the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) for verification and to obtain the service account information associated with the JWT token. Infisical is able to authenticate and interact with the TokenReview API by using a long-lived service account JWT token itself (referred to onward as the token reviewer JWT token). +2. The application sends the JWT token to Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint after which Infisical forwards the JWT token to the Kubernetes API Server at the TokenReview API for verification and to obtain the service account information associated with the JWT token. +Infisical is able to authenticate and interact with the TokenReview API by using either the long lived JWT token set while configuring this authentication method or by using the incoming token itself. The JWT token mentioned in this context is referred as the token reviewer JWT token. 3. Infisical checks the service account properties against set criteria such **Allowed Service Account Names** and **Allowed Namespaces**. 4. If all is well, Infisical returns a short-lived access token that the application can use to make authenticated requests to the Infisical API. @@ -53,6 +54,12 @@ In the following steps, we explore how to create and use identities for your app + + + + + **When to use this option**: Choose this approach when you want centralized authentication management. Only one service account needs special permissions, and your application service accounts remain unchanged. + 1.1. Start by creating a service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. ```yaml infisical-service-account.yaml @@ -61,7 +68,6 @@ In the following steps, we explore how to create and use identities for your app metadata: name: infisical-auth namespace: default - ``` ``` @@ -121,7 +127,40 @@ In the following steps, we explore how to create and use identities for your app Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. - + + + + + **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. + + + The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. + + For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: + + ```yaml client-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-client-binding-[your-app-name] + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: [your-app-service-account] + namespace: [your-app-namespace] + ``` + + ``` + kubectl apply -f client-role-binding.yaml + ``` + + When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. @@ -151,7 +190,8 @@ In the following steps, we explore how to create and use identities for your app Here's some more guidance on each field: - Kubernetes Host / Base Kubernetes API URL: The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running `kubectl cluster-info`. - - Token Reviewer JWT: A long-lived service account JWT token for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) to validate other service account JWT tokens submitted by applications/pods. This is the JWT token obtained from step 1.5. + - Token Reviewer JWT: A long-lived service account JWT token for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) to validate other service account JWT tokens submitted by applications/pods. This is the JWT token obtained from step 1.5(Reviewer Tab). If omitted, the client's own JWT will be used instead, which requires the client to have the `system:auth-delegator` ClusterRole binding. + This is shown in step 1, option 2. - Allowed Service Account Names: A comma-separated list of trusted service account names that are allowed to authenticate with Infisical. - Allowed Namespaces: A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical. - Allowed Audience: An optional audience claim that the service account JWT token must have to authenticate with Infisical. @@ -176,18 +216,19 @@ In the following steps, we explore how to create and use identities for your app To access the Infisical API as the identity, you should first make sure that the pod running your application is bound to a service account specified in the **Allowed Service Account Names** field of the identity's Kubernetes Auth authentication method configuration in step 2. - + Once bound, the pod will receive automatically mounted service account credentials that is a JWT token at the `/var/run/secrets/kubernetes.io/serviceaccount/token` path. This token should be used to authenticate with Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint. - + For information on how to configure sevice accounts for pods, refer to the guide [here](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/). - + We provide a code example below of how you might retrieve the JWT token and use it to authenticate with Infisical to gain access to the [Infisical API](/api-reference/overview/introduction). + + > The shown example uses Node.js but you can use any other language to retrieve the service account JWT token and use it to authenticate with Infisical. - - ```javascript + + ```javascript const fs = require("fs"); try { const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"; @@ -237,15 +278,16 @@ In the following steps, we explore how to create and use identities for your app There are a few reasons for why this might happen: - - - The access token has expired. - - The identity is insufficently permissioned to interact with the resources you wish to access. - - The client access token is being used from an untrusted IP. + +- The access token has expired. +- The identity is insufficently permissioned to interact with the resources you wish to access. +- The client access token is being used from an untrusted IP. + - A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. - - In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. +A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. + +In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. A token can be renewed any number of times where each call to renew it can extend the token's lifetime by increments of the access token's TTL. Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation. diff --git a/docs/images/platform/audit-logs/audit-logs-table.png b/docs/images/platform/audit-logs/audit-logs-table.png index a24b02bc0..ff8168aa5 100644 Binary files a/docs/images/platform/audit-logs/audit-logs-table.png and b/docs/images/platform/audit-logs/audit-logs-table.png differ diff --git a/docs/integrations/frameworks/terraform.mdx b/docs/integrations/frameworks/terraform.mdx index 40a1a19b5..898a77b00 100644 --- a/docs/integrations/frameworks/terraform.mdx +++ b/docs/integrations/frameworks/terraform.mdx @@ -6,7 +6,7 @@ description: "Learn how to fetch secrets from Infisical with Terraform using bot This guide demonstrates how to use Infisical to manage secrets in your Terraform infrastructure code, supporting both traditional data sources and ephemeral resources for enhanced security. It uses: - Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets -- The [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest) to fetch secrets for your infrastructure +- The [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) to fetch secrets for your infrastructure ## Prerequisites diff --git a/docs/integrations/platforms/apache-airflow.mdx b/docs/integrations/platforms/apache-airflow.mdx new file mode 100644 index 000000000..db4bd3eb2 --- /dev/null +++ b/docs/integrations/platforms/apache-airflow.mdx @@ -0,0 +1,5 @@ +--- +title: "Apache Airflow" +description: "Learn how to use Infisical as your custom secrets backend in Apache Airflow." +url: "https://github.com/Infisical/airflow-provider-infisical?tab=readme-ov-file#airflow-infisical-provider" +--- diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 5cedd5c3e..82e60af7f 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -124,7 +124,7 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - The `leaseTTL` is a string-formatted duration that defines the time the lease should last for the dynamic secret. + The `leaseTTL` is a string-formatted duration that defines the time the lease should last for the dynamic secret. The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. diff --git a/docs/internals/permissions.mdx b/docs/internals/permissions.mdx deleted file mode 100644 index 737d3b71e..000000000 --- a/docs/internals/permissions.mdx +++ /dev/null @@ -1,213 +0,0 @@ ---- -title: "Permissions" -description: "Infisical's permissions system provides granular access control." ---- - -## Overview - -The Infisical permissions system is based on a role-based access control (RBAC) model. The system allows you to define roles and assign them to users and machines. Each role has a set of permissions that define what actions a user can perform. - -Permissions are built on a subject-action-object model. The subject is the resource the permission is being applied to, the action is what the permission allows. -An example of a subject/action combination would be `secrets/read`. This permission allows the subject to read secrets. - -Refer to the table below for a list of subjects and the actions they support. - -## Subjects and Actions - - - - - - Not all actions are applicable to all subjects. As an example, the - `secrets-rollback` subject only supports `read`, and `create` as actions. - While `secrets` support `read`, `create`, `edit`, `delete`. - - -| Subject | Actions | -| ------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `role` | `read`, `create`, `edit`, `delete` | -| `member` | `read`, `create`, `edit`, `delete` | -| `groups` | `read`, `create`, `edit`, `delete` | -| `settings` | `read`, `create`, `edit`, `delete` | -| `integrations` | `read`, `create`, `edit`, `delete` | -| `webhooks` | `read`, `create`, `edit`, `delete` | -| `service-tokens` | `read`, `create`, `edit`, `delete` | -| `environments` | `read`, `create`, `edit`, `delete` | -| `tags` | `read`, `create`, `edit`, `delete` | -| `audit-logs` | `read`, `create`, `edit`, `delete` | -| `ip-allowlist` | `read`, `create`, `edit`, `delete` | -| `workspace` | `edit`, `delete` | -| `secrets` | `read`, `create`, `edit`, `delete` | -| `secret-folders` | `read`, `create`, `edit`, `delete` | -| `secret-imports` | `read`, `create`, `edit`, `delete` | -| `dynamic-secrets` | `read-root-credential`, `create-root-credential`, `edit-root-credential`, `delete-root-credential`, `lease` | -| `secret-rollback` | `read`, `create` | -| `secret-approval` | `read`, `create`, `edit`, `delete` | -| `secret-rotation` | `read`, `create`, `edit`, `delete` | -| `identity` | `read`, `create`, `edit`, `delete` | -| `certificate-authorities` | `read`, `create`, `edit`, `delete` | -| `certificates` | `read`, `create`, `edit`, `delete` | -| `certificate-templates` | `read`, `create`, `edit`, `delete` | -| `pki-alerts` | `read`, `create`, `edit`, `delete` | -| `pki-collections` | `read`, `create`, `edit`, `delete` | -| `kms` | `edit` | -| `cmek` | `read`, `create`, `edit`, `delete`, `encrypt`, `decrypt` | -| `secret-syncs` | `read`, `create`, `edit`, `delete`, `sync-secrets`, `import-secrets`, `remove-secrets` | - - - - - - - Not all actions are applicable to all subjects. As an example, the `workspace` - subject only supports `read`, and `create` as actions. While `member` support - `read`, `create`, `edit`, `delete`. - - -| Subject | Actions | -| --------------------- | ------------------------------------------------ | -| `workspace` | `read`, `create` | -| `role` | `read`, `create`, `edit`, `delete` | -| `member` | `read`, `create`, `edit`, `delete` | -| `secret-scanning` | `read`, `create`, `edit`, `delete` | -| `settings` | `read`, `create`, `edit`, `delete` | -| `incident-account` | `read`, `create`, `edit`, `delete` | -| `sso` | `read`, `create`, `edit`, `delete` | -| `scim` | `read`, `create`, `edit`, `delete` | -| `ldap` | `read`, `create`, `edit`, `delete` | -| `groups` | `read`, `create`, `edit`, `delete` | -| `billing` | `read`, `create`, `edit`, `delete` | -| `identity` | `read`, `create`, `edit`, `delete` | -| `project-templates` | `read`, `create`, `edit`, `delete` | -| `app-connections` | `read`, `create`, `edit`, `delete`, `connect` | -| `kms` | `read` | - - - - -## Inversion - -Permission inversion allows you to explicitly deny actions instead of allowing them. This is supported for the following subjects: - -- secrets -- secret-folders -- secret-imports -- dynamic-secrets - -When a permission is inverted, it changes from an "allow" rule to a "deny" rule. For example: - -```typescript -// Regular permission - allows reading secrets -{ - subject: "secrets", - action: ["read"] -} - -// Inverted permission - denies reading secrets -{ - subject: "secrets", - action: ["read"], - inverted: true -} -``` - -## Conditions - -Conditions allow you to create more granular permissions by specifying criteria that must be met for the permission to apply. This is supported for the following subjects: - -- secrets -- secret-folders -- secret-imports -- dynamic-secrets - -### Properties - -Conditions can be applied to the following properties: - -- `environment`: Control access based on environment slugs -- `secretPath`: Control access based on secret paths -- `secretName`: Control access based on secret names -- `secretTags`: Control access based on tags (only supports $in operator) - -### Operators - -The following operators are available for conditions: - -| Operator | Description | Example | -| -------- | ---------------------------------- | ----------------------------------------------------- | -| `$eq` | Equal | `{ environment: { $eq: "production" } }` | -| `$ne` | Not equal | `{ environment: { $ne: "development" } }` | -| `$in` | Matches any value in array | `{ environment: { $in: ["staging", "production"] } }` | -| `$glob` | Pattern matching using glob syntax | `{ secretPath: { $glob: "/app/\*" } }` | - -These details are especially useful if you're using the API to [create new project roles](../api-reference/endpoints/project-roles/create). -The rules outlined on this page, also apply when using our Terraform Provider to manage your Infisical project roles, or any other of our clients that manage project roles. - -## Migrating from permission V1 to permission V2 - -When upgrading to V2 permissions (i.e. when moving from using the `permissions` to `permissions_v2` field in your Terraform configurations, or upgrading to the V2 permission API), you'll need to update your permission structure as follows: - -Any permissions for `secrets` should be expanded to include equivalent permissions for: - -- `secret-imports` -- `secret-folders` (except for read permissions) -- `dynamic-secrets` - -For dynamic secrets, the actions need to be mapped differently: - -- `read` → `read-root-credential` -- `create` → `create-root-credential` -- `edit` → `edit-root-credential` (also adds `lease` permission) -- `delete` → `delete-root-credential` - -Example: - -```hcl -# Old V1 configuration -resource "infisical_project_role" "example" { - name = "example" - permissions = [ - { - subject = "secrets" - action = "read" - }, - { - subject = "secrets" - action = "edit" - } - ] -} - -# New V2 configuration -resource "infisical_project_role" "example" { - name = "example" - permissions_v2 = [ - # Original secrets permission - { - subject = "secrets" - action = ["read", "edit"] - inverted = false - }, - # Add equivalent secret-imports permission - { - subject = "secret-imports" - action = ["read", "edit"] - inverted = false - }, - # Add secret-folders permission (without read) - { - subject = "secret-folders" - action = ["edit"] - inverted = false - }, - # Add dynamic-secrets permission with mapped actions - { - subject = "dynamic-secrets" - action = ["read-root-credential", "edit-root-credential", "lease"] - inverted = false - } - ] -} -``` - -Note: When moving to V2 permissions, make sure to include all the necessary expanded permissions based on your original `secrets` permissions. diff --git a/docs/internals/permissions/migration.mdx b/docs/internals/permissions/migration.mdx new file mode 100644 index 000000000..c934f86e0 --- /dev/null +++ b/docs/internals/permissions/migration.mdx @@ -0,0 +1,118 @@ +--- +title: "Migration Guide" +description: "Guide for migrating permissions in Infisical" +--- + +# Migrating from Permission V1 to Permission V2 + +This guide provides instructions for upgrading from the legacy V1 permissions system to the more powerful V2 permissions system in Infisical. + +## Why Upgrade to V2? + +The V2 permissions system offers several advantages over V1: + +- **More granular control**: Separate permissions for different secret-related resources +- **Explicit deny rules**: Support for permission inversion +- **Conditional permissions**: Apply permissions based on specific criteria +- **Array-based actions**: Cleaner syntax for multiple actions + +## Migration Steps + +When upgrading to V2 permissions (i.e., when moving from using the `permissions` to `permissions_v2` field in your Terraform configurations, or upgrading to the V2 permission API), you'll need to update your permission structure as follows: + +### 1. Expand Secret Permissions + +Any permissions for `secrets` should be expanded to include equivalent permissions for: + +- `secret-imports` +- `secret-folders` (except for read permissions) +- `dynamic-secrets` + +### 2. Map Dynamic Secret Actions + +For dynamic secrets, the actions need to be mapped differently: + +| V1 Action | V2 Action | +| --------- | ----------------------------------------------------- | +| `read` | `read-root-credential` | +| `create` | `create-root-credential` | +| `edit` | `edit-root-credential` (also adds `lease` permission) | +| `delete` | `delete-root-credential` | + +### 3. Update Configuration Format + +V2 permissions use a different syntax, with actions stored in arrays and an optional `inverted` flag: + +```typescript +// V1 format (single action) +{ + subject: "secrets", + action: "read" +} + +// V2 format (array of actions) +{ + subject: "secrets", + action: ["read"], + inverted: false // Optional, defaults to false +} +``` + +## Example Migration + +Here's a complete example showing how to migrate a role from V1 to V2: + +```hcl +# Old V1 configuration +resource "infisical_project_role" "example" { + name = "example" + permissions = [ + { + subject = "secrets" + action = "read" + }, + { + subject = "secrets" + action = "edit" + } + ] +} + +# New V2 configuration +resource "infisical_project_role" "example" { + name = "example" + permissions_v2 = [ + # Original secrets permission + { + subject = "secrets" + action = ["read", "edit"] + inverted = false + }, + # Add equivalent secret-imports permission + { + subject = "secret-imports" + action = ["read", "edit"] + inverted = false + }, + # Add secret-folders permission (without read) + { + subject = "secret-folders" + action = ["edit"] + inverted = false + }, + # Add dynamic-secrets permission with mapped actions + { + subject = "dynamic-secrets" + action = ["read-root-credential", "edit-root-credential", "lease"] + inverted = false + } + ] +} +``` + +## Important Considerations + +- When moving to V2 permissions, make sure to include all the necessary expanded permissions based on your original `secrets` permissions. +- V2 permissions give you the ability to use conditions and inversion, which are not available in V1. +- During migration, review your existing roles and consider if more granular permissions would better fit your security requirements. +- Test your migrated permissions thoroughly in a non-production environment before deploying to production. diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx new file mode 100644 index 000000000..c68d845e2 --- /dev/null +++ b/docs/internals/permissions/organization-permissions.mdx @@ -0,0 +1,220 @@ +--- +title: "Organization Permissions" +description: "Comprehensive guide to Infisical's organization-level permissions" +--- + +## Overview + +Infisical's organization permissions system follows a role-based access control (RBAC) model built on a subject-action-object framework. At the organization level, these permissions determine what actions users/machines can perform on various resources across the entire organization. + +Each permission consists of: + +- **Subject**: The resource the permission applies to (e.g., workspaces, members, billing) +- **Action**: The operation that can be performed (e.g., read, create, edit, delete) + +Some organization-level resources—specifically `app-connections`—support conditional permissions and permission inversion for more granular access control. + +## Available Organization Permissions + +Below is a comprehensive list of all available organization-level subjects and their supported actions, organized by functional area. + +### Workspace Management + +#### Subject: `workspace` + +| Action | Description | +| -------- | --------------------- | +| `create` | Create new workspaces | + +### Role Management + +#### Subject: `role` + +| Action | Description | +| -------- | ------------------------------------------------------ | +| `read` | View organization roles and their assigned permissions | +| `create` | Create new organization roles | +| `edit` | Modify existing organization roles | +| `delete` | Remove organization roles | + +### User Management + +#### Subject: `member` + +| Action | Description | +| -------- | ------------------------------------ | +| `read` | View organization members | +| `create` | Add new members to the organization | +| `edit` | Modify member details | +| `delete` | Remove members from the organization | + +#### Subject: `groups` + +| Action | Description | +| ------------------ | ------------------------------------------------ | +| `read` | View organization groups | +| `create` | Create new groups in the organization | +| `edit` | Modify existing groups | +| `delete` | Remove groups from the organization | +| `grant-privileges` | Change permission levels for organization groups | +| `add-members` | Add members to groups | +| `remove-members` | Remove members from groups | + +#### Subject: `identity` + +| Action | Description | +| ------------------ | --------------------------------------------------- | +| `read` | View organization identities | +| `create` | Add new identities to organization | +| `edit` | Modify organization identities | +| `delete` | Remove identities from organization | +| `grant-privileges` | Change permission levels of organization identities | +| `revoke-auth` | Revoke authentication for identities | +| `create-token` | Create new authentication tokens | +| `delete-token` | Delete authentication tokens | +| `get-token` | Retrieve authentication tokens | + +### Security & Compliance + +#### Subject: `secret-scanning` + +| Action | Description | +| -------- | ----------------------------------------- | +| `read` | View secret scanning results and settings | +| `create` | Configure secret scanning | +| `edit` | Modify secret scanning settings | +| `delete` | Remove secret scanning configuration | + +#### Subject: `settings` + +| Action | Description | +| -------- | ----------------------------------------- | +| `read` | View organization settings | +| `create` | Setup and configure organization settings | +| `edit` | Modify organization settings | +| `delete` | Remove organization settings | + +#### Subject: `incident-contact` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View incident contacts | +| `create` | Set up new incident contacts | +| `edit` | Modify incident contact settings | +| `delete` | Remove incident contacts | + +#### Subject: `audit-logs` + +| Action | Description | +| ------ | ---------------------------- | +| `read` | View organization audit logs | + +### Identity Provider Integration + +#### Subject: `sso` + +| Action | Description | +| -------- | ---------------------------------- | +| `read` | View Single Sign-On configurations | +| `create` | Set up new SSO integrations | +| `edit` | Modify existing SSO settings | +| `delete` | Remove SSO configurations | + +#### Subject: `scim` + +| Action | Description | +| -------- | ----------------------------- | +| `read` | View SCIM configurations | +| `create` | Set up new SCIM provisioning | +| `edit` | Modify existing SCIM settings | +| `delete` | Remove SCIM configurations | + +#### Subject: `ldap` + +| Action | Description | +| -------- | ----------------------------- | +| `read` | View LDAP configurations | +| `create` | Set up new LDAP integrations | +| `edit` | Modify existing LDAP settings | +| `delete` | Remove LDAP configurations | + +### Billing & Subscriptions + +#### Subject: `billing` + +| Action | Description | +| -------- | ------------------------------------------------ | +| `read` | View billing information and subscription status | +| `create` | Set up new payment methods or subscriptions | +| `edit` | Modify billing details or subscription plans | +| `delete` | Remove payment methods or cancel subscriptions | + +### Templates & Automation + +#### Subject: `project-templates` + +| Action | Description | +| -------- | --------------------------------- | +| `read` | View project templates | +| `create` | Create new project templates | +| `edit` | Modify existing project templates | +| `delete` | Remove project templates | + +### Integrations + +#### Subject: `app-connections` + +Supports conditions and permission inversion + +| Action | Description | +| --------- | ---------------------------------- | +| `read` | View app connection configurations | +| `create` | Create new app connections | +| `edit` | Modify existing app connections | +| `delete` | Remove app connections | +| `connect` | Use app connections | + +### Key Management + +#### Subject: `kms` + +| Action | Description | +| -------- | ------------------------------------ | +| `read` | View organization KMS configurations | +| `create` | Set up new KMS configurations | +| `edit` | Modify KMS settings | +| `delete` | Remove KMS configurations | + +#### Subject: `kmip` + +| Action | Description | +| ------- | ---------------------------------- | +| `setup` | Configure KMIP server settings | +| `proxy` | Act as a proxy for KMIP operations | + +### Admin Tools + +#### Subject: `organization-admin-console` + +| Action | Description | +| --------------------- | ------------------------------------------- | +| `access-all-projects` | Access all projects within the organization | + +### Secure Share + +#### Subject: `secret-share` + +| Action | Description | +| ----------------- | ---------------------------- | +| `manage-settings` | Manage secret share settings | + +### Gateway Management + +#### Subject: `gateway` + +| Action | Description | +| ----------------- | --------------------------------- | +| `list-gateways` | View all organization gateways | +| `create-gateways` | Add new gateways to organization | +| `edit-gateways` | Modify existing gateway settings | +| `delete-gateways` | Remove gateways from organization | diff --git a/docs/internals/permissions/overview.mdx b/docs/internals/permissions/overview.mdx new file mode 100644 index 000000000..2fb4dc28d --- /dev/null +++ b/docs/internals/permissions/overview.mdx @@ -0,0 +1,89 @@ +--- +title: "Overview" +description: "Infisical's permissions system provides granular access control." +--- + +## Overview + +The Infisical permissions system is based on a role-based access control (RBAC) model. The system allows you to define roles and assign them to users and machines. Each role has a set of permissions that define what actions a user can perform. + +Permissions are built on a subject-action-object model. The subject is the resource the permission is being applied to, the action is what the permission allows. +An example of a subject/action combination would be `secrets/read`. This permission allows the subject to read secrets. + +## Permission Scope Levels + +Infisical's permission system operates at two distinct levels, providing comprehensive and flexible access control across your entire security infrastructure: + +### Project Permissions + +Project permissions control access to resources within a specific project, including secrets management, PKI, KMS, and SSH certificate functionality. + +For a comprehensive list of all project-level subjects, actions, and detailed descriptions, please refer to the [Project Permissions](/internals/permissions/project-permissions) documentation. + +### Organization Permissions + +Organization permissions control access to organization-wide resources and settings such as workspaces, billing, identity providers, and more. + +For a comprehensive list of all organization-level subjects, actions, and detailed descriptions, please refer to the [Organization Permissions](/internals/permissions/organization-permissions) documentation. + +## Inversion + +Permission inversion allows you to explicitly deny actions instead of allowing them. This is supported for the following subjects: + +- secrets +- secret-folders +- secret-imports +- dynamic-secrets + +When a permission is inverted, it changes from an "allow" rule to a "deny" rule. For example: + +```typescript +// Regular permission - allows reading secrets +{ + subject: "secrets", + action: ["read"] +} + +// Inverted permission - denies reading secrets +{ + subject: "secrets", + action: ["read"], + inverted: true +} +``` + +**Important:** The order of permissions matters when using inversion. For inverted (deny) permissions to be effective, there +typically needs to be a corresponding allow permission somewhere in the chain. Permissions are evaluated in sequence, +so the relative positioning of allow and deny rules determines the final access outcome. + +## Conditions + +Conditions allow you to create more granular permissions by specifying criteria that must be met for the permission to apply. This is supported for the following subjects: + +- secrets +- secret-folders +- secret-imports +- dynamic-secrets + +### Properties + +Conditions can be applied to the following properties: + +- `environment`: Control access based on environment slugs +- `secretPath`: Control access based on secret paths +- `secretName`: Control access based on secret names +- `secretTags`: Control access based on tags (only supports $in operator) + +### Operators + +The following operators are available for conditions: + +| Operator | Description | Example | +| -------- | ---------------------------------- | ----------------------------------------------------- | +| `$eq` | Equal | `{ environment: { $eq: "production" } }` | +| `$ne` | Not equal | `{ environment: { $ne: "development" } }` | +| `$in` | Matches any value in array | `{ environment: { $in: ["staging", "production"] } }` | +| `$glob` | Pattern matching using glob syntax | `{ secretPath: { $glob: "/app/\*" } }` | + +These details are especially useful if you're using the API to [create new project roles](../api-reference/endpoints/project-roles/create). +The rules outlined on this page, also apply when using our Terraform Provider to manage your Infisical project roles, or any other of our clients that manage project roles. diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx new file mode 100644 index 000000000..553b9d305 --- /dev/null +++ b/docs/internals/permissions/project-permissions.mdx @@ -0,0 +1,312 @@ +--- +title: "Project Permissions" +description: "Comprehensive guide to Infisical's project-level permissions" +--- + +## Overview + +Infisical's project permissions system follows a role-based access control (RBAC) model built on a subject-action-object framework. At the project level, these permissions determine what actions users/machines can perform on various resources within a specific project. + +Each permission consists of: + +- **Subject**: The resource the permission applies to (e.g., secrets, members, settings) +- **Action**: The operation that can be performed (e.g., read, create, edit, delete) + +Some project-level resources—specifically `secrets`, `secret-folders`, `secret-imports`, and `dynamic-secrets`—support conditional permissions and permission inversion for more granular access control. Conditions allow you to specify criteria (like environment, secret path, or tags) that must be met for the permission to apply. + +## Available Project Permissions + +Below is a comprehensive list of all available project-level subjects and their supported actions. + +### Core Platform & Access Control + +#### Subject: `role` + +| Action | Description | +| -------- | ------------------------------------------------- | +| `read` | View project roles and their assigned permissions | +| `create` | Create new project roles | +| `edit` | Modify existing project roles | +| `delete` | Remove project roles | + +#### Subject: `member` + +| Action | Description | +| ------------------ | ------------------------------------------- | +| `read` | View project members | +| `create` | Add new members to the project | +| `edit` | Modify member details | +| `delete` | Remove members from the project | +| `grant-privileges` | Change permission levels of project members | + +#### Subject: `groups` + +| Action | Description | +| ------------------ | ------------------------------------------ | +| `read` | View project groups | +| `create` | Create new groups within the project | +| `edit` | Modify existing groups | +| `delete` | Remove groups from the project | +| `grant-privileges` | Change permission levels of project groups | + +#### Subject: `identity` + +| Action | Description | +| ------------------ | ---------------------------------------------- | +| `read` | View project identities | +| `create` | Add new identities to project | +| `edit` | Modify project identities | +| `delete` | Remove identities from project | +| `grant-privileges` | Change permission levels of project identities | + +#### Subject: `settings` + +| Action | Description | +| -------- | -------------------------------------- | +| `read` | View project settings | +| `create` | Add new project configuration settings | +| `edit` | Modify project settings | +| `delete` | Remove project settings | + +#### Subject: `environments` + +| Action | Description | +| -------- | ------------------------------------ | +| `read` | View project environments | +| `create` | Add new environments to the project | +| `edit` | Modify existing environments | +| `delete` | Remove environments from the project | + +#### Subject: `tags` + +| Action | Description | +| -------- | ---------------------------------------- | +| `read` | View project tags | +| `create` | Create new tags for organizing resources | +| `edit` | Modify existing tags | +| `delete` | Remove tags from the project | + +#### Subject: `workspace` + +| Action | Description | +| -------- | ------------------------- | +| `edit` | Modify workspace settings | +| `delete` | Delete the workspace | + +#### Subject: `ip-allowlist` + +| Action | Description | +| -------- | -------------------------------------------- | +| `read` | View IP allowlists | +| `create` | Add new IP addresses or ranges to allowlists | +| `edit` | Modify existing IP allowlist entries | +| `delete` | Remove IP addresses from allowlists | + +#### Subject: `audit-logs` + +| Action | Description | +| ------ | ------------------------------------------------------- | +| `read` | View audit logs of actions performed within the project | + +#### Subject: `integrations` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View configured integrations | +| `create` | Add new third-party integrations | +| `edit` | Modify integration settings | +| `delete` | Remove integrations | + +#### Subject: `webhooks` + +| Action | Description | +| -------- | ------------------------------------ | +| `read` | View webhook configurations | +| `create` | Add new webhooks | +| `edit` | Modify webhook endpoints or triggers | +| `delete` | Remove webhooks | + +#### Subject: `service-tokens` + +| Action | Description | +| -------- | ---------------------------------------- | +| `read` | View service tokens | +| `create` | Create new service tokens for API access | +| `edit` | Modify token properties | +| `delete` | Revoke or remove service tokens | + +### Secrets Management + +#### Subject: `secrets` + +Supports conditions and permission inversion +| Action | Description | Notes | +| -------- | ------------------------------- | ----- | +| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | +| `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. | +| `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. | +| `create` | Add new secrets to the project | | +| `edit` | Modify existing secret values | | +| `delete` | Remove secrets from the project | | + +#### Subject: `secret-folders` + +Supports conditions and permission inversion +| Action | Description | +| -------- | ------------------------ | +| `read` | View secret folders | +| `create` | Create new folders | +| `edit` | Modify folder properties | +| `delete` | Remove secret folders | + +#### Subject: `secret-imports` + +Supports conditions and permission inversion +| Action | Description | +| -------- | --------------------- | +| `read` | View secret imports | +| `create` | Create secret imports | +| `edit` | Modify secret imports | +| `delete` | Remove secret imports | + +#### Subject: `secret-rollback` + +| Action | Description | +| -------- | ---------------------------------- | +| `read` | View secret versions and snapshots | +| `create` | Roll back secrets to snapshots | + +#### Subject: `secret-approval` + +| Action | Description | +| -------- | ----------------------------------- | +| `read` | View approval policies and requests | +| `create` | Create new approval policies | +| `edit` | Modify approval policies | +| `delete` | Remove approval policies | + +#### Subject: `secret-rotation` + +| Action | Description | +| -------- | ------------------------------------- | +| `read` | View secret rotation policies | +| `create` | Set up automatic secret rotation | +| `edit` | Modify rotation schedules or policies | +| `delete` | Remove rotation policies | + +#### Subject: `secret-syncs` + +| Action | Description | +| ---------------- | -------------------------------------------------- | +| `read` | View secret synchronization configurations | +| `create` | Create new sync configurations | +| `edit` | Modify existing sync settings | +| `delete` | Remove sync configurations | +| `sync-secrets` | Execute synchronization of secrets between systems | +| `import-secrets` | Import secrets from sync sources | +| `remove-secrets` | Remove secrets from sync destinations | + +#### Subject: `dynamic-secrets` + +Supports conditions and permission inversion +| Action | Description | +| ------------------------ | ---------------------------------- | +| `read-root-credential` | View dynamic secret configurations | +| `create-root-credential` | Create dynamic secrets | +| `edit-root-credential` | Edit dynamic secrets | +| `delete-root-credential` | Remove dynamic secrets | +| `lease` | Create dynamic secret leases | + +### Key Management Service (KMS) + +#### Subject: `kms` + +| Action | Description | +| ------ | --------------------------- | +| `edit` | Modify project KMS settings | + +#### Subject: `cmek` + +| Action | Description | +| --------- | ------------------------------------- | +| `read` | View Customer-Managed Encryption Keys | +| `create` | Add new encryption keys | +| `edit` | Modify key properties | +| `delete` | Remove encryption keys | +| `encrypt` | Use keys for encryption operations | +| `decrypt` | Use keys for decryption operations | + +### Public Key Infrastructure (PKI) + +#### Subject: `certificate-authorities` + +| Action | Description | +| -------- | ---------------------------------- | +| `read` | View certificate authorities | +| `create` | Create new certificate authorities | +| `edit` | Modify CA configurations | +| `delete` | Remove certificate authorities | + +#### Subject: `certificates` + +| Action | Description | +| -------- | ----------------------------- | +| `read` | View certificates | +| `create` | Issue new certificates | +| `delete` | Revoke or remove certificates | + +#### Subject: `certificate-templates` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View certificate templates | +| `create` | Create new certificate templates | +| `edit` | Modify template configurations | +| `delete` | Remove certificate templates | + +#### Subject: `pki-alerts` + +| Action | Description | +| -------- | ------------------------------------------------------------ | +| `read` | View PKI alert configurations | +| `create` | Create new alerts for certificate expiry or other PKI events | +| `edit` | Modify alert settings | +| `delete` | Remove PKI alerts | + +#### Subject: `pki-collections` + +| Action | Description | +| -------- | --------------------------------------------------- | +| `read` | View PKI resource collections | +| `create` | Create new collections for organizing PKI resources | +| `edit` | Modify collection properties | +| `delete` | Remove PKI collections | + +### SSH Certificate Management + +#### Subject: `ssh-certificate-authorities` + +| Action | Description | +| -------- | -------------------------------------- | +| `read` | View SSH certificate authorities | +| `create` | Create new SSH certificate authorities | +| `edit` | Modify SSH CA configurations | +| `delete` | Remove SSH certificate authorities | + +#### Subject: `ssh-certificates` + +| Action | Description | +| -------- | --------------------------------- | +| `read` | View SSH certificates | +| `create` | Issue new SSH certificates | +| `edit` | Modify SSH certificate properties | +| `delete` | Revoke or remove SSH certificates | + +#### Subject: `ssh-certificate-templates` + +| Action | Description | +| -------- | ------------------------------------ | +| `read` | View SSH certificate templates | +| `create` | Create new SSH certificate templates | +| `edit` | Modify SSH template configurations | +| `delete` | Remove SSH certificate templates | diff --git a/docs/mint.json b/docs/mint.json index 76bea0295..478db0276 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -402,7 +402,8 @@ ] }, "integrations/frameworks/terraform", - "integrations/platforms/ansible" + "integrations/platforms/ansible", + "integrations/platforms/apache-airflow" ] }, { @@ -1186,7 +1187,15 @@ "group": "Internals", "pages": [ "internals/overview", - "internals/permissions", + { + "group": "Permissions", + "pages": [ + "internals/permissions/overview", + "internals/permissions/project-permissions", + "internals/permissions/organization-permissions", + "internals/permissions/migration" + ] + }, "internals/components", "internals/security", "internals/service-tokens" diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index a0bf48687..47bc9a9ea 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -99,6 +99,10 @@ client := infisical.NewInfisicalClient(context.Background(), infisical.Config{ Defines how long certain responses should be cached in memory, in seconds. When set to a positive value, responses from specific methods (like secret fetching) will be cached for this duration. Set to 0 to disable caching. + + + Allows you to pass custom headers to the HTTP requests made by the SDK. Expected format is a map of `Header1: Value1, Header2: Value 2`. + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7e9ca7e7..1fa72b81a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -362,26 +362,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.26.10" }, "bin": { "parser": "bin/babel-parser.js" @@ -423,9 +423,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -435,14 +435,14 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" @@ -476,9 +476,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -1010,6 +1010,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -1028,9 +1045,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", "cpu": [ "arm64" ], @@ -1647,12 +1664,12 @@ } }, "node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.3.tgz", + "integrity": "sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.0.0", + "@octokit/types": "^13.6.2", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1674,18 +1691,18 @@ } }, "node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.6.tgz", - "integrity": "sha512-zcvqqf/+TicbTCa/Z+3w4eBJcAxCFymtc0UAIsR3dEVoNilWld4oXdscQ3laXamTszUZdusw97K8+DrbFiOwjw==", + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.6.2" + "@octokit/types": "^13.10.0" }, "engines": { "node": ">= 18" @@ -1722,14 +1739,15 @@ } }, "node_modules/@octokit/request": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", - "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.2.tgz", + "integrity": "sha512-dZl0ZHx6gOQGcffgm1/Sf6JfEpmh34v3Af2Uci02vzUYz6qEN6zepoRtmybWXIGXFIK8K9ylE3b+duCWqhArtg==", "license": "MIT", "dependencies": { - "@octokit/endpoint": "^10.0.0", - "@octokit/request-error": "^6.0.1", - "@octokit/types": "^13.1.0", + "@octokit/endpoint": "^10.1.3", + "@octokit/request-error": "^6.1.7", + "@octokit/types": "^13.6.2", + "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1737,12 +1755,12 @@ } }, "node_modules/@octokit/request-error": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.5.tgz", - "integrity": "sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", + "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.0.0" + "@octokit/types": "^13.6.2" }, "engines": { "node": ">= 18" @@ -1764,12 +1782,12 @@ } }, "node_modules/@octokit/types": { - "version": "13.6.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.2.tgz", - "integrity": "sha512-WpbZfZUcZU77DrSW4wbsSgTPfKcp286q3ItaIgvSbBpZJlu6mnYXAkjZz6LVZPXkEvLIM8McanyZejKTYUHipA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@peculiar/asn1-cms": { @@ -4956,9 +4974,9 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", + "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5451,9 +5469,9 @@ "license": "CC-BY-4.0" }, "node_modules/canvg": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz", - "integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", "license": "MIT", "optional": true, "dependencies": { @@ -7314,6 +7332,22 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12588,13 +12622,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", + "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -12608,9 +12642,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], @@ -12625,9 +12659,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], @@ -12642,9 +12676,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], @@ -12659,9 +12693,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], @@ -12676,9 +12710,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], @@ -12693,9 +12727,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], @@ -12710,9 +12744,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], @@ -12727,9 +12761,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], @@ -12744,9 +12778,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], @@ -12761,9 +12795,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], @@ -12778,9 +12812,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], @@ -12795,9 +12829,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], @@ -12812,9 +12846,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], @@ -12829,9 +12863,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], @@ -12846,9 +12880,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], @@ -12863,9 +12897,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], @@ -12880,9 +12914,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], @@ -12897,9 +12931,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], @@ -12914,9 +12948,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], @@ -12931,9 +12965,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", "cpu": [ "x64" ], @@ -12948,9 +12982,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], @@ -12965,9 +12999,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], @@ -12982,9 +13016,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], @@ -12999,9 +13033,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -13012,30 +13046,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/tsyringe": { @@ -13552,9 +13587,9 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 6a1086996..0cb2fd0e4 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -17,6 +17,8 @@ export type CheckboxProps = Omit< isError?: boolean; isIndeterminate?: boolean; containerClassName?: string; + indicatorClassName?: string; + allowMultilineLabel?: boolean; }; export const Checkbox = ({ @@ -30,6 +32,8 @@ export const Checkbox = ({ isError, isIndeterminate, containerClassName, + indicatorClassName, + allowMultilineLabel, ...props }: CheckboxProps): JSX.Element => { return ( @@ -48,7 +52,9 @@ export const Checkbox = ({ {...props} id={id} > - + {isIndeterminate ? ( ) : ( @@ -57,7 +63,11 @@ export const Checkbox = ({