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 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, 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)); // truemergePoliciesAny() — 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 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, 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 rateEmpty Policy Arrays
- Calling
mergePolicies()without any policies returnsfalse(denies by default). - Calling
mergePoliciesAny()without any policies also returnsfalse(denies by default).
const emptyAnd = mergePolicies(); // Always denies
const emptyOr = mergePoliciesAny(); // Always deniesBest Practices
- Name policies descriptively —
securityPolicy,ownerAccessPolicy,compliancePolicy - Keep policies focused — Each policy should handle one concern
- Document merge order — Explain why policies are merged in a specific order
- Test merged policies — Ensure the combined behavior is correct
- Consider performance — Fewer policies means fewer checks
Last updated on