Merge branch 'main' into feat/addReplicateFolderContent

This commit is contained in:
carlosmonastyrski
2025-04-01 08:16:09 -03:00
261 changed files with 6743 additions and 2170 deletions
@@ -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 }}
@@ -1,52 +1,103 @@
name: Release image + Helm chart K8s Operator name: Release K8 Operator Docker Image
on: on:
push: push:
tags: tags:
- "infisical-k8-operator/v*.*.*" - "infisical-k8-operator/v*.*.*"
jobs: jobs:
release: release-image:
runs-on: ubuntu-latest name: Generate Helm Chart PR
steps: runs-on: ubuntu-latest
- name: Extract version from tag outputs:
id: extract_version pr_number: ${{ steps.create-pr.outputs.pull-request-number }}
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical-k8-operator/}" steps:
- uses: actions/checkout@v2 - name: Extract version from tag
id: extract_version
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical-k8-operator/}"
- name: 🔧 Set up QEMU - name: Checkout code
uses: docker/setup-qemu-action@v1 uses: actions/checkout@v2
- name: 🔧 Set up Docker Buildx # Dependency for helm generation
uses: docker/setup-buildx-action@v1 - name: Install Helm
uses: azure/setup-helm@v3
with:
version: v3.10.0
- name: 🐋 Login to Docker Hub # Dependency for helm generation
uses: docker/login-action@v1 - name: Install Go
with: uses: actions/setup-go@v4
username: ${{ secrets.DOCKERHUB_USERNAME }} with:
password: ${{ secrets.DOCKERHUB_TOKEN }} go-version: 1.21
- name: Build and push # Install binaries for helm generation
id: docker_build - name: Install dependencies
uses: docker/build-push-action@v2 working-directory: k8-operator
with: run: |
context: k8-operator make helmify
push: true make kustomize
platforms: linux/amd64,linux/arm64 make controller-gen
tags: |
infisical/kubernetes-operator:latest
infisical/kubernetes-operator:${{ steps.extract_version.outputs.version }}
- name: Checkout - name: Generate Helm Chart
uses: actions/checkout@v2 working-directory: k8-operator
- name: Install Helm run: make helm
uses: azure/setup-helm@v3
with: - name: Update Helm Chart Version
version: v3.10.0 run: ./k8-operator/scripts/update-version.sh ${{ steps.extract_version.outputs.version }}
- name: Install python
uses: actions/setup-python@v4 - name: Debug - Check file changes
- name: Install Cloudsmith CLI run: |
run: pip install --upgrade cloudsmith-cli echo "Current git status:"
- name: Build and push helm package to Cloudsmith git status
run: cd helm-charts && sh upload-k8s-operator-cloudsmith.sh echo ""
env: echo "Modified files:"
CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} 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 <[email protected]>
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 }}
+4
View File
@@ -8,3 +8,7 @@ frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/S
docs/mint.json:generic-api-key:651 docs/mint.json:generic-api-key:651
backend/src/ee/services/hsm/hsm-service.ts:generic-api-key:134 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/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
+1
View File
@@ -106,6 +106,7 @@ declare module "@fastify/request-context" {
claims: Record<string, string>; claims: Record<string, string>;
}; };
}; };
identityPermissionMetadata?: Record<string, unknown>; // filled by permission service
} }
} }
@@ -16,7 +16,7 @@ const createAuditLogPartition = async (knex: Knex, startDate: Date, endDate: Dat
const startDateStr = formatPartitionDate(startDate); const startDateStr = formatPartitionDate(startDate);
const endDateStr = formatPartitionDate(endDate); 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( await knex.schema.raw(
`CREATE TABLE ${partitionName} PARTITION OF ${TableName.AuditLog} FOR VALUES FROM ('${startDateStr}') TO ('${endDateStr}')` `CREATE TABLE ${partitionName} PARTITION OF ${TableName.AuditLog} FOR VALUES FROM ('${startDateStr}') TO ('${endDateStr}')`
@@ -0,0 +1,30 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.Organization, "shouldUseNewPrivilegeSystem")) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.dropColumn("shouldUseNewPrivilegeSystem");
t.dropColumn("privilegeUpgradeInitiatedByUsername");
t.dropColumn("privilegeUpgradeInitiatedAt");
});
}
}
@@ -0,0 +1,23 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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"]);
});
}
}
@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
// we can't make it back to non nullable, it will fail
}
@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas/models";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
});
}
}
@@ -16,7 +16,8 @@ export const AccessApprovalPoliciesSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard"), 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<typeof AccessApprovalPoliciesSchema>; export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
@@ -28,7 +28,7 @@ export const IdentityKubernetesAuthsSchema = z.object({
allowedNamespaces: z.string(), allowedNamespaces: z.string(),
allowedNames: z.string(), allowedNames: z.string(),
allowedAudience: z.string(), allowedAudience: z.string(),
encryptedKubernetesTokenReviewerJwt: zodBuffer, encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(),
encryptedKubernetesCaCertificate: zodBuffer.nullable().optional() encryptedKubernetesCaCertificate: zodBuffer.nullable().optional()
}); });
+3
View File
@@ -23,6 +23,9 @@ export const OrganizationsSchema = z.object({
defaultMembershipRole: z.string().default("member"), defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false), enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional(), 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() allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional()
}); });
@@ -16,7 +16,8 @@ export const SecretApprovalPoliciesSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.string().default("hard"), 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<typeof SecretApprovalPoliciesSchema>; export type TSecretApprovalPolicies = z.infer<typeof SecretApprovalPoliciesSchema>;
@@ -16,7 +16,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
// for CSRs sent in PEM, we leave them as is // for CSRs sent in PEM, we leave them as is
// for CSRs sent in base64, we preprocess them to remove new lines and spaces // for CSRs sent in base64, we preprocess them to remove new lines and spaces
if (!csrBody.includes("BEGIN CERTIFICATE REQUEST")) { if (!csrBody.includes("BEGIN CERTIFICATE REQUEST")) {
csrBody = csrBody.replace(/\n/g, "").replace(/ /g, ""); csrBody = csrBody.replaceAll("\n", "").replaceAll(" ", "");
} }
done(null, csrBody); done(null, csrBody);
@@ -29,7 +29,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1), 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: { response: {
200: z.object({ 200: z.object({
@@ -147,7 +148,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).optional(), 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: { response: {
200: z.object({ 200: z.object({
@@ -110,7 +110,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
secretPath: z.string().nullish(), secretPath: z.string().nullish(),
envId: z.string(), envId: z.string(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish() deletedAt: z.date().nullish(),
allowedSelfApprovals: z.boolean()
}), }),
reviewers: z reviewers: z
.object({ .object({
+2 -2
View File
@@ -61,8 +61,8 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => {
if (ldapConfig.groupSearchBase) { if (ldapConfig.groupSearchBase) {
const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))";
const groupSearchFilter = (ldapConfig.groupSearchFilter || groupFilter) const groupSearchFilter = (ldapConfig.groupSearchFilter || groupFilter)
.replace(/{{\.Username}}/g, user.uid) .replaceAll("{{.Username}}", user.uid)
.replace(/{{\.UserDN}}/g, user.dn); .replaceAll("{{.UserDN}}", user.dn);
if (!isValidLdapFilter(groupSearchFilter)) { if (!isValidLdapFilter(groupSearchFilter)) {
throw new Error("Generated LDAP search filter is invalid."); throw new Error("Generated LDAP search filter is invalid.");
@@ -35,7 +35,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.array() .array()
.min(1, { message: "At least one approver should be provided" }), .min(1, { message: "At least one approver should be provided" }),
approvals: z.number().min(1).default(1), 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: { response: {
200: z.object({ 200: z.object({
@@ -85,7 +86,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
.nullable() .nullable()
.transform((val) => (val ? removeTrailingSlash(val) : val)) .transform((val) => (val ? removeTrailingSlash(val) : val))
.transform((val) => (val === "" ? "/" : val)), .transform((val) => (val === "" ? "/" : val)),
enforcementLevel: z.nativeEnum(EnforcementLevel).optional() enforcementLevel: z.nativeEnum(EnforcementLevel).optional(),
allowedSelfApprovals: z.boolean().default(true)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -49,7 +49,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.array(), .array(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish() deletedAt: z.date().nullish(),
allowedSelfApprovals: z.boolean()
}), }),
committerUser: approvalRequestUser, committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), 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(), approvers: approvalRequestUser.array(),
secretPath: z.string().optional().nullable(), secretPath: z.string().optional().nullable(),
enforcementLevel: z.string(), enforcementLevel: z.string(),
deletedAt: z.date().nullish() deletedAt: z.date().nullish(),
allowedSelfApprovals: z.boolean()
}), }),
environment: z.string(), environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(), statusChangedByUser: approvalRequestUser.optional(),
@@ -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 { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter"; import { writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerSshCertRouter = async (server: FastifyZodProvider) => { export const registerSshCertRouter = async (server: FastifyZodProvider) => {
server.route({ 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 { return {
serialNumber, serialNumber,
signedKey: signedPublicKey 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 { return {
serialNumber, serialNumber,
signedKey: signedPublicKey, signedKey: signedPublicKey,
@@ -65,7 +65,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvers, approvers,
projectSlug, projectSlug,
environment, environment,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}: TCreateAccessApprovalPolicy) => { }: TCreateAccessApprovalPolicy) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
@@ -153,7 +154,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}, },
tx tx
); );
@@ -216,7 +218,8 @@ export const accessApprovalPolicyServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
approvals, approvals,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}: TUpdateAccessApprovalPolicy) => { }: TUpdateAccessApprovalPolicy) => {
const groupApprovers = approvers const groupApprovers = approvers
.filter((approver) => approver.type === ApproverType.Group) .filter((approver) => approver.type === ApproverType.Group)
@@ -262,7 +265,8 @@ export const accessApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}, },
tx tx
); );
@@ -26,6 +26,7 @@ export type TCreateAccessApprovalPolicy = {
projectSlug: string; projectSlug: string;
name: string; name: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = {
@@ -35,6 +36,7 @@ export type TUpdateAccessApprovalPolicy = {
secretPath?: string; secretPath?: string;
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = { export type TDeleteAccessApprovalPolicy = {
@@ -61,6 +61,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), 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("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
) )
@@ -119,6 +120,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: doc.policyApprovals, approvals: doc.policyApprovals,
secretPath: doc.policySecretPath, secretPath: doc.policySecretPath,
enforcementLevel: doc.policyEnforcementLevel, enforcementLevel: doc.policyEnforcementLevel,
allowedSelfApprovals: doc.policyAllowedSelfApprovals,
envId: doc.policyEnvId, envId: doc.policyEnvId,
deletedAt: doc.policyDeletedAt deletedAt: doc.policyDeletedAt
}, },
@@ -254,6 +256,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("slug").withSchema(TableName.Environment).as("environment"),
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), 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("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
); );
@@ -275,6 +278,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
allowedSelfApprovals: el.policyAllowedSelfApprovals,
deletedAt: el.policyDeletedAt deletedAt: el.policyDeletedAt
}, },
requestedByUser: { requestedByUser: {
@@ -320,6 +320,11 @@ export const accessApprovalRequestServiceFactory = ({
message: "The policy associated with this access request has been deleted." 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({ const { membership, hasRole } = await permissionService.getProjectPermission({
actor, actor,
@@ -45,7 +45,6 @@ export const auditLogStreamServiceFactory = ({
}: TCreateAuditLogStreamDTO) => { }: TCreateAuditLogStreamDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" });
const appCfg = getConfig();
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan.auditLogStreams) { if (!plan.auditLogStreams) {
throw new BadRequestError({ throw new BadRequestError({
@@ -62,9 +61,8 @@ export const auditLogStreamServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings);
if (appCfg.isCloud) { const appCfg = getConfig();
blockLocalAndPrivateIpAddresses(url); if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url);
}
const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId });
if (totalStreams.length >= plan.auditLogStreamLimit) { if (totalStreams.length >= plan.auditLogStreamLimit) {
@@ -135,9 +133,8 @@ export const auditLogStreamServiceFactory = ({
const { orgId } = logStream; const { orgId } = logStream;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const appCfg = getConfig(); const appCfg = getConfig();
if (url && appCfg.isCloud) blockLocalAndPrivateIpAddresses(url); if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url);
// testing connection first // testing connection first
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
@@ -1,8 +1,10 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { ActorType } from "@app/services/auth/auth-type";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
@@ -81,8 +83,12 @@ export const auditLogServiceFactory = ({
if (!data.projectId && !data.orgId) if (!data.projectId && !data.orgId)
throw new BadRequestError({ message: "Must specify either project id or org id" }); throw new BadRequestError({ message: "Must specify either project id or org id" });
} }
const el = { ...data };
return auditLogQueue.pushToLog(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 { return {
@@ -290,6 +290,7 @@ interface UserActorMetadata {
userId: string; userId: string;
email?: string | null; email?: string | null;
username: string; username: string;
permission?: Record<string, unknown>;
} }
interface ServiceActorMetadata { interface ServiceActorMetadata {
@@ -300,6 +301,7 @@ interface ServiceActorMetadata {
interface IdentityActorMetadata { interface IdentityActorMetadata {
identityId: string; identityId: string;
name: string; name: string;
permission?: Record<string, unknown>;
} }
interface ScimClientActorMetadata {} interface ScimClientActorMetadata {}
@@ -966,6 +968,7 @@ interface LoginIdentityOidcAuthEvent {
identityId: string; identityId: string;
identityOidcAuthId: string; identityOidcAuthId: string;
identityAccessTokenId: string; identityAccessTokenId: string;
oidcClaimsReceived: Record<string, unknown>;
}; };
} }
@@ -1,5 +1,6 @@
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { isCertChainValid } from "@app/services/certificate/certificate-fns"; import { isCertChainValid } from "@app/services/certificate/certificate-fns";
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; 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 certTemplate = await certificateTemplateDAL.findById(certificateTemplateId);
const leafCertificate = decodeURIComponent(sslClientCert).match( const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0];
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g
)?.[0];
if (!leafCertificate) { if (!leafCertificate) {
throw new UnauthorizedError({ message: "Missing client certificate" }); throw new UnauthorizedError({ message: "Missing client certificate" });
@@ -88,10 +87,7 @@ export const certificateEstServiceFactory = ({
const verifiedChains = await Promise.all( const verifiedChains = await Promise.all(
caCertChains.map((chain) => { caCertChains.map((chain) => {
const caCert = new x509.X509Certificate(chain.certificate); const caCert = new x509.X509Certificate(chain.certificate);
const caChain = const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || [];
chain.certificateChain
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)
?.map((c) => new x509.X509Certificate(c)) || [];
return isCertChainValid([cert, caCert, ...caChain]); return isCertChainValid([cert, caCert, ...caChain]);
}) })
@@ -172,19 +168,15 @@ export const certificateEstServiceFactory = ({
} }
if (!estConfig.disableBootstrapCertValidation) { if (!estConfig.disableBootstrapCertValidation) {
const caCerts = estConfig.caChain const caCerts = extractX509CertFromChain(estConfig.caChain)?.map((cert) => {
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) return new x509.X509Certificate(cert);
?.map((cert) => { });
return new x509.X509Certificate(cert);
});
if (!caCerts) { if (!caCerts) {
throw new BadRequestError({ message: "Failed to parse certificate chain" }); throw new BadRequestError({ message: "Failed to parse certificate chain" });
} }
const leafCertificate = decodeURIComponent(sslClientCert).match( const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0];
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g
)?.[0];
if (!leafCertificate) { if (!leafCertificate) {
throw new BadRequestError({ message: "Missing client certificate" }); throw new BadRequestError({ message: "Missing client certificate" });
@@ -250,13 +242,7 @@ export const certificateEstServiceFactory = ({
kmsService kmsService
}); });
const certificates = caCertChain const certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert));
.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 caCertificate = new x509.X509Certificate(caCert); const caCertificate = new x509.X509Certificate(caCert);
return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]);
@@ -183,7 +183,7 @@ export const dynamicSecretLeaseServiceFactory = ({
}); });
const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); 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` }); 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); 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` }); throw new NotFoundError({ message: `Dynamic secret lease with ID '${leaseId}' not found` });
const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; const dynamicSecretCfg = dynamicSecretLease.dynamicSecret;
@@ -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 { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { getDbConnectionHost } from "@app/lib/knex"; 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 appCfg = getConfig();
const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); // if (appCfg.NODE_ENV === "development") return ["host.docker.internal"]; // incase you want to remove this check in dev
// no need for validation when it's dev
if (appCfg.NODE_ENV === "development") return;
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 ( // get host db ip
appCfg.isCloud && const exclusiveIps: string[] = [];
!isGateway && for await (const el of reservedHosts) {
// localhost if (el) {
// internal ips if (net.isIPv4(el)) {
(host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) exclusiveIps.push(el);
) } else {
throw new BadRequestError({ message: "Invalid db host" }); const resolvedIps = await dns.resolve4(el);
exclusiveIps.push(...resolvedIps);
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" });
} }
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;
}; };
@@ -13,6 +13,7 @@ import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; 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. // 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)); CreateElastiCacheUserSchema.parse(JSON.parse(providerInputs.creationStatement));
DeleteElasticCacheUserSchema.parse(JSON.parse(providerInputs.revocationStatement)); 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; return providerInputs;
}; };
@@ -3,9 +3,10 @@ import handlebars from "handlebars";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; 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"; import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models";
const generatePassword = (size = 48) => { const generatePassword = (size = 48) => {
@@ -20,14 +21,28 @@ const generateUsername = () => {
export const CassandraProvider = (): TDynamicProviderFns => { export const CassandraProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs);
if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") { const hostIps = await Promise.all(
throw new BadRequestError({ message: "Invalid db host" }); 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<typeof DynamicSecretCassandraSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretCassandraSchema> & { hostIps: string[] }) => {
const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined;
const client = new cassandra.Client({ const client = new cassandra.Client({
sslOptions, sslOptions,
@@ -40,7 +55,7 @@ export const CassandraProvider = (): TDynamicProviderFns => {
}, },
keyspace: providerInputs.keyspace, keyspace: providerInputs.keyspace,
localDataCenter: providerInputs?.localDataCenter, localDataCenter: providerInputs?.localDataCenter,
contactPoints: providerInputs.host.split(",").filter(Boolean) contactPoints: providerInputs.hostIps
}); });
return client; return client;
}; };
@@ -19,15 +19,14 @@ const generateUsername = () => {
export const ElasticSearchProvider = (): TDynamicProviderFns => { export const ElasticSearchProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host); const [hostIp] = await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp };
return providerInputs;
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema> & { hostIp: string }) => {
const connection = new ElasticSearchClient({ const connection = new ElasticSearchClient({
node: { node: {
url: new URL(`${providerInputs.host}:${providerInputs.port}`), url: new URL(`${providerInputs.hostIp}:${providerInputs.port}`),
...(providerInputs.ca && { ...(providerInputs.ca && {
ssl: { ssl: {
rejectUnauthorized: false, rejectUnauthorized: false,
@@ -19,15 +19,15 @@ const generateUsername = () => {
export const MongoDBProvider = (): TDynamicProviderFns => { export const MongoDBProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs); const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host); const [hostIp] = await verifyHostInputValidity(providerInputs.host);
return providerInputs; return { ...providerInputs, hostIp };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoDBSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoDBSchema> & { hostIp: string }) => {
const isSrv = !providerInputs.port; const isSrv = !providerInputs.port;
const uri = isSrv const uri = isSrv
? `mongodb+srv://${providerInputs.host}` ? `mongodb+srv://${providerInputs.hostIp}`
: `mongodb://${providerInputs.host}:${providerInputs.port}`; : `mongodb://${providerInputs.hostIp}:${providerInputs.port}`;
const client = new MongoClient(uri, { const client = new MongoClient(uri, {
auth: { auth: {
@@ -3,7 +3,6 @@ import https from "https";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { removeTrailingSlash } from "@app/lib/fn";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
@@ -79,14 +78,13 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa
export const RabbitMqProvider = (): TDynamicProviderFns => { export const RabbitMqProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host); const [hostIp] = await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp };
return providerInputs;
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema> & { hostIp: string }) => {
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: `${removeTrailingSlash(providerInputs.host)}:${providerInputs.port}/api`, baseURL: `${providerInputs.hostIp}:${providerInputs.port}/api`,
auth: { auth: {
username: providerInputs.username, username: providerInputs.username,
password: providerInputs.password password: providerInputs.password
@@ -5,6 +5,7 @@ import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models";
@@ -51,16 +52,28 @@ const executeTransactions = async (connection: Redis, commands: string[]): Promi
export const RedisDatabaseProvider = (): TDynamicProviderFns => { export const RedisDatabaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs); const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host); const [hostIp] = await verifyHostInputValidity(providerInputs.host);
return providerInputs; 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<typeof DynamicSecretRedisDBSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRedisDBSchema> & { hostIp: string }) => {
let connection: Redis | null = null; let connection: Redis | null = null;
try { try {
connection = new Redis({ connection = new Redis({
username: providerInputs.username, username: providerInputs.username,
host: providerInputs.host, host: providerInputs.hostIp,
port: providerInputs.port, port: providerInputs.port,
password: providerInputs.password, password: providerInputs.password,
...(providerInputs.ca && { ...(providerInputs.ca && {
@@ -5,6 +5,7 @@ import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models"; import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models";
@@ -27,14 +28,25 @@ export const SapAseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host); const [hostIp] = await verifyHostInputValidity(providerInputs.host);
return providerInputs; 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<typeof DynamicSecretSapAseSchema>, useMaster?: boolean) => { const $getClient = async (
providerInputs: z.infer<typeof DynamicSecretSapAseSchema> & { hostIp: string },
useMaster?: boolean
) => {
const connectionString = const connectionString =
`DRIVER={FreeTDS};` + `DRIVER={FreeTDS};` +
`SERVER=${providerInputs.host};` + `SERVER=${providerInputs.hostIp};` +
`PORT=${providerInputs.port};` + `PORT=${providerInputs.port};` +
`DATABASE=${useMaster ? "master" : providerInputs.database};` + `DATABASE=${useMaster ? "master" : providerInputs.database};` +
`UID=${providerInputs.username};` + `UID=${providerInputs.username};` +
@@ -83,7 +95,7 @@ export const SapAseProvider = (): TDynamicProviderFns => {
password 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) { for await (const query of queries) {
// If it's an adduser query, we need to first call sp_addlogin on the MASTER database. // 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 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 client = await $getClient(providerInputs);
const masterClient = await $getClient(providerInputs, true); const masterClient = await $getClient(providerInputs, true);
@@ -11,6 +11,7 @@ import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretSapHanaSchema, TDynamicProviderFns } from "./models"; import { DynamicSecretSapHanaSchema, TDynamicProviderFns } from "./models";
@@ -28,13 +29,24 @@ export const SapHanaProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs);
verifyHostInputValidity(providerInputs.host); const [hostIp] = await verifyHostInputValidity(providerInputs.host);
return providerInputs; 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<typeof DynamicSecretSapHanaSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapHanaSchema> & { hostIp: string }) => {
const client = hdb.createClient({ const client = hdb.createClient({
host: providerInputs.host, host: providerInputs.hostIp,
port: providerInputs.port, port: providerInputs.port,
user: providerInputs.username, user: providerInputs.username,
password: providerInputs.password, password: providerInputs.password,
@@ -5,6 +5,7 @@ import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models"; import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models";
@@ -31,6 +32,18 @@ const getDaysToExpiry = (expiryDate: Date) => {
export const SnowflakeProvider = (): TDynamicProviderFns => { export const SnowflakeProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSnowflakeSchema.parseAsync(inputs); 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; return providerInputs;
}; };
@@ -5,6 +5,7 @@ import { z } from "zod";
import { withGatewayProxy } from "@app/lib/gateway"; import { withGatewayProxy } from "@app/lib/gateway";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { verifyHostInputValidity } from "../dynamic-secret-fns";
@@ -117,8 +118,21 @@ type TSqlDatabaseProviderDTO = {
export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => { export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); 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<typeof DynamicSecretSqlDBSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSqlDBSchema>) => {
@@ -144,7 +158,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
} }
: undefined : undefined
}, },
acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT,
pool: { min: 0, max: 7 }
}); });
return db; return db;
}; };
@@ -178,7 +193,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
const validateConnection = async (inputs: unknown) => { const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
let isConnected = false; 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 }); const db = await $getClient({ ...providerInputs, port, host });
// oracle needs from keyword // oracle needs from keyword
const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1";
+77 -33
View File
@@ -3,8 +3,7 @@ import slugify from "@sindresorhus/slugify";
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-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 { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service"; 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 { TPermissionServiceFactory } from "../permission/permission-service";
import { TGroupDALFactory } from "./group-dal"; import { TGroupDALFactory } from "./group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns";
@@ -67,14 +67,14 @@ export const groupServiceFactory = ({
const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission( const { permission, membership } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups);
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan.groups) if (!plan.groups)
@@ -87,14 +87,26 @@ export const groupServiceFactory = ({
actorOrgId actorOrgId
); );
const isCustomRole = Boolean(customRole); 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)
if (!permissionBoundary.isValid) throw new PermissionBoundaryError({
throw new ForbiddenRequestError({ message: constructPermissionErrorMessage(
name: "PermissionBoundaryError", "Failed to create group",
message: "Failed to create a more privileged group", membership.shouldUseNewPrivilegeSystem,
details: { missingPermissions: permissionBoundary.missingPermissions } OrgPermissionGroupActions.GrantPrivileges,
}); OrgPermissionSubjects.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions }
});
}
const group = await groupDAL.transaction(async (tx) => { const group = await groupDAL.transaction(async (tx) => {
const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx);
@@ -133,14 +145,15 @@ export const groupServiceFactory = ({
}: TUpdateGroupDTO) => { }: TUpdateGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission( const { permission, membership } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan.groups) if (!plan.groups)
@@ -161,11 +174,21 @@ export const groupServiceFactory = ({
); );
const isCustomRole = Boolean(customOrgRole); const isCustomRole = Boolean(customOrgRole);
const permissionBoundary = validatePermissionBoundary(permission, rolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.GrantPrivileges,
OrgPermissionSubjects.Groups,
permission,
rolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update a more privileged group", "Failed to update group",
membership.shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.GrantPrivileges,
OrgPermissionSubjects.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
if (isCustomRole) customRole = customOrgRole; if (isCustomRole) customRole = customOrgRole;
@@ -215,7 +238,7 @@ export const groupServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups);
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -242,7 +265,7 @@ export const groupServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
const group = await groupDAL.findById(id); const group = await groupDAL.findById(id);
if (!group) { if (!group) {
@@ -275,7 +298,7 @@ export const groupServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
const group = await groupDAL.findOne({ const group = await groupDAL.findOne({
orgId: actorOrgId, orgId: actorOrgId,
@@ -303,14 +326,14 @@ export const groupServiceFactory = ({
const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => { const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission( const { permission, membership } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
// check if group with slug exists // check if group with slug exists
const group = await groupDAL.findOne({ const group = await groupDAL.findOne({
@@ -338,11 +361,22 @@ export const groupServiceFactory = ({
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
// check if user has broader or equal to privileges than group // 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to add user to more privileged group", "Failed to add user to more privileged group",
membership.shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.AddMembers,
OrgPermissionSubjects.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -374,14 +408,14 @@ export const groupServiceFactory = ({
}: TRemoveUserFromGroupDTO) => { }: TRemoveUserFromGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission( const { permission, membership } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
// check if group with slug exists // check if group with slug exists
const group = await groupDAL.findOne({ const group = await groupDAL.findOne({
@@ -409,11 +443,21 @@ export const groupServiceFactory = ({
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
// check if user has broader or equal to privileges than group // 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to delete user from more privileged group", "Failed to delete user from more privileged group",
membership.shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.RemoveMembers,
OrgPermissionSubjects.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -2,16 +2,17 @@ import { ForbiddenError, subject } from "@casl/ability";
import { packRules } from "@casl/ability/extra"; import { packRules } from "@casl/ability/extra";
import { ActionProjectType, TableName } from "@app/db/schemas"; import { ActionProjectType, TableName } from "@app/db/schemas";
import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns";
import { TPermissionServiceFactory } from "../permission/permission-service"; 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 { TIdentityProjectAdditionalPrivilegeV2DALFactory } from "./identity-project-additional-privilege-v2-dal";
import { import {
IdentityProjectAdditionalPrivilegeTemporaryMode, IdentityProjectAdditionalPrivilegeTemporaryMode,
@@ -64,10 +65,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId }) subject(ProjectPermissionSub.Identity, { identityId })
); );
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission({ const { permission: targetIdentityPermission, membership } = await permissionService.getProjectPermission({
actor: ActorType.IDENTITY, actor: ActorType.IDENTITY,
actorId: identityId, actorId: identityId,
projectId: identityProjectMembership.projectId, 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 // 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 // @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)); 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged identity", "Failed to update more privileged identity",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
validateHandlebarTemplate("Identity Additional Privilege Create", JSON.stringify(customPermission || []), {
allowedExpressions: (val) => val.includes("identity.")
});
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
slug, slug,
@@ -150,10 +164,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
); );
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission({ const { permission: targetIdentityPermission, membership } = await permissionService.getProjectPermission({
actor: ActorType.IDENTITY, actor: ActorType.IDENTITY,
actorId: identityProjectMembership.identityId, actorId: identityProjectMembership.identityId,
projectId: identityProjectMembership.projectId, 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 // 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 // @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 || [])); 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged identity", "Failed to update more privileged identity",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
validateHandlebarTemplate("Identity Additional Privilege Update", JSON.stringify(data.permissions || []), {
allowedExpressions: (val) => val.includes("identity.")
});
if (data?.slug) { if (data?.slug) {
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
slug: data.slug, slug: data.slug,
@@ -227,7 +255,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
message: `Failed to find identity with membership ${identityPrivilege.projectMembershipId}` message: `Failed to find identity with membership ${identityPrivilege.projectMembershipId}`
}); });
const { permission } = await permissionService.getProjectPermission({ const { permission, membership } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
projectId: identityProjectMembership.projectId, projectId: identityProjectMembership.projectId,
@@ -236,7 +264,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
); );
const { permission: identityRolePermission } = await permissionService.getProjectPermission({ const { permission: identityRolePermission } = await permissionService.getProjectPermission({
@@ -247,11 +275,21 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity,
permission,
identityRolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged identity", "Failed to update more privileged identity",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -287,7 +325,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionIdentityActions.Read,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
); );
@@ -322,7 +360,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionIdentityActions.Read,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
); );
@@ -358,7 +396,7 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionIdentityActions.Read,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
); );
@@ -2,16 +2,21 @@ import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability"
import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns";
import { TPermissionServiceFactory } from "../permission/permission-service"; 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 { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal";
import { import {
IdentityProjectAdditionalPrivilegeTemporaryMode, IdentityProjectAdditionalPrivilegeTemporaryMode,
@@ -63,7 +68,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
if (!identityProjectMembership) if (!identityProjectMembership)
throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission({ const { permission, membership } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
projectId: identityProjectMembership.projectId, projectId: identityProjectMembership.projectId,
@@ -71,8 +76,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId }) 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 // 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 // @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)); 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged identity", "Failed to update more privileged identity",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -102,6 +118,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
}); });
if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); 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)); const packedPermission = JSON.stringify(packRules(customPermission));
if (!dto.isTemporary) { if (!dto.isTemporary) {
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({
@@ -150,7 +170,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
if (!identityProjectMembership) if (!identityProjectMembership)
throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission({ const { permission, membership } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
projectId: identityProjectMembership.projectId, projectId: identityProjectMembership.projectId,
@@ -160,7 +180,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId }) 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 // 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 // @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 || [])); 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged identity", "Failed to update more privileged identity",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -203,6 +233,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
} }
const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; 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; const packedPermission = data.permissions ? JSON.stringify(packRules(data.permissions)) : undefined;
if (isTemporary) { if (isTemporary) {
@@ -255,7 +288,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
if (!identityProjectMembership) if (!identityProjectMembership)
throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission({ const { permission, membership } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
projectId: identityProjectMembership.projectId, projectId: identityProjectMembership.projectId,
@@ -264,7 +297,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId }) subject(ProjectPermissionSub.Identity, { identityId })
); );
@@ -276,11 +309,21 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity,
permission,
identityRolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to edit more privileged identity", "Failed to edit more privileged identity",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionSub.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -327,7 +370,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionIdentityActions.Read,
subject(ProjectPermissionSub.Identity, { identityId }) subject(ProjectPermissionSub.Identity, { identityId })
); );
@@ -371,7 +414,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionIdentityActions.Read,
subject(ProjectPermissionSub.Identity, { identityId }) subject(ProjectPermissionSub.Identity, { identityId })
); );
+3 -2
View File
@@ -4,8 +4,9 @@ import crypto, { KeyObject } from "crypto";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; 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 { ms } from "@app/lib/ms";
import { isFQDN } from "@app/lib/validator/validate-url";
import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import { import {
@@ -665,7 +666,7 @@ export const kmipServiceFactory = ({
.split(",") .split(",")
.map((name) => name.trim()) .map((name) => name.trim())
.map((altName) => { .map((altName) => {
if (isValidHostname(altName)) { if (isFQDN(altName, { allow_wildcard: true })) {
return { return {
type: "dns", type: "dns",
value: altName value: altName
@@ -97,12 +97,14 @@ export const searchGroups = async (
res.on("searchEntry", (entry) => { res.on("searchEntry", (entry) => {
const dn = entry.dn.toString(); const dn = entry.dn.toString();
const regex = /cn=([^,]+)/; const cnStartIndex = dn.indexOf("cn=");
const match = dn.match(regex);
// parse the cn from the dn
const cn = (match && match[1]) as string;
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) => { res.on("error", (error) => {
ldapClient.unbind(); ldapClient.unbind();
@@ -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;
@@ -12,10 +12,13 @@ import { getConfig } from "@app/lib/config/env";
import { verifyOfflineLicense } from "@app/lib/crypto"; import { verifyOfflineLicense } from "@app/lib/crypto";
import { NotFoundError } from "@app/lib/errors"; import { NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { TOrgDALFactory } from "@app/services/org/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 { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums";
import { TLicenseDALFactory } from "./license-dal"; import { TLicenseDALFactory } from "./license-dal";
import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns"; import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns";
import { import {
@@ -28,6 +31,7 @@ import {
TFeatureSet, TFeatureSet,
TGetOrgBillInfoDTO, TGetOrgBillInfoDTO,
TGetOrgTaxIdDTO, TGetOrgTaxIdDTO,
TOfflineLicense,
TOfflineLicenseContents, TOfflineLicenseContents,
TOrgInvoiceDTO, TOrgInvoiceDTO,
TOrgLicensesDTO, TOrgLicensesDTO,
@@ -39,10 +43,12 @@ import {
} from "./license-types"; } from "./license-types";
type TLicenseServiceFactoryDep = { type TLicenseServiceFactoryDep = {
orgDAL: Pick<TOrgDALFactory, "findOrgById">; orgDAL: Pick<TOrgDALFactory, "findOrgById" | "countAllOrgMembers">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseDAL: TLicenseDALFactory; licenseDAL: TLicenseDALFactory;
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem">; keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem">;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
projectDAL: TProjectDALFactory;
}; };
export type TLicenseServiceFactory = ReturnType<typeof licenseServiceFactory>; export type TLicenseServiceFactory = ReturnType<typeof licenseServiceFactory>;
@@ -57,11 +63,14 @@ export const licenseServiceFactory = ({
orgDAL, orgDAL,
permissionService, permissionService,
licenseDAL, licenseDAL,
keyStore keyStore,
identityOrgMembershipDAL,
projectDAL
}: TLicenseServiceFactoryDep) => { }: TLicenseServiceFactoryDep) => {
let isValidLicense = false; let isValidLicense = false;
let instanceType = InstanceType.OnPrem; let instanceType = InstanceType.OnPrem;
let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures();
let selfHostedLicense: TOfflineLicense | null = null;
const appCfg = getConfig(); const appCfg = getConfig();
const licenseServerCloudApi = setupLicenseRequestWithStore( const licenseServerCloudApi = setupLicenseRequestWithStore(
@@ -125,6 +134,7 @@ export const licenseServiceFactory = ({
instanceType = InstanceType.EnterpriseOnPremOffline; instanceType = InstanceType.EnterpriseOnPremOffline;
logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`);
isValidLicense = true; isValidLicense = true;
selfHostedLicense = contents.license;
return; return;
} }
} }
@@ -348,10 +358,21 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found` message: `Organization with ID '${orgId}' not found`
}); });
} }
const { data } = await licenseServerCloudApi.request.get( if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` const { data } = await licenseServerCloudApi.request.get(
); `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing`
return data; );
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 // returns org current plan feature table
@@ -365,10 +386,41 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found` message: `Organization with ID '${orgId}' not found`
}); });
} }
const { data } = await licenseServerCloudApi.request.get( if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` 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) => { const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => {
@@ -44,6 +44,28 @@ export enum OrgPermissionGatewayActions {
DeleteGateways = "delete-gateways" 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 { export enum OrgPermissionSubjects {
Workspace = "workspace", Workspace = "workspace",
Role = "role", Role = "role",
@@ -80,10 +102,10 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Sso]
| [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.Scim]
| [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionActions, OrgPermissionSubjects.Ldap]
| [OrgPermissionActions, OrgPermissionSubjects.Groups] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
| [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
| [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
@@ -256,20 +278,28 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
can(OrgPermissionActions.Delete, 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.Read, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Delete, 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.Read, OrgPermissionSubjects.Kms);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Create, OrgPermissionSubjects.Kms);
@@ -316,7 +346,7 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); can(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
@@ -327,10 +357,10 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
@@ -49,6 +49,7 @@ export const permissionDALFactory = (db: TDbClient) => {
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.select( .select(
selectAllTableCols(TableName.OrgMembership), selectAllTableCols(TableName.OrgMembership),
db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization),
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("permissions").withSchema(TableName.OrgRoles),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
@@ -70,7 +71,8 @@ export const permissionDALFactory = (db: TDbClient) => {
OrgMembershipsSchema.extend({ OrgMembershipsSchema.extend({
permissions: z.unknown(), permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(), orgAuthEnforced: z.boolean().optional().nullable(),
customRoleSlug: z.string().optional().nullable() customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean()
}).parse(el), }).parse(el),
childrenMapper: [ childrenMapper: [
{ {
@@ -118,7 +120,9 @@ export const permissionDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.IdentityOrgMembership)) .select(selectAllTableCols(TableName.IdentityOrgMembership))
.select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced")) .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"))
.select("permissions") .select("permissions")
.select(db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization))
.first(); .first();
return membership; return membership;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "GetOrgIdentityPermission" }); 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("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project), db.ref("orgId").withSchema(TableName.Project),
db.ref("type").withSchema(TableName.Project).as("projectType"), 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({ const [userPermission] = sqlNestRelationships({
@@ -684,7 +689,8 @@ export const permissionDALFactory = (db: TDbClient) => {
groupMembershipCreatedAt, groupMembershipCreatedAt,
groupMembershipUpdatedAt, groupMembershipUpdatedAt,
membershipUpdatedAt, membershipUpdatedAt,
projectType projectType,
shouldUseNewPrivilegeSystem
}) => ({ }) => ({
orgId, orgId,
orgAuthEnforced, orgAuthEnforced,
@@ -694,7 +700,8 @@ export const permissionDALFactory = (db: TDbClient) => {
projectType, projectType,
id: membershipId || groupMembershipId, id: membershipId || groupMembershipId,
createdAt: membershipCreatedAt || groupMembershipCreatedAt, createdAt: membershipCreatedAt || groupMembershipCreatedAt,
updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt,
shouldUseNewPrivilegeSystem
}), }),
childrenMapper: [ childrenMapper: [
{ {
@@ -995,6 +1002,7 @@ export const permissionDALFactory = (db: TDbClient) => {
`${TableName.IdentityProjectMembership}.projectId`, `${TableName.IdentityProjectMembership}.projectId`,
`${TableName.Project}.id` `${TableName.Project}.id`
) )
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => { .leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder void queryBuilder
.on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) .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("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.ProjectRoles), db.ref("permissions").withSchema(TableName.ProjectRoles),
db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization),
db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"),
db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"),
db db
@@ -1045,7 +1054,8 @@ export const permissionDALFactory = (db: TDbClient) => {
membershipUpdatedAt, membershipUpdatedAt,
orgId, orgId,
identityName, identityName,
projectType projectType,
shouldUseNewPrivilegeSystem
}) => ({ }) => ({
id: membershipId, id: membershipId,
identityId, identityId,
@@ -1055,6 +1065,7 @@ export const permissionDALFactory = (db: TDbClient) => {
updatedAt: membershipUpdatedAt, updatedAt: membershipUpdatedAt,
orgId, orgId,
projectType, projectType,
shouldUseNewPrivilegeSystem,
// just a prefilled value // just a prefilled value
orgAuthEnforced: false orgAuthEnforced: false
}), }),
@@ -3,9 +3,11 @@ import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/abilit
import { z } from "zod"; import { z } from "zod";
import { TOrganizations } from "@app/db/schemas"; import { TOrganizations } from "@app/db/schemas";
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type";
import { OrgPermissionSet } from "./org-permission";
import { import {
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
ProjectPermissionSet, ProjectPermissionSet,
@@ -145,4 +147,57 @@ const escapeHandlebarsMissingDict = (obj: Record<string, string>, key: string) =
return new Proxy(obj, handler); 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
};
@@ -244,22 +244,20 @@ export const permissionServiceFactory = ({
const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges));
const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false });
const metadataKeyValuePair = escapeHandlebarsMissingDict( const unescapedMetadata = objectify(
objectify( userProjectPermission.metadata,
userProjectPermission.metadata, (i) => i.key,
(i) => i.key, (i) => i.value
(i) => i.value
),
"identity.metadata"
); );
const templateValue = { const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata");
id: userProjectPermission.userId, requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata });
username: userProjectPermission.username,
metadata: metadataKeyValuePair
};
const interpolateRules = templatedRules( const interpolateRules = templatedRules(
{ {
identity: templateValue identity: {
id: userProjectPermission.userId,
username: userProjectPermission.username,
metadata: metadataKeyValuePair
}
}, },
{ data: false } { data: false }
); );
@@ -331,15 +329,16 @@ export const permissionServiceFactory = ({
? escapeHandlebarsMissingDict(unescapedIdentityAuthInfo as never, "identity.auth") ? escapeHandlebarsMissingDict(unescapedIdentityAuthInfo as never, "identity.auth")
: {}; : {};
const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata");
const templateValue = {
id: identityProjectPermission.identityId, requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata, auth: unescapedIdentityAuthInfo });
username: identityProjectPermission.username,
metadata: metadataKeyValuePair,
auth: identityAuthInfo
};
const interpolateRules = templatedRules( const interpolateRules = templatedRules(
{ {
identity: templateValue identity: {
id: identityProjectPermission.identityId,
username: identityProjectPermission.username,
metadata: metadataKeyValuePair,
auth: identityAuthInfo
}
}, },
{ data: false } { data: false }
); );
@@ -398,14 +397,18 @@ export const permissionServiceFactory = ({
const scopes = ServiceTokenScopes.parse(serviceToken.scopes || []); const scopes = ServiceTokenScopes.parse(serviceToken.scopes || []);
return { return {
permission: buildServiceTokenProjectPermission(scopes, serviceToken.permissions), permission: buildServiceTokenProjectPermission(scopes, serviceToken.permissions),
membership: undefined membership: {
shouldUseNewPrivilegeSystem: true
}
}; };
}; };
type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE
? { ? {
permission: MongoAbility<ProjectPermissionSet, MongoQuery>; permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
membership: undefined; membership: {
shouldUseNewPrivilegeSystem: boolean;
};
hasRole: (arg: string) => boolean; hasRole: (arg: string) => boolean;
} // service token doesn't have both membership and roles } // service token doesn't have both membership and roles
: { : {
@@ -414,6 +417,7 @@ export const permissionServiceFactory = ({
orgAuthEnforced: boolean | null | undefined; orgAuthEnforced: boolean | null | undefined;
orgId: string; orgId: string;
roles: Array<{ role: string }>; roles: Array<{ role: string }>;
shouldUseNewPrivilegeSystem: boolean;
}; };
hasRole: (role: string) => boolean; hasRole: (role: string) => boolean;
}; };
@@ -440,14 +444,13 @@ export const permissionServiceFactory = ({
), ),
"identity.metadata" "identity.metadata"
); );
const templateValue = {
id: userProjectPermission.userId,
username: userProjectPermission.username,
metadata: metadataKeyValuePair
};
const interpolateRules = templatedRules( const interpolateRules = templatedRules(
{ {
identity: templateValue identity: {
id: userProjectPermission.userId,
username: userProjectPermission.username,
metadata: metadataKeyValuePair
}
}, },
{ data: false } { data: false }
); );
@@ -487,14 +490,13 @@ export const permissionServiceFactory = ({
), ),
"identity.metadata" "identity.metadata"
); );
const templateValue = {
id: identityProjectPermission.identityId,
username: identityProjectPermission.username,
metadata: metadataKeyValuePair
};
const interpolateRules = templatedRules( const interpolateRules = templatedRules(
{ {
identity: templateValue identity: {
id: identityProjectPermission.identityId,
username: identityProjectPermission.username,
metadata: metadataKeyValuePair
}
}, },
{ data: false } { data: false }
); );
@@ -43,6 +43,30 @@ export enum ProjectPermissionDynamicSecretActions {
Lease = "lease" 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 { export enum ProjectPermissionSecretSyncActions {
Read = "read", Read = "read",
Create = "create", Create = "create",
@@ -150,8 +174,8 @@ export type ProjectPermissionSet =
] ]
| [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member] | [ProjectPermissionMemberActions, ProjectPermissionSub.Member]
| [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionGroupActions, ProjectPermissionSub.Groups]
| [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Integrations]
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
| [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs]
@@ -162,7 +186,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ | [
ProjectPermissionActions, ProjectPermissionIdentityActions,
ProjectPermissionSub.Identity | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields) ProjectPermissionSub.Identity | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
] ]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
@@ -290,13 +314,13 @@ const GeneralPermissionSchema = [
}), }),
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.Member).describe("The entity this permission pertains to."), 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." "Describe what action an entity can take."
) )
}), }),
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.Groups).describe("The entity this permission pertains to."), 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." "Describe what action an entity can take."
) )
}), }),
@@ -510,7 +534,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."), 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." "Describe what action an entity can take."
), ),
conditions: IdentityManagementConditionSchema.describe( conditions: IdentityManagementConditionSchema.describe(
@@ -531,12 +555,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretImports,
ProjectPermissionSub.SecretApproval, ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.SecretRotation, ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Member,
ProjectPermissionSub.Groups,
ProjectPermissionSub.Role, ProjectPermissionSub.Role,
ProjectPermissionSub.Integrations, ProjectPermissionSub.Integrations,
ProjectPermissionSub.Webhooks, ProjectPermissionSub.Webhooks,
ProjectPermissionSub.Identity,
ProjectPermissionSub.ServiceTokens, ProjectPermissionSub.ServiceTokens,
ProjectPermissionSub.Settings, ProjectPermissionSub.Settings,
ProjectPermissionSub.Environments, 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( can(
[ [
ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSecretActions.DescribeAndReadValue,
@@ -677,9 +731,9 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); 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( can(
[ [
@@ -703,10 +757,10 @@ const buildMemberPermissionRules = () => {
can( can(
[ [
ProjectPermissionActions.Read, ProjectPermissionIdentityActions.Read,
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
ProjectPermissionActions.Create, ProjectPermissionIdentityActions.Create,
ProjectPermissionActions.Delete ProjectPermissionIdentityActions.Delete
], ],
ProjectPermissionSub.Identity ProjectPermissionSub.Identity
); );
@@ -820,12 +874,12 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); can(ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity);
can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments);
@@ -2,15 +2,20 @@ import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import { ActionProjectType, TableName } from "@app/db/schemas"; import { ActionProjectType, TableName } from "@app/db/schemas";
import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns";
import { TPermissionServiceFactory } from "../permission/permission-service"; 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 { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal";
import { import {
ProjectUserAdditionalPrivilegeTemporaryMode, ProjectUserAdditionalPrivilegeTemporaryMode,
@@ -63,8 +68,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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({ const { permission: targetUserPermission, membership } = await permissionService.getProjectPermission({
actor: ActorType.USER, actor: ActorType.USER,
actorId: projectMembership.userId, actorId: projectMembership.userId,
projectId: projectMembership.projectId, 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 // 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 // @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)); 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged user", "Failed to update more privileged user",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionMemberActions.GrantPrivileges,
ProjectPermissionSub.Member
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -92,6 +107,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
if (existingSlug) if (existingSlug)
throw new BadRequestError({ message: `Additional privilege with provided slug ${slug} already exists` }); 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)); const packedPermission = JSON.stringify(packRules(customPermission));
if (!dto.isTemporary) { if (!dto.isTemporary) {
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ 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}'` 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, actor,
actorId, actorId,
projectId: projectMembership.projectId, projectId: projectMembership.projectId,
@@ -154,7 +173,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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({ const { permission: targetUserPermission } = await permissionService.getProjectPermission({
actor: ActorType.USER, actor: ActorType.USER,
actorId: projectMembership.userId, 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 // 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 // @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 || [])); 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) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to update more privileged identity", "Failed to update more privileged user",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionMemberActions.GrantPrivileges,
ProjectPermissionSub.Member
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -185,6 +214,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
throw new BadRequestError({ message: `Additional privilege with provided slug ${dto.slug} already exists` }); 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 isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary;
const packedPermission = dto.permissions && JSON.stringify(packRules(dto.permissions)); const packedPermission = dto.permissions && JSON.stringify(packRules(dto.permissions));
@@ -244,7 +277,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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); const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id);
return { return {
@@ -281,7 +314,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
return { return {
...userPrivilege, ...userPrivilege,
@@ -308,7 +341,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
const userPrivileges = await projectUserAdditionalPrivilegeDAL.find( const userPrivileges = await projectUserAdditionalPrivilegeDAL.find(
{ {
+1 -7
View File
@@ -29,15 +29,9 @@ export const parseScimFilter = (filterToParse: string | undefined) => {
attributeName = "name"; 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 = ({ export const buildScimUser = ({
orgMembershipId, orgMembershipId,
username, username,
@@ -62,7 +62,8 @@ export const secretApprovalPolicyServiceFactory = ({
projectId, projectId,
secretPath, secretPath,
environment, environment,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}: TCreateSapDTO) => { }: TCreateSapDTO) => {
const groupApprovers = approvers const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group) ?.filter((approver) => approver.type === ApproverType.Group)
@@ -113,7 +114,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}, },
tx tx
); );
@@ -172,7 +174,8 @@ export const secretApprovalPolicyServiceFactory = ({
actorAuthMethod, actorAuthMethod,
approvals, approvals,
secretPolicyId, secretPolicyId,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}: TUpdateSapDTO) => { }: TUpdateSapDTO) => {
const groupApprovers = approvers const groupApprovers = approvers
?.filter((approver) => approver.type === ApproverType.Group) ?.filter((approver) => approver.type === ApproverType.Group)
@@ -218,7 +221,8 @@ export const secretApprovalPolicyServiceFactory = ({
approvals, approvals,
secretPath, secretPath,
name, name,
enforcementLevel enforcementLevel,
allowedSelfApprovals
}, },
tx tx
); );
@@ -10,6 +10,7 @@ export type TCreateSapDTO = {
projectId: string; projectId: string;
name: string; name: string;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateSapDTO = { export type TUpdateSapDTO = {
@@ -19,6 +20,7 @@ export type TUpdateSapDTO = {
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[];
name?: string; name?: string;
enforcementLevel?: EnforcementLevel; enforcementLevel?: EnforcementLevel;
allowedSelfApprovals?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDeleteSapDTO = { export type TDeleteSapDTO = {
@@ -112,6 +112,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"),
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), 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("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt")
); );
@@ -150,7 +151,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel, enforcementLevel: el.policyEnforcementLevel,
envId: el.policyEnvId, envId: el.policyEnvId,
deletedAt: el.policyDeletedAt deletedAt: el.policyDeletedAt,
allowedSelfApprovals: el.policyAllowedSelfApprovals
} }
}), }),
childrenMapper: [ childrenMapper: [
@@ -336,6 +338,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
), ),
db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), 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("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"),
@@ -364,7 +367,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel enforcementLevel: el.policyEnforcementLevel,
allowedSelfApprovals: el.policyAllowedSelfApprovals
}, },
committerUser: { committerUser: {
userId: el.committerUserId, 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` `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("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
db.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"),
db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"),
db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
@@ -511,7 +516,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
name: el.policyName, name: el.policyName,
approvals: el.policyApprovals, approvals: el.policyApprovals,
secretPath: el.policySecretPath, secretPath: el.policySecretPath,
enforcementLevel: el.policyEnforcementLevel enforcementLevel: el.policyEnforcementLevel,
allowedSelfApprovals: el.policyAllowedSelfApprovals
}, },
committerUser: { committerUser: {
userId: el.committerUserId, userId: el.committerUserId,
@@ -352,6 +352,11 @@ export const secretApprovalRequestServiceFactory = ({
message: "The policy associated with this secret approval request has been deleted." 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({ const { hasRole } = await permissionService.getProjectPermission({
actor: ActorType.USER, actor: ActorType.USER,
@@ -8,23 +8,49 @@ import axios from "axios";
import jmespath from "jmespath"; import jmespath from "jmespath";
import knex from "knex"; import knex from "knex";
import { getConfig } from "@app/lib/config/env";
import { getDbConnectionHost } from "@app/lib/knex";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns";
import { TAssignOp, TDbProviderClients, TDirectAssignOp, THttpProviderFunction } from "../templates/types"; import { TAssignOp, TDbProviderClients, TDirectAssignOp, THttpProviderFunction } from "../templates/types";
import { TSecretRotationData, TSecretRotationDbFn } from "./secret-rotation-queue-types"; import { TSecretRotationData, TSecretRotationDbFn } from "./secret-rotation-queue-types";
const REGEX = /\${([^}]+)}/g;
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; 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) => { export const interpolate = (data: any, getValue: (key: string) => unknown) => {
if (!data) return; if (!data) return;
if (typeof data === "number") return data; if (typeof data === "number") return data;
if (typeof data === "string") { 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)) { if (typeof data === "object" && Array.isArray(data)) {
@@ -88,32 +114,14 @@ export const secretRotationDbFn = async ({
variables, variables,
options options
}: TSecretRotationDbFn) => { }: TSecretRotationDbFn) => {
const appCfg = getConfig();
const ssl = ca ? { rejectUnauthorized: false, ca } : undefined; 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 [hostIp] = await verifyHostInputValidity(host);
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 db = knex({ const db = knex({
client, client,
connection: { connection: {
database, database,
port, port,
host, host: hostIp,
user: username, user: username,
password, password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
@@ -8,7 +8,18 @@ type GetFullFolderPath = {
export const getFullFolderPath = async ({ folderDAL, folderId, envId }: GetFullFolderPath): Promise<string> => { export const getFullFolderPath = async ({ folderDAL, folderId, envId }: GetFullFolderPath): Promise<string> => {
// Helper function to remove duplicate slashes // 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 // Fetch all folders at once based on environment ID to avoid multiple queries
const folders = await folderDAL.find({ envId }); const folders = await folderDAL.find({ envId });
@@ -1,14 +1,34 @@
import { isIP } from "net";
import { isFQDN } from "@app/lib/validator/validate-url";
// Validates usernames or wildcard (*) // Validates usernames or wildcard (*)
export const isValidUserPattern = (value: string): boolean => { export const isValidUserPattern = (value: string): boolean => {
// Matches valid Linux usernames or a wildcard (*) // Length check before regex to prevent ReDoS
const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; 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); return userRegex.test(value);
}; };
// Validates hostnames, wildcard domains, or IP addresses // Validates hostnames, wildcard domains, or IP addresses
export const isValidHostPattern = (value: string): boolean => { export const isValidHostPattern = (value: string): boolean => {
// Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses // Input validation
const hostRegex = if (typeof value !== "string") return false;
/^(?:\*|\*\.[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); // 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
});
}; };
@@ -8,6 +8,7 @@ import { promisify } from "util";
import { TSshCertificateTemplates } from "@app/db/schemas"; import { TSshCertificateTemplates } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { import {
@@ -18,6 +19,7 @@ import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-type
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const EXEC_TIMEOUT_MS = 10000; // 10 seconds
/* eslint-disable no-bitwise */ /* eslint-disable no-bitwise */
export const createSshCertSerialNumber = () => { export const createSshCertSerialNumber = () => {
const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits
@@ -64,7 +66,9 @@ export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => {
// Generate the SSH key pair // Generate the SSH key pair
// The "-N ''" sets an empty passphrase // The "-N ''" sets an empty passphrase
// The keys are created in the temporary directory // 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 // Read the generated keys
const publicKey = await fs.readFile(publicKeyFile, "utf8"); const publicKey = await fs.readFile(publicKeyFile, "utf8");
@@ -87,7 +91,10 @@ export const getSshPublicKey = async (privateKey: string) => {
await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 });
// Run ssh-keygen to extract the public key // 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(); return stdout.trim();
} finally { } finally {
// Ensure that files and the temporary directory are cleaned up // 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 // 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({ throw new BadRequestError({
message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.` 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. * that it only contains alphanumeric characters with no spaces.
*/ */
export const validateSshCertificateKeyId = (keyId: string) => { export const validateSshCertificateKeyId = (keyId: string) => {
const regex = /^[A-Za-z0-9-]+$/; const regex = characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen]);
if (!regex.test(keyId)) { if (!regex(keyId)) {
throw new BadRequestError({ throw new BadRequestError({
message: message:
"Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces." "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 { try {
await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 }); 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) { } catch (error) {
throw new BadRequestError({ throw new BadRequestError({
message: "Failed to validate SSH public key format: could not be parsed." 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 }); await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 });
// Execute the signing process // 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 // Read the signed public key from the generated cert file
const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8");
+6 -4
View File
@@ -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.", 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.", caCert: "The PEM-encoded CA cert for the Kubernetes API server.",
tokenReviewerJwt: 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: allowedNamespaces:
"The comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", "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.", 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.", 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.", caCert: "The new PEM-encoded CA cert for the Kubernetes API server.",
tokenReviewerJwt: 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: allowedNamespaces:
"The new comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", "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.", 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.", workspaceId: "The ID of the project to list folders from.",
environment: "The slug of the environment to list folders from.", environment: "The slug of the environment to list folders from.",
path: "The path 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: { GET_BY_ID: {
folderId: "The ID of the folder to get details." 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.", search: "The text string to filter secret keys and folder names by.",
includeSecrets: "Whether to include project secrets in the response.", includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders 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: { SECRET_DETAILS_LIST: {
projectId: "The ID of the project to list secrets/folders from.", projectId: "The ID of the project to list secrets/folders from.",
+2 -2
View File
@@ -28,8 +28,8 @@ export const createDigestAuthRequestInterceptor = (
nc += 1; nc += 1;
const nonceCount = nc.toString(16).padStart(8, "0"); const nonceCount = nc.toString(16).padStart(8, "0");
const cnonce = crypto.randomBytes(24).toString("hex"); const cnonce = crypto.randomBytes(24).toString("hex");
const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -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].replace(/"/g, ""); 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 ha1 = crypto.createHash("md5").update(`${username}:${realm}:${password}`).digest("hex");
const path = opts.url; const path = opts.url;
+27 -18
View File
@@ -1,26 +1,35 @@
// Credit: https://github.com/miguelmota/is-base64 type Base64Options = {
export const isBase64 = ( urlSafe?: boolean;
v: string, padding?: boolean;
opts = { allowEmpty: false, mimeRequired: false, allowMime: true, paddingRequired: false } };
) => {
if (opts.allowEmpty === false && v === "") { const base64WithPadding = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/;
return false; 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}=)?"; // Default padding to true unless urlSafe is true
const mimeRegex = "(data:\\w+\\/[a-zA-Z\\+\\-\\.]+;base64,)"; const opts: Base64Options = {
urlSafe: false,
padding: options.urlSafe === undefined ? true : !options.urlSafe,
...options
};
if (opts.mimeRequired === true) { if (str === "") return true;
regex = mimeRegex + regex;
} else if (opts.allowMime === true) { let regex;
regex = `${mimeRegex}?${regex}`; if (opts.urlSafe) {
regex = opts.padding ? base64UrlWithPadding : base64UrlWithoutPadding;
} else {
regex = opts.padding ? base64WithPadding : base64WithoutPadding;
} }
if (opts.paddingRequired === false) { return (!opts.padding || str.length % 4 === 0) && regex.test(str);
regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}(==)?|[A-Za-z0-9+\\/]{3}=?)?";
}
return new RegExp(`^${regex}$`, "gi").test(v);
}; };
export const getBase64SizeInBytes = (base64String: string) => { export const getBase64SizeInBytes = (base64String: string) => {
@@ -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-----`
]);
});
});
@@ -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;
};
+1
View File
@@ -56,6 +56,7 @@ const envSchema = z
// TODO(akhilmhdh): will be changed to one // TODO(akhilmhdh): will be changed to one
ENCRYPTION_KEY: zpStr(z.string().optional()), ENCRYPTION_KEY: zpStr(z.string().optional()),
ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()),
QUEUE_WORKERS_ENABLED: zodStrBool.default("true"),
HTTPS_ENABLED: zodStrBool, HTTPS_ENABLED: zodStrBool,
// smtp options // smtp options
SMTP_HOST: zpStr(z.string().optional()), SMTP_HOST: zpStr(z.string().optional()),
+17
View File
@@ -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 { export class BadRequestError extends Error {
name: string; name: string;
+1
View File
@@ -93,6 +93,7 @@ export const pingGatewayAndVerify = async ({
let lastError: Error | null = null; let lastError: Error | null = null;
const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => {
throw new BadRequestError({ throw new BadRequestError({
message: (err as Error)?.message,
error: err as Error error: err as Error
}); });
}); });
-6
View File
@@ -107,12 +107,6 @@ export const isValidIp = (ip: string) => {
return net.isIPv4(ip) || net.isIPv6(ip); 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 = { export type TIp = {
ipAddress: string; ipAddress: string;
type: IPType; type: IPType;
+61
View File
@@ -0,0 +1,61 @@
import { BlockList } from "node:net";
import { BadRequestError } from "../errors";
// Define BlockList instances for each range type
const ipv4RangeLists: Record<string, BlockList> = {
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";
@@ -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}` });
});
};
@@ -1,5 +1,11 @@
import { CharacterType, characterValidator } from "./validate-string";
// regex to allow only alphanumeric, dash, underscore // 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) => export const isValidSecretPath = (path: string) =>
path path
@@ -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();
});
});
@@ -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, string> = {
[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);
};
};
@@ -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();
});
});
+113 -14
View File
@@ -1,18 +1,117 @@
import { getConfig } from "../config/env"; import dns from "node:dns/promises";
import { isIPv4 } from "net";
import { BadRequestError } from "../errors"; 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 validUrl = new URL(url);
const appCfg = getConfig(); const inputHostIps: string[] = [];
// on cloud local ips are not allowed if (isIPv4(validUrl.host)) {
if ( inputHostIps.push(validUrl.host);
appCfg.isCloud && } else {
(validUrl.host === "host.docker.internal" || if (validUrl.host === "localhost" || validUrl.host === "host.docker.internal") {
validUrl.host.match(/^10\.\d+\.\d+\.\d+/) || throw new BadRequestError({ message: "Local IPs not allowed as URL" });
validUrl.host.match(/^192\.168\.\d+\.\d+/)) }
) const resolvedIps = await dns.resolve4(validUrl.host);
throw new BadRequestError({ message: "Local IPs not allowed as URL" }); inputHostIps.push(...resolvedIps);
}
if (validUrl.host === "localhost" || validUrl.host === "127.0.0.1") const isInternalIp = inputHostIps.some((el) => isPrivateIp(el));
throw new BadRequestError({ message: "Localhost not allowed" }); 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;
});
}; };
+12 -4
View File
@@ -272,10 +272,13 @@ export const queueServiceFactory = (
connection connection
}); });
workerContainer[name] = new Worker<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>(name, jobFn, { const appCfg = getConfig();
...queueSettings, if (appCfg.QUEUE_WORKERS_ENABLED) {
connection workerContainer[name] = new Worker<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>(name, jobFn, {
}); ...queueSettings,
connection
});
}
}; };
const startPg = async <T extends QueueName>( const startPg = async <T extends QueueName>(
@@ -307,6 +310,11 @@ export const queueServiceFactory = (
event: U, event: U,
listener: WorkerListener<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>[U] listener: WorkerListener<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>[U]
) => { ) => {
const appCfg = getConfig();
if (!appCfg.QUEUE_WORKERS_ENABLED) {
return;
}
const worker = workerContainer[name]; const worker = workerContainer[name];
worker.on(event, listener); worker.on(event, listener);
}; };
+12 -1
View File
@@ -1,6 +1,8 @@
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { z } from "zod"; import { z } from "zod";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
interface SlugSchemaInputs { interface SlugSchemaInputs {
min?: number; min?: number;
max?: number; max?: number;
@@ -27,4 +29,13 @@ export const GenericResourceNameSchema = z
.trim() .trim()
.min(1, { message: "Name must be at least 1 character" }) .min(1, { message: "Name must be at least 1 character" })
.max(64, { message: "Name must be 64 or fewer characters" }) .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"
);
+2 -1
View File
@@ -13,6 +13,7 @@ import {
InternalServerError, InternalServerError,
NotFoundError, NotFoundError,
OidcAuthError, OidcAuthError,
PermissionBoundaryError,
RateLimitError, RateLimitError,
ScimRequestError, ScimRequestError,
UnauthorizedError UnauthorizedError
@@ -117,7 +118,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
conditions: el.conditions conditions: el.conditions
})) }))
}); });
} else if (error instanceof ForbiddenRequestError) { } else if (error instanceof ForbiddenRequestError || error instanceof PermissionBoundaryError) {
void res.status(HttpStatusCodes.Forbidden).send({ void res.status(HttpStatusCodes.Forbidden).send({
reqId: req.id, reqId: req.id,
statusCode: HttpStatusCodes.Forbidden, statusCode: HttpStatusCodes.Forbidden,
+1 -1
View File
@@ -65,7 +65,7 @@ export const registerSecretScannerGhApp = async (server: FastifyZodProvider) =>
payload: JSON.stringify(req.body), payload: JSON.stringify(req.body),
signature: signatureSHA256 signature: signatureSHA256
}); });
void res.send("ok"); return res.send("ok");
} }
}); });
} }
+2 -2
View File
@@ -34,7 +34,7 @@ export const registerServeUI = async (
TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED
}; };
const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; 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(); reply.callNotFound();
return; return;
} }
void reply.sendFile("index.html"); return reply.sendFile("index.html");
} }
}); });
} }
+25 -1
View File
@@ -1,5 +1,6 @@
import { CronJob } from "cron"; import { CronJob } from "cron";
import { Knex } from "knex"; import { Knex } from "knex";
import { monitorEventLoopDelay } from "perf_hooks";
import { z } from "zod"; import { z } from "zod";
import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; 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 { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
import { TKeyStoreFactory } from "@app/keystore/keystore"; import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { getConfig, TEnvConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { TQueueServiceFactory } from "@app/queue"; import { TQueueServiceFactory } from "@app/queue";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue";
@@ -246,6 +248,9 @@ import { registerV1Routes } from "./v1";
import { registerV2Routes } from "./v2"; import { registerV2Routes } from "./v2";
import { registerV3Routes } from "./v3"; import { registerV3Routes } from "./v3";
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
export const registerRoutes = async ( export const registerRoutes = async (
server: FastifyZodProvider, server: FastifyZodProvider,
{ {
@@ -408,7 +413,14 @@ export const registerRoutes = async (
serviceTokenDAL, serviceTokenDAL,
projectDAL projectDAL
}); });
const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); const licenseService = licenseServiceFactory({
permissionService,
orgDAL,
licenseDAL,
keyStore,
identityOrgMembershipDAL,
projectDAL
});
const hsmService = hsmServiceFactory({ const hsmService = hsmServiceFactory({
hsmModule, hsmModule,
@@ -1630,6 +1642,18 @@ export const registerRoutes = async (
const cfg = getConfig(); const cfg = getConfig();
const serverCfg = await getServerCfg(); 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 { // try {
// await db.raw("SELECT NOW()"); // await db.raw("SELECT NOW()");
// } catch (err) { // } catch (err) {
@@ -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( export const sapPubSchema = SecretApprovalPoliciesSchema.merge(
z.object({ z.object({
environment: z.object({ environment: z.object({
@@ -16,7 +16,12 @@ import { secretsLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getUserAgentType } from "@app/server/plugins/audit-log";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; 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 { AuthMode } from "@app/services/auth/auth-type";
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; 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 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) => { const parseSecretPathSearch = (search?: string) => {
if (!search) if (!search)
return { return {
@@ -109,6 +100,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(), search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(),
includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets),
includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders),
includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports),
includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets)
}), }),
response: { response: {
@@ -124,9 +116,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}) })
.array() .array()
.optional(), .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(), totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(), totalSecretCount: z.number().optional(),
totalImportCount: z.number().optional(),
totalCount: z.number() totalCount: z.number()
}) })
} }
@@ -143,6 +143,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
orderDirection, orderDirection,
includeFolders, includeFolders,
includeSecrets, includeSecrets,
includeImports,
includeDynamicSecrets includeDynamicSecrets
} = req.query; } = req.query;
@@ -159,6 +160,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let remainingLimit = limit; let remainingLimit = limit;
let adjustedOffset = offset; let adjustedOffset = offset;
let imports: Awaited<ReturnType<typeof server.services.secretImport.getImportsMultiEnv>> | undefined;
let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined; let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined; let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined;
let dynamicSecrets: let dynamicSecrets:
@@ -168,6 +170,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let totalFolderCount: number | undefined; let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined; let totalDynamicSecretCount: number | undefined;
let totalSecretCount: 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) { if (includeFolders) {
// this is the unique count, ie duplicate folders across envs only count as 1 // 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, folders,
dynamicSecrets, dynamicSecrets,
secrets, secrets,
imports,
totalFolderCount, totalFolderCount,
totalDynamicSecretCount, totalDynamicSecretCount,
totalImportCount,
totalSecretCount, totalSecretCount,
totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) totalCount:
(totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0)
}; };
} }
}); });
@@ -24,7 +24,7 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick(
allowedAudience: true allowedAudience: true
}).extend({ }).extend({
caCert: z.string(), caCert: z.string(),
tokenReviewerJwt: z.string() tokenReviewerJwt: z.string().optional().nullable()
}); });
export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => { export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => {
@@ -98,7 +98,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
.object({ .object({
kubernetesHost: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.kubernetesHost), kubernetesHost: z.string().trim().min(1).describe(KUBERNETES_AUTH.ATTACH.kubernetesHost),
caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), 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 allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation
allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames), allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames),
allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience), allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience),
@@ -195,7 +195,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide
.object({ .object({
kubernetesHost: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.kubernetesHost), kubernetesHost: z.string().trim().min(1).optional().describe(KUBERNETES_AUTH.UPDATE.kubernetesHost),
caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), 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 allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation
allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames), allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames),
allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience), allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience),
@@ -32,6 +32,8 @@ const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.pick({
caCert: z.string() caCert: z.string()
}); });
const MAX_OIDC_CLAIM_SIZE = 32_768;
export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) => { export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "POST", method: "POST",
@@ -55,7 +57,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider)
} }
}, },
handler: async (req) => { handler: async (req) => {
const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg } = const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg, oidcTokenData } =
await server.services.identityOidcAuth.login({ await server.services.identityOidcAuth.login({
identityId: req.body.identityId, identityId: req.body.identityId,
jwt: req.body.jwt jwt: req.body.jwt
@@ -69,7 +71,11 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider)
metadata: { metadata: {
identityId: identityOidcAuth.identityId, identityId: identityOidcAuth.identityId,
identityAccessTokenId: identityAccessToken.id, 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." }
} }
} }
}); });
@@ -4,7 +4,6 @@ import {
AuditLogsSchema, AuditLogsSchema,
GroupsSchema, GroupsSchema,
IncidentContactsSchema, IncidentContactsSchema,
OrganizationsSchema,
OrgMembershipsSchema, OrgMembershipsSchema,
OrgRolesSchema, OrgRolesSchema,
UsersSchema UsersSchema
@@ -57,7 +56,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
organization: OrganizationsSchema organization: sanitizedOrganizationSchema
}) })
} }
}, },
@@ -263,7 +262,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
message: z.string(), message: z.string(),
organization: OrganizationsSchema organization: sanitizedOrganizationSchema
}) })
} }
}, },
@@ -9,6 +9,8 @@ import { readLimit, secretsLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { booleanSchema } from "../sanitizedSchemas";
export const registerSecretFolderRouter = async (server: FastifyZodProvider) => { export const registerSecretFolderRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
url: "/", url: "/",
@@ -347,11 +349,14 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/") .default("/")
.transform(prefixWithSlash) .transform(prefixWithSlash)
.transform(removeTrailingSlash) .transform(removeTrailingSlash)
.describe(FOLDERS.LIST.directory) .describe(FOLDERS.LIST.directory),
recursive: booleanSchema.default(false).describe(FOLDERS.LIST.recursive)
}), }),
response: { response: {
200: z.object({ 200: z.object({
folders: SecretFoldersSchema.array() folders: SecretFoldersSchema.extend({
relativePath: z.string().optional()
}).array()
}) })
} }
}, },
@@ -1,7 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { import {
OrganizationsSchema,
OrgMembershipsSchema, OrgMembershipsSchema,
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectsSchema, ProjectsSchema,
@@ -15,6 +14,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ActorType, AuthMode } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
export const registerOrgRouter = async (server: FastifyZodProvider) => { export const registerOrgRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -335,7 +335,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
organization: OrganizationsSchema organization: sanitizedOrganizationSchema
}) })
} }
}, },
@@ -365,7 +365,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
organization: OrganizationsSchema, organization: sanitizedOrganizationSchema,
accessToken: z.string() accessToken: z.string()
}) })
} }
@@ -396,4 +396,30 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
return { organization, accessToken: tokens.accessToken }; 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 };
}
});
}; };
@@ -7,9 +7,11 @@ import { z } from "zod";
import { ActionProjectType, ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { ActionProjectType, ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; 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 { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { isFQDN } from "@app/lib/validator/validate-url";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
@@ -58,7 +60,6 @@ import {
TSignIntermediateDTO, TSignIntermediateDTO,
TUpdateCaDTO TUpdateCaDTO
} from "./certificate-authority-types"; } from "./certificate-authority-types";
import { hostnameRegex } from "./certificate-authority-validators";
type TCertificateAuthorityServiceFactoryDep = { type TCertificateAuthorityServiceFactoryDep = {
certificateAuthorityDAL: Pick< certificateAuthorityDAL: Pick<
@@ -1017,9 +1018,7 @@ export const certificateAuthorityServiceFactory = ({
const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength;
// validate imported certificate and certificate chain // validate imported certificate and certificate chain
const certificates = certificateChain const certificates = extractX509CertFromChain(certificateChain)?.map((cert) => new x509.X509Certificate(cert));
.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" }); 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 // check if the altName is a valid hostname
if (hostnameRegex.test(altName)) { if (isFQDN(altName, { allow_wildcard: true })) {
return { return {
type: "dns", type: "dns",
value: altName value: altName
@@ -1702,7 +1701,7 @@ export const certificateAuthorityServiceFactory = ({
} }
// check if the altName is a valid hostname // check if the altName is a valid hostname
if (hostnameRegex.test(altName)) { if (isFQDN(altName, { allow_wildcard: true })) {
return { return {
type: "dns", type: "dns",
value: altName value: altName
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { isValidIp } from "@app/lib/ip"; import { isValidIp } from "@app/lib/ip";
import { isFQDN } from "@app/lib/validator/validate-url";
const isValidDate = (dateString: string) => { const isValidDate = (dateString: string) => {
const date = new Date(dateString); 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 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 export const validateAltNamesField = z
.string() .string()
.trim() .trim()
@@ -27,7 +27,7 @@ export const validateAltNamesField = z
if (data === "") return true; if (data === "") return true;
// Split and validate each alt name // Split and validate each alt name
return data.split(", ").every((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);
}); });
}, },
{ {
@@ -11,6 +11,7 @@ export const validateCertificateDetailsAgainstTemplate = (
}, },
template: TCertificateTemplates template: TCertificateTemplates
) => { ) => {
// these are validated in router using validateTemplateRegexField
const commonNameRegex = new RegExp(template.commonName); const commonNameRegex = new RegExp(template.commonName);
if (!commonNameRegex.test(cert.commonName)) { if (!commonNameRegex.test(cert.commonName)) {
throw new BadRequestError({ throw new BadRequestError({
@@ -6,6 +6,7 @@ import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; 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 { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
@@ -281,9 +282,7 @@ export const certificateTemplateServiceFactory = ({
}); });
// validate CA chain // validate CA chain
const certificates = caChain const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert));
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)
?.map((cert) => new x509.X509Certificate(cert));
if (!certificates) { if (!certificates) {
throw new BadRequestError({ message: "Failed to parse certificate chain" }); throw new BadRequestError({ message: "Failed to parse certificate chain" });
@@ -379,9 +378,7 @@ export const certificateTemplateServiceFactory = ({
}; };
if (caChain) { if (caChain) {
const certificates = caChain const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert));
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)
?.map((cert) => new x509.X509Certificate(cert));
if (!certificates) { if (!certificates) {
throw new BadRequestError({ message: "Failed to parse certificate chain" }); throw new BadRequestError({ message: "Failed to parse certificate chain" });
@@ -1,13 +1,27 @@
import safe from "safe-regex"; import safe from "safe-regex";
import z from "zod"; import z from "zod";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
export const validateTemplateRegexField = z export const validateTemplateRegexField = z
.string() .string()
.min(1) .min(1)
.max(100) .max(100)
.regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { .refine(
message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." (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 // we ensure that the inputted pattern is computationally safe by limiting star height to 1
.refine((v) => safe(v), { .refine((v) => safe(v), {
message: "Unsafe REGEX pattern" message: "Unsafe REGEX pattern"
@@ -1,12 +1,15 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; 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 { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionGroupActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; 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 { groupBy } from "@app/lib/fn";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { isUuidV4 } from "@app/lib/validator"; 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) 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` }); 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, actor,
actorId, actorId,
projectId, projectId,
@@ -78,7 +81,7 @@ export const groupProjectServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Create, ProjectPermissionSub.Groups);
let group: TGroups | null = null; let group: TGroups | null = null;
if (isUuidV4(groupIdOrName)) { if (isUuidV4(groupIdOrName)) {
@@ -102,11 +105,21 @@ export const groupProjectServiceFactory = ({
project.id project.id
); );
const permissionBoundary = validatePermissionBoundary(permission, rolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionGroupActions.GrantPrivileges,
ProjectPermissionSub.Groups,
permission,
rolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to assign group to a more privileged role", "Failed to assign group to role",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionGroupActions.GrantPrivileges,
ProjectPermissionSub.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
} }
@@ -248,7 +261,7 @@ export const groupProjectServiceFactory = ({
if (!project) throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); 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, actor,
actorId, actorId,
projectId, projectId,
@@ -256,7 +269,7 @@ export const groupProjectServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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 }); const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId });
if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` }); if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` });
@@ -269,11 +282,21 @@ export const groupProjectServiceFactory = ({
requestedRoleChange, requestedRoleChange,
project.id project.id
); );
const permissionBoundary = validatePermissionBoundary(permission, rolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionGroupActions.GrantPrivileges,
ProjectPermissionSub.Groups,
permission,
rolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to assign group to a more privileged role", "Failed to assign group to role",
membership.shouldUseNewPrivilegeSystem,
ProjectPermissionGroupActions.GrantPrivileges,
ProjectPermissionSub.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
} }
@@ -360,7 +383,7 @@ export const groupProjectServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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 deletedProjectGroup = await groupProjectDAL.transaction(async (tx) => {
const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx); const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx);
@@ -405,7 +428,7 @@ export const groupProjectServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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); const groupMemberships = await groupProjectDAL.findByProjectId(project.id);
return groupMemberships; return groupMemberships;
@@ -433,7 +456,7 @@ export const groupProjectServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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, { const [groupMembership] = await groupProjectDAL.findByProjectId(project.id, {
groupId groupId
@@ -5,11 +5,14 @@ import jwt from "jsonwebtoken";
import { IdentityAuthMethod } from "@app/db/schemas"; import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; 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 { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; 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 { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -39,6 +42,31 @@ type TIdentityAwsAuthServiceFactoryDep = {
export type TIdentityAwsAuthServiceFactory = ReturnType<typeof identityAwsAuthServiceFactory>; export type TIdentityAwsAuthServiceFactory = ReturnType<typeof identityAwsAuthServiceFactory>;
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 "<your-access-key-id>/<date>/<aws-region>/<aws-service>/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 = ({ export const identityAwsAuthServiceFactory = ({
identityAccessTokenDAL, identityAccessTokenDAL,
identityAwsAuthDAL, identityAwsAuthDAL,
@@ -57,6 +85,9 @@ export const identityAwsAuthServiceFactory = ({
const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString());
const body: string = Buffer.from(iamRequestBody, "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 { const {
data: { data: {
GetCallerIdentityResponse: { GetCallerIdentityResponse: {
@@ -65,7 +96,7 @@ export const identityAwsAuthServiceFactory = ({
} }
}: { data: TGetCallerIdentityResponse } = await axios({ }: { data: TGetCallerIdentityResponse } = await axios({
method: iamHttpRequestMethod, method: iamHttpRequestMethod,
url: headers?.Host ? `https://${headers.Host}` : identityAwsAuth.stsEndpoint, url,
headers, headers,
data: body data: body
}); });
@@ -93,7 +124,8 @@ export const identityAwsAuthServiceFactory = ({
.some((principalArn) => { .some((principalArn) => {
// convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$"
// considers exact matches + wildcard matches // 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)); return regex.test(extractPrincipalArn(Arn));
}); });
@@ -175,7 +207,7 @@ export const identityAwsAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId 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 plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
@@ -254,7 +286,7 @@ export const identityAwsAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId 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 plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
@@ -308,7 +340,7 @@ export const identityAwsAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId };
}; };
@@ -326,14 +358,14 @@ export const identityAwsAuthServiceFactory = ({
message: "The identity does not have aws auth" message: "The identity does not have aws auth"
}); });
} }
const { permission } = await permissionService.getOrgPermission( const { permission, membership } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
identityMembershipOrg.orgId, identityMembershipOrg.orgId,
actorAuthMethod, actorAuthMethod,
actorOrgId 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 } = await permissionService.getOrgPermission(
ActorType.IDENTITY, ActorType.IDENTITY,
@@ -343,11 +375,22 @@ export const identityAwsAuthServiceFactory = ({
actorOrgId actorOrgId
); );
const permissionBoundary = validatePermissionBoundary(permission, rolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
OrgPermissionIdentityActions.RevokeAuth,
OrgPermissionSubjects.Identity,
permission,
rolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to revoke aws auth of identity with more privileged role", "Failed to revoke aws auth of identity with more privileged role",
membership.shouldUseNewPrivilegeSystem,
OrgPermissionIdentityActions.RevokeAuth,
OrgPermissionSubjects.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
@@ -1,6 +1,8 @@
import safe from "safe-regex";
import { z } from "zod"; import { z } from "zod";
const twelveDigitRegex = /^\d{12}$/; 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_.@+*/-]+|\*)$/; const arnRegex = /^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/;
export const validateAccountIds = z export const validateAccountIds = z
@@ -42,7 +44,8 @@ export const validatePrincipalArns = z
// Split the string by commas to check each supposed ARN // Split the string by commas to check each supposed ARN
const arns = data.split(","); const arns = data.split(",");
// Return true only if every item matches one of the allowed ARN formats // 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: message:
@@ -3,11 +3,14 @@ import jwt from "jsonwebtoken";
import { IdentityAuthMethod } from "@app/db/schemas"; import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; 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 { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; 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 { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -147,7 +150,7 @@ export const identityAzureAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId 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 plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
@@ -225,7 +228,7 @@ export const identityAzureAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId 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 plan = await licenseService.getPlan(identityMembershipOrg.orgId);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
@@ -281,7 +284,7 @@ export const identityAzureAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId };
}; };
@@ -300,14 +303,14 @@ export const identityAzureAuthServiceFactory = ({
message: "The identity does not have azure auth" message: "The identity does not have azure auth"
}); });
} }
const { permission } = await permissionService.getOrgPermission( const { permission, membership } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
identityMembershipOrg.orgId, identityMembershipOrg.orgId,
actorAuthMethod, actorAuthMethod,
actorOrgId 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 } = await permissionService.getOrgPermission(
ActorType.IDENTITY, ActorType.IDENTITY,
@@ -316,11 +319,21 @@ export const identityAzureAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const permissionBoundary = validatePermissionBoundary(permission, rolePermission); const permissionBoundary = validatePrivilegeChangeOperation(
membership.shouldUseNewPrivilegeSystem,
OrgPermissionIdentityActions.RevokeAuth,
OrgPermissionSubjects.Identity,
permission,
rolePermission
);
if (!permissionBoundary.isValid) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new PermissionBoundaryError({
name: "PermissionBoundaryError", message: constructPermissionErrorMessage(
message: "Failed to revoke azure auth of identity with more privileged role", "Failed to revoke azure auth of identity with more privileged role",
membership.shouldUseNewPrivilegeSystem,
OrgPermissionIdentityActions.RevokeAuth,
OrgPermissionSubjects.Identity
),
details: { missingPermissions: permissionBoundary.missingPermissions } details: { missingPermissions: permissionBoundary.missingPermissions }
}); });

Some files were not shown because too many files have changed in this diff Show More