Zap Studio

Merging Policies

Combine multiple policies with deny-overrides or allow-overrides strategies in @zap-studio/permit.

As your application grows, you may want to organize authorization logic into separate policies. @zap-studio/permit provides two strategies for merging policies: deny-overrides and allow-overrides.

Why Merge Policies?

Merging policies is useful when:

  • Separation of concerns — Keep domain-specific rules in separate files
  • Layered security — Apply base rules that can be tightened or relaxed
  • Feature flags — Enable/disable features by adding or removing policies
  • Multi-tenancy — Combine organization policies with application policies

mergePolicies() — Deny-Overrides Strategy

The mergePolicies() function combines policies using a deny-overrides strategy. An action is allowed only if all policies allow it.

import { mergePolicies } from "@zap-studio/permit";

const mergedPolicy = mergePolicies(policy1, policy2, policy3);

Think of it as an AND operation: policy1 AND policy2 AND policy3

Behavior

Policy 1Policy 2Result
allowallowallow
allowdenydeny
denyallowdeny
denydenydeny

Example: Base + Restrictive Policy

import { z } from "zod";
import { createPolicy, when, mergePolicies } from "@zap-studio/permit";
import type { Resources, Actions } from "@zap-studio/permit/types";

const resources = {
  document: z.object({
    id: z.string(),
    ownerId: z.string(),
    classification: z.enum(["public", "internal", "confidential"]),
  }),
} satisfies Resources;

const actions = {
  document: ["read", "write", "delete"],
} as const satisfies Actions<typeof resources>;

type AppContext = {
  user: { id: string; clearanceLevel: number } | null;
};

// Base policy: standard access rules
const basePolicy = createPolicy<AppContext>({
  resources,
  actions,
  rules: {
    document: {
      read: when((ctx, _, doc) =>
        doc.classification === "public" || ctx.user?.id === doc.ownerId
      ),
      write: when((ctx, _, doc) => ctx.user?.id === doc.ownerId),
      delete: when((ctx, _, doc) => ctx.user?.id === doc.ownerId),
    },
  },
});

// Security policy: additional clearance requirements
const securityPolicy = createPolicy<AppContext>({
  resources,
  actions,
  rules: {
    document: {
      read: when((ctx, _, doc) => {
        if (doc.classification === "confidential") {
          return (ctx.user?.clearanceLevel ?? 0) >= 3;
        }
        if (doc.classification === "internal") {
          return (ctx.user?.clearanceLevel ?? 0) >= 1;
        }
        return true;
      }),
      write: when((ctx, _, doc) => {
        if (doc.classification === "confidential") {
          return (ctx.user?.clearanceLevel ?? 0) >= 3;
        }
        return true;
      }),
      delete: when((ctx, _, doc) => {
        if (doc.classification === "confidential") {
          return (ctx.user?.clearanceLevel ?? 0) >= 4;
        }
        return true;
      }),
    },
  },
});

// Merge: both policies must allow
const policy = mergePolicies(basePolicy, securityPolicy);

// Usage
const confidentialDoc = {
  id: "doc-1",
  ownerId: "user-123",
  classification: "confidential" as const,
};

// Owner with low clearance: base allows, security denies → DENIED
const lowClearanceOwner: AppContext = {
  user: { id: "user-123", clearanceLevel: 1 },
};
console.log(policy.can(lowClearanceOwner, "read", "document", confidentialDoc)); // false

// Owner with high clearance: both allow → ALLOWED
const highClearanceOwner: AppContext = {
  user: { id: "user-123", clearanceLevel: 3 },
};
console.log(policy.can(highClearanceOwner, "read", "document", confidentialDoc)); // true

mergePoliciesAny() — Allow-Overrides Strategy

The mergePoliciesAny() function combines policies using an allow-overrides strategy. An action is allowed if any policy allows it.

import { mergePoliciesAny } from "@zap-studio/permit";

const mergedPolicy = mergePoliciesAny(policy1, policy2, policy3);

Think of it as an OR operation: policy1 OR policy2 OR policy3

Behavior

Policy 1Policy 2Result
allowallowallow
allowdenyallow
denyallowallow
denydenydeny

Example: Multiple Access Paths

import { z } from "zod";
import { createPolicy, when, mergePoliciesAny } from "@zap-studio/permit";
import type { Resources, Actions } from "@zap-studio/permit/types";

const resources = {
  file: z.object({
    id: z.string(),
    ownerId: z.string(),
    isPublic: z.boolean(),
    sharedWith: z.array(z.string()),
    teamId: z.string().nullable(),
  }),
} satisfies Resources;

const actions = {
  file: ["read", "write", "delete"],
} as const satisfies Actions<typeof resources>;

type FileContext = {
  user: { id: string; teamIds: string[] } | null;
};

// Owner access policy
const ownerPolicy = createPolicy<FileContext>({
  resources,
  actions,
  rules: {
    file: {
      read: when((ctx, _, file) => ctx.user?.id === file.ownerId),
      write: when((ctx, _, file) => ctx.user?.id === file.ownerId),
      delete: when((ctx, _, file) => ctx.user?.id === file.ownerId),
    },
  },
});

// Public access policy
const publicPolicy = createPolicy<FileContext>({
  resources,
  actions,
  rules: {
    file: {
      read: when((_, __, file) => file.isPublic),
      write: when(() => false),
      delete: when(() => false),
    },
  },
});

// Shared access policy
const sharedPolicy = createPolicy<FileContext>({
  resources,
  actions,
  rules: {
    file: {
      read: when((ctx, _, file) => file.sharedWith.includes(ctx.user?.id ?? "")),
      write: when((ctx, _, file) => file.sharedWith.includes(ctx.user?.id ?? "")),
      delete: when(() => false),
    },
  },
});

// Team access policy
const teamPolicy = createPolicy<FileContext>({
  resources,
  actions,
  rules: {
    file: {
      read: when((ctx, _, file) =>
        file.teamId !== null && (ctx.user?.teamIds.includes(file.teamId) ?? false)
      ),
      write: when((ctx, _, file) =>
        file.teamId !== null && (ctx.user?.teamIds.includes(file.teamId) ?? false)
      ),
      delete: when(() => false),
    },
  },
});

// Any of these policies can grant access
const filePolicy = mergePoliciesAny(ownerPolicy, publicPolicy, sharedPolicy, teamPolicy);

// Usage
const file = {
  id: "file-1",
  ownerId: "user-owner",
  isPublic: false,
  sharedWith: ["user-shared"],
  teamId: "team-1",
};

const sharedUser: FileContext = {
  user: { id: "user-shared", teamIds: [] },
};
console.log(filePolicy.can(sharedUser, "read", "file", file));   // true (via sharedPolicy)
console.log(filePolicy.can(sharedUser, "write", "file", file));  // true (via sharedPolicy)
console.log(filePolicy.can(sharedUser, "delete", "file", file)); // false (no policy allows)

Combining Both Strategies

You can combine mergePolicies and mergePoliciesAny for complex scenarios:

import { mergePolicies, mergePoliciesAny } from "@zap-studio/permit";

// Access layer: multiple paths to access
const accessPolicy = mergePoliciesAny(ownerPolicy, sharedPolicy, publicPolicy);

// Security layer: must pass all security checks
const securedPolicy = mergePolicies(auditPolicy, compliancePolicy, ratePolicy);

// Final policy: must have access AND pass security
const finalPolicy = mergePolicies(accessPolicy, securedPolicy);

Visualization

                   ┌─────────────┐
                   │ finalPolicy │
                   └──────┬──────┘
                          │ AND (mergePolicies)
            ┌─────────────┴─────────────┐
            │                           │
     ┌──────┴──────┐             ┌──────┴───────┐
     │accessPolicy │             │securedPolicy │
     └──────┬──────┘             └──────┬───────┘
            │ OR                        │ AND
    ┌───────┼───────┐           ┌───────┼───────┐
    │       │       │           │       │       │
 owner   shared   public     audit  compliance rate

Empty Policy Arrays

  • Calling mergePolicies() without any policies returns false (denies by default).
  • Calling mergePoliciesAny() without any policies also returns false (denies by default).
const emptyAnd = mergePolicies();    // Always denies
const emptyOr = mergePoliciesAny(); // Always denies

Best Practices

  1. Name policies descriptivelysecurityPolicy, ownerAccessPolicy, compliancePolicy
  2. Keep policies focused — Each policy should handle one concern
  3. Document merge order — Explain why policies are merged in a specific order
  4. Test merged policies — Ensure the combined behavior is correct
  5. Consider performance — Fewer policies means fewer checks
Edit on GitHub

Last updated on

On this page