What monolith authorization design actually involves: definition, decision, enforcement
Authorization in a monolith is three separate jobs, and most design debates go wrong because they treat them as one.
Policy definition is the statement of rules: an editor may publish articles in their own workspace; a billing admin may issue refunds under a set threshold. It answers "who may do what" in terms a reviewer can read.
Decision is evaluating a specific subject, action, and resource against those rules and returning allow or deny. It needs facts: the user's roles, the resource's owner, its state, perhaps the time.
Enforcement is the point in the code where that answer has an effect. A request is rejected, a button is hidden, or rows are filtered out of a query.
When one team argues for a central policy engine and another insists each module should check its own permissions, they are usually arguing past each other. The first group wants rules in one place so they can be reviewed, tested, and audited. The second group knows that only the orders module understands how an order is loaded, which fields matter, and how to filter ten thousand of them efficiently. Both are right, but about different layers.
This article takes a hybrid position. Express decisions as central, testable policies. Each module owns enforcement and the data-dependent checks, and it reaches the policy layer through a narrow interface. That interface passes in a subject, an action, and a resource, and returns a decision the module can act on.
How centralized policies and per-module checks work
In the centralized style, that interface is the whole authorization layer. One module, or an in-process policy engine, exposes a single call such as can(subject, action, resource). Every role, every permission, and every condition lives in one place. A billing service asks can(user, "refund", invoice) and gets back allow or deny without knowing why. Because the rules sit together, a reviewer can read the entire permission model in one pass, and a change to what "support agent" means is one diff instead of a search across the codebase.
In the per-module style, each module owns its module permissions. The billing module defines invoice.refund, keeps its own map from roles to those permissions, and checks them in its service layer, for example if user.role in REFUND_ROLES and invoice.amount < limit. Inline conditionals like that amount limit build up quickly. Domain rules are easy to add there because the module already has the invoice loaded and understands what it means.
The tradeoff is about where knowledge lives:
- Centralized suits small teams with stable roles who want one auditable rule set. The cost is that the policy layer must learn domain concepts, such as refund limits, that belong to other modules.
- Per-module suits teams with strong module ownership and rules that differ a lot by domain. The cost is that no one can answer "what can this role do?" without reading every module.
# --- Centralized: one policy module, reviewed as a unit ---
# authz/policy.py
POLICY = {
"support_agent": {"invoice.read", "invoice.refund"},
"billing_admin": {"invoice.read", "invoice.refund", "invoice.void"},
}
REFUND_LIMIT = {"support_agent": 500} # domain rule the policy layer now has to know
def can(subject, action, resource):
if action not in POLICY.get(subject.role, set()):
return False
if action == "invoice.refund" and subject.role in REFUND_LIMIT:
return resource.amount < REFUND_LIMIT[subject.role]
return True
# billing/service.py: asks, gets allow or deny, does not know why
def refund(user, invoice):
if not can(user, "invoice.refund", invoice):
raise PermissionError("refund denied")
...
# --- Per-module: billing owns its permissions and role mapping ---
# billing/permissions.py
REFUND_ROLES = {"support_agent", "billing_admin"}
AGENT_REFUND_LIMIT = 500
# billing/service.py: check lives next to the domain logic
def refund(user, invoice):
if user.role not in REFUND_ROLES:
raise PermissionError("refund denied")
# Inline conditional: easy to add here, invisible to anyone auditing roles
if user.role == "support_agent" and invoice.amount >= AGENT_REFUND_LIMIT:
raise PermissionError("refund over agent limit")
...
The same refund rule written both ways: once in a central can(subject, action, resource) policy that has to absorb the billing refund limit, and once as a billing-owned role map with an inline conditional in the service layer.
Where each approach breaks: RBAC limits, data loading, and drift
Plain RBAC answers "can an editor edit invoices?" It cannot answer "can this editor edit this invoice?" That second question depends on who owns the record, which tenant it belongs to, and what state it is in, such as "only drafts are editable." Those facts live in the module that owns the data. Once rules depend on them, you need attribute- or relationship-based checks, and those checks force a choice about who loads the data.
Central engine failure modes
The policy file turns into a coupling hotspot. Every team edits it, so every change waits on the same reviewers. The authorization module starts importing domain models from billing, projects and users, which is the coupling a modular monolith exists to prevent. Attribute loading also gets expensive. Either the engine fetches attributes per decision, which produces N+1 queries on lists, or callers pre-fetch the context themselves. When callers do it, they often pass the wrong or stale context, and the engine still returns an answer.
Per-module failure modes
"Manager" ends up meaning one thing in billing and another in projects. Permission names diverge, so invoice.write, edit_project and CanUpdate all coexist. Auditing a role means reading every module.
Where to enforce
Checks that run only in middleware never see background jobs, inter-module calls or admin scripts. Checks placed only deep in the domain layer get forgotten when someone adds a new entry point.
| Dimension | Central policies | Per-module checks |
|---|---|---|
| Consistency | High | Drifts over time |
| Coupling | Engine depends on all modules | Low |
| Auditability | One place to read | Scattered |
| Data access | Remote or pre-fetched | Local, cheap |
| List filtering | Hard without predicates | Natural in queries |
| Team autonomy | Low | High |
The list-filtering problem: from yes/no decisions to query predicates
A check like can(user, "read", invoice) answers a question about one record you already hold. A list endpoint asks a different question: which invoices may this user see, sorted, paginated, and counted? A yes/no function cannot answer that without being called on every candidate row.
The obvious workaround is to load candidates and filter in memory. It works for small tables and breaks in predictable ways. If you fetch page 1 with LIMIT 50 and then drop unauthorized rows, the user gets a short page. Total counts are wrong. Cursors skip or repeat items. To fix this you over-fetch, and soon you are scanning thousands of rows to return fifty.
The efficient pattern turns the policy into a predicate the database can evaluate. The rule "members see invoices in their own tenant; managers also see drafts" becomes a WHERE clause that indexes can use. The module owns this translation because it knows its own schema, joins, and soft-delete columns. That knowledge is what makes the approach hard to generalize.
This creates the core trade-off. If your central policy engine can only return booleans, every list endpoint still needs a hand-written query filter. That filter duplicates the rule and drifts from it.
The example below avoids that duplication. Each rule is defined once as data: the conditions on a resource's fields. Two functions are derived from that one definition. canRead evaluates the conditions against a loaded object. readScope compiles the same conditions into query-builder clauses. A shared test feeds both functions the same fixtures and fails if they disagree.
type User = { id: string; tenantId: string; role: "member" | "manager" };
type Invoice = { id: string; tenantId: string; status: "draft" | "sent" | "paid" };
type Cond = { field: "tenantId" | "status"; op: "eq" | "neq"; value: (u: User) => string };
// Single source of truth: per role, a list of conditions that must ALL hold.
const readRules: Record<User["role"], Cond[]> = {
member: [
{ field: "tenantId", op: "eq", value: (u) => u.tenantId },
{ field: "status", op: "neq", value: () => "draft" }, // members never see drafts
],
manager: [{ field: "tenantId", op: "eq", value: (u) => u.tenantId }],
};
// The module owns its schema, so it owns the field -> column mapping.
const columns: Record<Cond["field"], string> = { tenantId: "invoices.tenant_id", status: "invoices.status" };
// Single-resource check: evaluate the conditions against a loaded object.
export function canRead(u: User, inv: Invoice): boolean {
return readRules[u.role].every((c) =>
c.op === "eq" ? inv[c.field] === c.value(u) : inv[c.field] !== c.value(u));
}
// List scope: compile the same conditions into a parameterized WHERE fragment.
// Assumes NOT NULL columns; with NULLs, SQL '<>' and JS '!==' would disagree.
export function readScope(u: User, firstParam = 1): { where: string; params: string[] } {
const params: string[] = [];
const parts = readRules[u.role].map((c) => {
params.push(c.value(u));
return `${columns[c.field]} ${c.op === "eq" ? "=" : "<>"} $${firstParam + params.length - 1}`;
});
return { where: parts.length ? parts.join(" AND ") : "FALSE", params }; // no rules = deny
}
// Usage in the list endpoint: filtering, ordering, LIMIT and COUNT all happen in the database.
// const { where, params } = readScope(user);
// const sql = `SELECT * FROM invoices WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT 50`;
// Parity test idea: insert fixtures, run SELECT id ... WHERE readScope(u),
// and assert the returned ids equal fixtures.filter((f) => canRead(u, f)).map((f) => f.id).
One rule definition per role drives both canRead for a single loaded invoice and readScope, which compiles the same conditions into a parameterized SQL WHERE clause so list endpoints can paginate and count in the database.
How to implement the hybrid design
The hybrid has three pieces. The core owns a small shared vocabulary: action names such as read, update, approve, and role names. It also owns one decision interface, authorize(actor, action, resource). It owns nothing domain-specific. Each module registers its resource types with the core at startup, supplying an attribute loader that turns an ID into the facts policies need, such as owner, tenant, and status. Because modules push loaders in, the core never imports a domain model, and the dependency arrow keeps pointing inward.
Enforcement sits at each module's public API, not only in HTTP middleware. The flow looks like this:
- HTTP handlers, background jobs, and admin scripts all call a module's public method.
- That method calls
authorize. - The core calls back into the module's registered loader, then evaluates the policy.
A nightly job that calls invoices.void(id) hits the same check as a browser request. Middleware-only checks silently exempt every non-HTTP path. Jobs that legitimately act with elevated rights should pass an explicit system actor. Bypassing the check hides those calls from audit.
Caching needs care. Role and decision caches cut latency, but a revoked user who keeps access for an hour is a security failure, not a performance tradeoff. There are three safer options:
- Request-scoped caching: cache only within a single request or job run.
- Short TTLs: keep cached entries alive for seconds, not minutes.
- Explicit invalidation: publish an event on every role change that evicts that actor's entries.
A separate policy DSL or engine is overkill while policies are mostly role checks plus ownership. It becomes worth it when non-engineers author rules, when policies change faster than deploys, or when several services must share them.
// core/authz.ts: owns the shared vocabulary and the decision interface. It imports no domain models.
export type Action = "read" | "update" | "approve" | "void";
export type Actor = { id: string; roles: string[]; tenantId: string };
export type Facts = { tenantId: string; ownerId?: string; status?: string };
type Loader = (id: string) => Promise<Facts>;
type Policy = (actor: Actor, action: Action, facts: Facts) => boolean;
const registry = new Map<string, { load: Loader; policy: Policy }>();
// Modules push their loaders in at startup, so the dependency arrow points toward the core.
export function registerResource(type: string, load: Loader, policy: Policy): void {
registry.set(type, { load, policy });
}
export async function authorize(actor: Actor, action: Action, type: string, id: string): Promise<void> {
const entry = registry.get(type);
if (!entry) throw new Error(`unregistered resource type: ${type}`); // fail closed
const facts = await entry.load(id); // call back into the owning module
if (!entry.policy(actor, action, facts)) throw new Error(`forbidden: ${action} ${type}/${id}`);
}
// invoices/index.ts: the module registers its own resource type, loader, and policy.
declare const db: { invoices: { findFacts(id: string): Promise<Facts>; markVoid(id: string, by: string): Promise<void> } };
registerResource(
"invoice",
(id) => db.invoices.findFacts(id), // load only the facts the policy needs
(actor, action, f) =>
actor.tenantId === f.tenantId &&
(action === "read" ||
(action === "void" && actor.roles.includes("billing_admin") && f.status !== "paid"))
);
// Public API: HTTP handlers, nightly jobs, and admin scripts all reach this same check.
export async function voidInvoice(actor: Actor, id: string): Promise<void> {
await authorize(actor, "void", "invoice", id);
await db.invoices.markVoid(id, actor.id); // record the actor for the audit trail
}
The invoices module registers an attribute loader and policy with a core authorize() function that imports no domain code, and its public voidInvoice method calls authorize() so that every caller passes the same check.
Testing, auditing, and migrating from scattered checks
A hybrid degrades when checks quietly stop running or when nobody can explain why a request was allowed. Three practices prevent that.
Table-driven policy tests. Because decisions are pure functions of subject, action, and resource, each rule can be tested as a row of inputs and an expected outcome:
| Subject | Action | Resource | Expected |
|---|---|---|---|
| editor, team A | update | doc owned by team A | allow |
| editor, team A | update | doc owned by team B | deny |
| viewer, team A | delete | doc owned by team A | deny |
| admin | delete | archived doc | allow |
Adding a rule means adding rows, and a reviewer can read the table without reading the policy code.
Contract tests on entry points. Policy tests prove rules are correct but not that they are called. Enumerate every route, job handler, and public module function, then assert that each one invokes the decision interface or is on an explicit, reviewed allowlist. A new endpoint without a check should fail the build.
Structured decision logs. Record subject, action, resource, outcome, and the rule that matched. Free-text logs cannot answer "who could delete invoices last Tuesday"; structured fields can.
Migration. First, inventory every existing check by searching for role comparisons and ownership conditionals. Then introduce the decision interface as a thin wrapper that calls the current logic unchanged. Move rules one module at a time, evaluating old and new paths side by side and logging both outcomes. Cut over a module only when the logs show no disagreements across representative traffic.
# test_document_policy.py -- run with: pytest -q
from dataclasses import dataclass
import pytest
@dataclass(frozen=True)
class Subject:
role: str
team: str | None = None
@dataclass(frozen=True)
class Doc:
owner_team: str
archived: bool = False
def decide(subject: Subject, action: str, resource: Doc) -> bool:
# Pure function: no I/O, so every rule is testable as a table row.
if subject.role == "admin":
return True
if subject.role == "editor" and action == "update":
return subject.team == resource.owner_team
return False # default deny
# Each row mirrors the policy table; adding a rule means adding rows.
CASES = [
(Subject("editor", "A"), "update", Doc("A"), True),
(Subject("editor", "A"), "update", Doc("B"), False),
(Subject("viewer", "A"), "delete", Doc("A"), False),
(Subject("admin"), "delete", Doc("A", archived=True), True),
]
@pytest.mark.parametrize("subject,action,resource,expected", CASES)
def test_decide(subject, action, resource, expected):
assert decide(subject, action, resource) is expected
A pytest parametrized test that encodes the policy table as rows of subject, action, resource, and expected outcome against a pure decision function with default deny.
Alternatives, cost, and the path to services
Alternatives and their costs
A pure central engine with a policy DSL fits when rules are numerous, change often, or need review by people outside the codebase. You pay for a second language that engineers must learn, debug, and test, and the owning team becomes a review bottleneck for every rule change. Pure per-module checks suit a few modules with unrelated rules, but logic gets duplicated and every audit means reading every module. Middleware-only role guards are reasonable for a handful of stable roles and need no DSL. They cannot see resource ownership, though, so they can't answer "may this user edit this invoice?" Relationship-based models fit sharing, folders, and org hierarchies, where access follows graph edges rather than role names.
The hybrid saves time in four ways. Everyone reasons in one vocabulary. Modules reuse one test harness instead of building their own. Auditors read a single registry of rules. Each module has one enforcement entry point, which leaves fewer paths to bypass.
Effect on a future split
When a module owns enforcement behind a stable interface, extraction moves the checks along with the code. When a central engine reads module tables or internals, it becomes a distributed dependency, and every authorization decision turns into a cross-service call.
| Team size | Rule complexity | Extraction plans | Recommended approach |
|---|---|---|---|
| Small | Few stable roles | None | Middleware role guards |
| Small to medium | Ownership and tenant rules | Unlikely | Hybrid |
| Medium to large | Many changing rules | Likely | Hybrid, module-owned enforcement |
| Large | Sharing and hierarchies | Any | Relationship-based model behind module interfaces |
| Any | Externally reviewed policy | None | Central engine with DSL |
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith authorization design, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.