SecurityGENERALLANGUAGE-SPECIFICCONTESTED

Least Privilege as a Design Decision

Least privilege is usually taught as an infrastructure setting. At code granularity it is a parameter type: a function handed a reader cannot write, and that is enforced rather than reviewed.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

How do I give a module only the access it needs, in a way the next engineer cannot casually undo?

The requirement

"Email every customer a monthly usage summary." The reporting code needs to read usage, orders and contact details. It needs to write nothing at all.

The obvious build

Inject the database and be careful. The reporting code obviously is not going to write anything — it is a report. Building a second, narrower interface for it is ceremony, and now there are two interfaces to maintain.

Why it breaks

Six months later the report gains a "mark as notified" step, because the object was right there. Nothing in the design said no, so the answer was yes, and the reporting job is now a writer.

How it breaks as requirements change
  • Six months later the report gains a "mark as notified" step, because the object was right there. Nothing in the design said no, so the answer was yes, and the reporting job is now a writer.
  • A bug in a read path with write access is a data-loss bug rather than a wrong-number bug. The blast radius of every future mistake in this module was set by its parameter type.
  • The claim "we would notice in review" holds while the module is small and the reviewer is the author's teammate. It degrades exactly when the module gets big and the reviewer gets busy — which is when it matters (Review Size).
  • When someone finally asks "what can the reporting job touch?", the only honest answer is "everything", and answering it properly means reading the whole module.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The codebase has one Database object injected everywhere, because that is how it started and there are four hundred call sites.
  • The report runs as a scheduled job with no user in context, so there is no request-scoped identity to lean on.
  • The team is small enough that "we would notice in review" is a plausible claim — and this lesson is partly about why it is a weaker claim than it feels.
Invariants
  • A reporting job must never mutate customer data, under any code path, including a mistaken one.
  • A module cannot acquire access it was not given — no reaching for a global, no constructing its own client (Hidden Global State).
  • What a module can reach is visible at its boundary, so reviewing its privileges does not require reading its body.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The composition root owns which capability each module receives — that is the one place privileges are decided (Wiring and the Composition Root).
  • The module owns doing its job with what it was given, and owns *declaring* what it needs by its parameter types.
  • Nobody owns "being careful". That is not a responsibility, it is the absence of one.
Boundaries
  • The privilege boundary is the module's constructor or function signature. Whatever crosses it is what the module can do, and the review question becomes "is this list right?" rather than "does the body misbehave?".
  • The interface belongs to the *consumer*, not the provider: UsageReader is defined by the reporting module and implemented by persistence, which is what keeps it narrow (Interface Segregation, Critically).
  • A second boundary is worth having outside the process — a read-only database role for the reporting job — because the in-code boundary protects against mistakes and the credential protects against the code being wrong (Security Engineering calls the layering defence in depth).

Privilege is whatever the signature lets in

The reporting module in the first version can do anything the database can do. Not because anyone decided that, but because the wide object was the one available, and nobody ever chooses to hand over more access — they just fail to choose to hand over less.

The second version says the same thing about the module's intent, except that it says it to the compiler. The interesting property is not that it is safer today; it is that the future pull request which adds a write has to change the wiring, and a change to the wiring is something a reviewer will actually see.

Two ways to hand a job its access
1// wide: the module can do anything the Database can
2export class UsageReport {
3 constructor(private db: Database) {}
4 async run() { /* reads... and could write */ }
5}
6
7// narrow: the interface belongs to the consumer
8// reporting/ports.ts
9export interface UsageReader {
10 usageFor(id: CustomerId, month: Month): Promise<Usage>
11 contactFor(id: CustomerId): Promise<Contact>
12}
13
14// reporting/report.ts
15export class UsageReport {
16 constructor(private usage: UsageReader, private mail: CanSendEmail) {}
17 async run() { /* there is no write to reach for */ }
18}
19
20// main.ts — the one place privileges are decided
21new UsageReport(pgUsageReader(pool), smtpMailer(cfg))

The reporting module now has two capabilities and they are listed in one line of main.ts. Answering "what can this job touch?" during an incident stops being an exercise in reading four hundred lines. Note also that neither type mentions Postgres or SMTP — narrowing privilege and inverting the dependency turn out to be the same edit.

The change that shows what it bought

The argument for narrowing is not that today's code is wrong. It is about what happens to the next requirement — specifically, a requirement that sounds small and quietly asks the reporting job to become a writer.

The report should also suspend customers over their quota
The change

Product wants the monthly report to suspend any customer more than 20% over their plan quota, and note the suspension on the account.

Reporting holds the whole `Database`
UsageReport
testsusage_report_test
1 module · 1 test file

One module, one test, one afternoon. It looks like the cheaper design — and it is, for this change. What it costs is that a reporting job now writes to accounts, and nothing anywhere records that this was a decision. The next incident where a report corrupts account state starts here.

Reporting holds `UsageReader` and `CanSendEmail`
UsageReportreporting/ports.tsmain.tsaccounts/SuspendCustomer
testsusage_report_testsuspend_customer_testwiring_test
4 modules · 3 test files

More work, and the extra work is the design conversation the first version skipped: does reporting suspend customers, or does it ask the accounts module to? The forced answer is usually the second, which is also the right one — suspension is an account rule with its own invariants.

what it cost The narrow design is genuinely more expensive for this change — four files instead of one, and a discussion. It is a bet that the discussion is worth more than the afternoon, which is true when suspension has rules and false when the "write" is a debug timestamp. A design that makes every widening expensive also makes legitimate widening expensive, and there is no version of this that only slows down the bad changes.

How much narrowing is worth it

The failure at each end is real. No narrowing gives every module the keys to everything; total narrowing gives you a codebase of single-method interfaces and a wiring file nobody can read. The question is where on that line a given module sits, and the answer is decided by blast radius rather than by principle.

A useful heuristic: narrow when a mistake in this module would be *irreversible* — writes to customer data, money movement, sending things to people, deleting anything. Leave it wide where a mistake produces a wrong answer you can fix by rerunning.

Granularity of privilege, priced
OptionSimplicityFlexibilityTestabilityOperationalMigration costNote
One wide `Database` everywhereSimplest to write and impossible to audit. Testing needs a real database or a huge fake, which is the first symptom. Migration cost is nil because it is the status quo.
Narrow port per consumerThe recommendation for modules that write or send. Fakes become trivial, audit becomes reading one signature, and the retrofit cost on existing code is the real objection.
Single-method capabilities everywhereMaximum control, and the wiring becomes a language of its own. Justified in a payments core, absurd in a CRUD admin panel (Capability Passing).
Wide types plus a read-only DB credentialProtects the data without touching the code, and often the highest-value single move on a legacy codebase. Does nothing for auditability at the module level, and fails loudly at runtime rather than at compile time.

caveat These scores compare structures, not outcomes, and they cannot express the thing that actually decides it: what a mistake in this particular module would cost. A wide type over a reporting replica with no write path is fine; a narrow port over the ledger is not optional. Read the rows as "what each shape makes easy", never as a ranking — and note that the last row is not code design at all, which is precisely why it is often the right first move.

How to build it

Most important first.

  • Type the dependency by what the caller needs. A parameter of type UsageReader with three methods is a statement about privilege that the compiler checks on every build.
  • Define the narrow interface next to the consumer. Defined next to the repository it becomes a shared thing that grows to satisfy everyone, which is how narrow interfaces get wide (Interface Versus Implementation).
  • Decide privileges at the composition root, so there is one file where "who can do what" can be read end to end (Constructor Injection).
  • Refuse ambient access. A module that can call db.get() from a module-level import has no privilege boundary regardless of what its constructor says (Service Locator).
  • Match the in-code narrowing with a real credential where the data warrants it — a read-only role, a scoped token — because a type is a guarantee about your code and not about a compromised process.
  • Narrow where the blast radius justifies it. Every module having a bespoke interface is its own kind of failure, and this is a judgement about consequence, not a rule (Over-Decomposition).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a genuinely new read to the reporting job: one method on one interface, one implementation, one test double updated. Bounded and obvious.
  • Adding a *write* to the reporting job: you cannot, without changing its declared privileges in the composition root — which is exactly the change you want to be visible, and the reason the design pays.
  • Auditing "what can this module reach" goes from reading the module to reading one signature. That is the change that compounds, because it is asked during every incident.
  • The retrofit cost is the honest catch: narrowing four hundred existing call sites is a project, so on an existing codebase this is applied at the edges — new modules and modules being changed anyway — and never as a sweep (Incremental Migration).
What the recommended approach costs
  • Every narrow interface is a type to name, maintain and mentally map onto its implementation. On a small codebase that indirection genuinely costs more than the mistake it prevents.
  • The interface belonging to the consumer means N consumers can produce N overlapping interfaces over the same data, which looks like duplication and sometimes is.
  • It protects against accident and only weakly against intent. A determined engineer casts, and a compromised process has the credential the adapter holds — so the in-code boundary is one layer, not the layer.

What can go wrong

Failure modes
  • The narrow interface grows. UsageReader gains markNotified, then updateFlags, and within a year it is the wide interface with a modest name — this is the normal failure and it happens through ordinary, individually reasonable pull requests.
  • Narrowing is applied uniformly, so the codebase acquires forty single-method interfaces and the wiring becomes the hardest file to read (Over-Decomposition).
  • The type is narrow and the runtime object is the wide one, so a cast, a test double or a debugging session restores full access. The guarantee is only as strong as the language's ability to stop you.
  • Everyone agrees privileges should be narrow and the composition root is generated by a container that autowires whatever a constructor asks for, which quietly makes "what do I need" self-service (Dependency Injection).
Dependencies, and their direction
  • The reporting module depends on an interface it owns; persistence depends on the reporting module's interface to satisfy it. The dependency points inward, which is the inversion doing real work rather than ceremonial work (Dependency Inversion).
  • The composition root depends on everything, and that is correct — it is the only place allowed to know the whole graph.
  • Nothing depends on the wide Database type except the adapters, which shrinks the number of modules a schema change can reach (Backend Engineering calls the failure schema leakage).
Misreads
  • "Least privilege means IAM policies." IAM is one grain of it. The same principle at function granularity is a parameter type, and it applies inside a monolith with one database user (Security Engineering owns the operational half).
  • "So every dependency should be an interface." No — that is the rule this domain explicitly refuses. Narrow where the blast radius of a mistake is large; a formatter and a date helper do not need one (Premature Abstraction).
  • "A read-only type makes the system secure." It makes an entire class of accident impossible in your code. It does nothing about the credential, the ORM escape hatch, or the raw SQL path someone added.
  • "We can add this later." You can, per module. You cannot cheaply do it to four hundred call sites, which is why the useful move is to stop widening rather than to plan a sweep.
Smells this explains
  • god-object
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Test the reporting module against a fake implementing only UsageReader. If the fake is easy to write, the interface is narrow; if it is a hundred lines, the interface is telling you it is not (Testing as Design Feedback).
  • Assert the composition root, not just the modules: a test that reads the wiring and fails if the reporting job is constructed with the writable repository catches the regression this design exists to prevent.
  • Where a read-only credential backs the boundary, one integration test that a write attempt through that credential fails — otherwise "read-only role" is a claim about a config file nobody has exercised.
  • Do not test that the module does not write. You cannot, in general; that is precisely why it is expressed as a type rather than as a test.
How this design ages
  • Narrow interfaces widen under pressure unless something notices. The cheapest guard is that the interface lives next to its one consumer, so widening it looks strange in review rather than routine.
  • As the system grows, the in-code boundary tends to be joined by an operational one — separate credentials, then a separate process, eventually a separate service. Each step is the same decision at a coarser grain (The Modular Monolith).
  • The design stops being right if the reporting job legitimately becomes a writer — at which point the answer is to split it into a reader and a writer with different privileges, not to widen the interface.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat a module's reachable surface should be declared at its boundary rather than discovered in its body holds regardless of stack; what changes is whether the declaration is checked by a compiler, a linter or a reviewer.
  • LANGUAGE-SPECIFICIn Go or TypeScript, structural typing means the narrow interface costs one declaration and the implementation satisfies it automatically. In Java or C# the implementing class must name it, so narrowing a dependency touches the provider too; in Python it is a Protocol that nothing enforces at runtime, which turns the guarantee into a linting convention.
  • CONTESTEDThe strongest opposing view: capability narrowing at code granularity buys almost nothing against a real attacker — who owns the process and its credentials — while imposing a permanent interface-proliferation tax, and the same effort spent on database roles and network policy would protect against actual compromise rather than against a hypothetical careless colleague. The counter is that most data damage is accidental rather than adversarial, and this is the cheapest defence against accident; but the argument that it is security theatre at the type level deserves a real answer, not a dismissal.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the same decision at service granularity is which component holds which credential and which network path exists at all, where the enforcement is infrastructure rather than a type and the failure mode is a compromised process rather than a careless commit.