Merging Policies
Combine multiple policies with AND or OR strategies in permit.
permit merges policies via mergePoliciesAnd and mergePoliciesOr. As your application grows, you may want to organize authorization logic into separate policies. permit provides two merge strategies: AND and OR.
Why Merge Policies?
Merging policies is useful for:
- 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
mergePoliciesAnd() — AND Strategy
The mergePoliciesAnd() function combines policies using an AND strategy. An action is allowed only if all policies allow it.
import { mergePoliciesAnd } from "@zap-studio/permit";
const mergedPolicy = mergePoliciesAnd(policy1, policy2, policy3);
Think of it as an AND operation: policy1 AND policy2 AND policy3
Behavior
| Policy 1 | Policy 2 | Result |
|---|---|---|
| allow | allow | allow |
| allow | deny | deny |
| deny | allow | deny |
| deny | deny | deny |
Example: Base + Restrictive Policy
import { z } from "zod";
import { createPolicy, when, mergePoliciesAnd } from "@zap-studio/permit";
import type { Resources, Actions } from "@zap-studio/permit";
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 = mergePoliciesAnd(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(await policy.can(lowClearanceOwner, "document:read", confidentialDoc)); // false
// Owner with high clearance: both allow → ALLOWED
const highClearanceOwner: AppContext = {
user: { id: "user-123", clearanceLevel: 3 },
};
console.log(await policy.can(highClearanceOwner, "document:read", confidentialDoc)); // true
mergePoliciesOr() — OR Strategy
The mergePoliciesOr() function combines policies using an OR strategy. An action is allowed if any policy allows it.
import { mergePoliciesOr } from "@zap-studio/permit";
const mergedPolicy = mergePoliciesOr(policy1, policy2, policy3);
Think of it as an OR operation: policy1 OR policy2 OR policy3
Behavior
| Policy 1 | Policy 2 | Result |
|---|---|---|
| allow | allow | allow |
| allow | deny | allow |
| deny | allow | allow |
| deny | deny | deny |
Example: Multiple Access Paths
import { z } from "zod";
import { createPolicy, when, mergePoliciesOr } from "@zap-studio/permit";
import type { Resources, Actions } from "@zap-studio/permit";
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 = mergePoliciesOr(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(await filePolicy.can(sharedUser, "file:read", file)); // true (via sharedPolicy)
console.log(await filePolicy.can(sharedUser, "file:write", file)); // true (via sharedPolicy)
console.log(await filePolicy.can(sharedUser, "file:delete", file)); // false (no policy allows)
Combining Both Strategies
You can combine mergePoliciesAnd() and mergePoliciesOr() for complex scenarios:
import { mergePoliciesAnd, mergePoliciesOr } from "@zap-studio/permit";
// Access layer: multiple paths to access
const accessPolicy = mergePoliciesOr(ownerPolicy, sharedPolicy, publicPolicy);
// Security layer: must pass all security checks
const securedPolicy = mergePoliciesAnd(auditPolicy, compliancePolicy, ratePolicy);
// Final policy: must have access AND pass security
const finalPolicy = mergePoliciesAnd(accessPolicy, securedPolicy);
Visualization
┌─────────────┐
│ finalPolicy │
└──────┬──────┘
│ AND (mergePoliciesAnd)
┌─────────────┴─────────────┐
│ │
┌──────┴──────┐ ┌──────┴───────┐
│accessPolicy │ │securedPolicy │
└──────┬──────┘ └──────┬───────┘
│ OR │ AND
┌───────┼───────┐ ┌───────┼───────┐
│ │ │ │ │ │
owner shared public audit compliance rate
Empty Policy Lists
Merging zero policies always denies, with either strategy:
const emptyAnd = mergePoliciesAnd(); // can() always resolves to false
const emptyOr = mergePoliciesOr(); // can() always resolves to false
Best Practices
- Name policies descriptively —
securityPolicy,ownerAccessPolicy,compliancePolicy - Keep policies focused — Each policy should handle one concern
- Document why each policy is included — Argument order has no effect on the result; explain each policy’s role instead of relying on its position
- Test merged policies — Ensure the combined behavior is correct
- Keep merged policies cheap — Every policy runs on every check, even after another has already decided the outcome; avoid expensive work (I/O, heavy computation) inside a merged policy’s rules