Case Study 1: Meridian's Access Review and the Wire That Almost Wasn't Watched

"Nobody granted her the ability to commit fraud. We just never took away the pieces." — Elena Vasquez, GRC Analyst, Meridian Regional Bank (constructed)

Executive Summary

A routine quarterly access review at Meridian Regional Bank surfaced a finding that no single grant had ever flagged: a long-tenured teller's account could both initiate and approve an outbound wire transfer, a segregation-of-duties violation with direct fraud exposure. This case study follows GRC analyst Elena Vasquez, security engineer Sam Whitfield, and junior analyst Theo Brandt as they trace the finding to its root cause (privilege creep across four years of role changes), redesign Meridian's branch roles around durable job functions, build segregation of duties for wire transfers at two layers (static role separation and runtime self-approval prevention), and stand up an access-review process that will catch the next instance before an auditor — or an attacker — does. You will see the chapter's concepts stop being definitions and become a working remediation: RBAC role engineering, least privilege, the access-control matrix, the PDP/PEP split, and the maker-checker workflow. The scenario, names, and all figures are constructed for teaching (Tier 3).

Skills applied: distinguishing authentication from authorization; reading an access-control matrix (row vs. column); diagnosing privilege creep and toxic combinations; RBAC role engineering (top-down + bottom-up); designing segregation of duties as a maker-checker workflow; placing enforcement at the PDP vs. role-design layer; drafting an access-control policy increment.

Background

Meridian Regional Bank is mid-size — roughly 1,800 employees, 120 branches across three Midwestern states — and, like most organizations its age, it carries two decades of accumulated access decisions that no one has ever reviewed all at once. Authentication, after the near-miss phishing incident of Chapter 1 and the standard set in Chapter 16, is in good shape: phishing-resistant factors for sensitive roles, MFA broadly. The bank knows who its users are with high confidence. What it has never systematically asked is what each of them can do — and whether that still matches what their current job requires.

The trigger for this review was not an incident. It was the bank's external auditors, preparing for the annual examination, who asked Dana Okafor (CISO) a deceptively simple question: "Can you demonstrate that no single employee can both originate and approve a wire transfer?" Dana could not answer it on the spot, which is itself a finding. She tasked Elena and Sam with producing the evidence — and, if the evidence turned out badly, fixing what it revealed. She gave them the same rule she gives every review: "Be honest about what we find. An access review that confirms everything is fine is usually an access review that didn't look hard enough."

Wire transfers are Meridian's highest-risk routine action for a simple reason: a wire moves real money, irreversibly, to an external party, often in minutes. Unlike most banking operations, a fraudulent wire cannot be quietly reversed the next morning. That is precisely why banking practice — and SOX controls over financial processes — demand that wires be segregated: the person who enters a wire must not be the person who approves it. The question the auditor asked was, in effect, "is your most dangerous action actually protected by the two-person control everyone assumes is there?"

The Analysis

Phase 1 — Building the access matrix

Elena and Sam started where any access review must start: by assembling the current state into a form a human can reason about. They exported role assignments from the core-banking system and Active Directory and built an access control matrix — roles down one axis, high-value resources across the other, each cell holding the operations that role can perform. The designed roles looked clean:

Role ↓ \ Resource → Customer accounts Transactions Wire: initiate Wire: approve Rate exceptions Branch reports
Teller read, open create
Senior_Teller read, open create, reverse (≤ limit)
Branch_Manager read, open create, reverse approve read
Wire_Operator read initiate
Wire_Approver read approve

"On paper, we're fine," Sam said. "Wire_Operator can initiate and not approve. Wire_Approver can approve and not initiate. The roles are properly separated." Elena was unconvinced. "The roles are separated. That tells us what's possible by design. It doesn't tell us what people actually hold. The auditor didn't ask whether the roles are separated. They asked whether any person can do both."

This is the distinction the chapter draws between reading a matrix down a column versus across a row. Reading down the Wire: approve column answers "which roles can approve a wire?" — and the answer was reassuring (Branch_Manager? no; only Wire_Approver). But that is the resource-owner view. To find privilege creep, you must read across each person's row: what can this individual account do, everywhere, in combination? So Elena and Sam did the harder export — not roles, but users-to-permissions, every account flattened to the union of everything its assigned roles granted.

🔗 Connection: The chapter's §17.6 makes exactly this point: the designed-role view and the actual-assignment view are different things, and audits fail when they check only the former. A role catalog can be immaculate while individual accounts are a mess, because the danger lives in assignments that accumulate across roles, not in the roles themselves.

Phase 2 — Reading across the rows

The flattened export was 1,800 rows long. Most were unremarkable — a teller with the Teller role and nothing else, a loan officer with Loan_Officer. Theo, doing the tedious work of sorting accounts by how many distinct roles each held, noticed that a handful of long-tenured employees held four, five, even six roles. He pulled the most extreme one:

user: r.kowalski   (current job title: Senior Teller, Ohio branch)
  roles held:
    Teller          (hired 2020)
    Senior_Teller   (promoted 2021)
    Branch_Ops      (2022 — 6-week coverage for ops lead; "temporary")
    Reporting_Admin (2022 — project "Atlas"; project closed Q4 2022)
    Wire_Approver   (2024 — covered an absent approver for 3 weeks)
  flattened permissions (union across all five roles):
    open_account, accept_deposit, read_balance, reverse_txn,
    read_branch_reports, adjust_rate, initiate_wire, approve_wire   <-- !!

There it was. Branch_Ops had, buried in its bundle, the initiate_wire permission (operations leads sometimes originate wires). Wire_Approver carried approve_wire. Held separately, across separate jobs at separate times, each had been individually reasonable and individually approved. Together, on one account, they were a toxic combination: r.kowalski could enter a wire and approve her own wire, moving money out of Meridian with no second person in the loop.

"Walk me through how this happened," Dana asked when they brought it to her. Elena traced it: "Four years, four role changes, zero removals. She was promoted — nobody removed Teller, which is harmless. She covered for the ops lead for six weeks in 2022 — we granted Branch_Ops and never took it back when the coverage ended. She joined a reporting project — Reporting_Admin, never removed when the project closed. She covered an absent wire approver this year — Wire_Approver, still active. Each grant was a yes to a real, temporary need. We just never said no afterward. That's privilege creep, and it assembled a fraud-enabling combination that no human ever decided to create."

🛡️ Defender's Lens: Notice what made this dangerous and invisible: no alert, no policy violation at the moment of any single grant, no malice. Every grant passed its own approval. The violation existed only in the combination, which nothing in Meridian's process had ever assembled and examined. This is why periodic access reviews that read across the row — and explicit segregation-of-duties rules that check combinations — are detective and preventive controls that no amount of careful per-grant approval can replace.

Theo asked the question a good analyst asks: "Was it ever abused?" Priya's team pulled the wire accounting logs (the third A — accounting — earning its keep). r.kowalski had initiated wires as part of her ops coverage; the logs showed every one had, in fact, been approved by a different person, because in practice the branch followed the two-person convention even though the system did not enforce it. "So we got lucky," Dana said. "The control that protected us was a human habit, not a system rule. Habits fail. The day she's out sick and someone's in a hurry, or the day her account is the one that gets stolen, the habit isn't there and the system says yes." That sentence became the justification for everything that followed.

Phase 3 — Redesigning the roles (role engineering)

Remediating r.kowalski's account was a five-minute fix: remove Branch_Ops, Reporting_Admin, and Wire_Approver, leaving Senior_Teller (and the redundant Teller), which matches her current job. But Dana wanted the systemic fix, not the one-off: "If we just clean up this one account, we'll be back here in two years with a different name. Why did our roles let this accumulate, and how do we make it structurally hard?"

Sam led a role-engineering pass, combining the two approaches the chapter describes. Bottom-up, he mined the flattened export to see what real permission clusters existed — which surfaced both the genuine job patterns and the accumulated cruft (single-occupant roles, duplicated permissions, the Branch_Ops bundle that silently mixed operations and wire powers). Top-down, he and Elena interviewed branch operations and HR to define roles around durable job functions the bank actually hires for, not around the accidents of who-covered-for-whom. The reconciled design:

Role Inherits Adds Explicitly must NOT have
All_Employees (base) email, intranet, HR self-service, time-clock any banking-system access
Teller All_Employees open_account, accept_deposit, read_balance reverse_txn; any wire permission
Senior_Teller Teller reverse_txn (≤ limit), override_hold any wire permission; rate changes
Branch_Manager Senior_Teller approve_exception, read_branch_reports, adjust_rate (band) initiate_wire; approve_wire; self-approval
Wire_Operator All_Employees initiate_wire, read_wire_queue approve_wire
Wire_Approver All_Employees approve_wire, read_wire_queue initiate_wire
Reporting_Reader All_Employees read_branch_reports (read-only) any write; any wire permission

Four deliberate design decisions came out of this, each tracing to the chapter:

  1. A base role plus a hierarchy. All_Employees holds the access everyone needs; every other role inherits it rather than repeating it. Senior_Teller is "Teller plus a couple of approvals," not a from-scratch list. This keeps roles small and consistent and prevents the duplication that breeds drift.
  2. Wire handling is walled off from the teller hierarchy. Wire_Operator and Wire_Approver inherit only from All_Employees, not from any teller role. A branch manager does not silently acquire wire powers by virtue of seniority — those are separate, deliberately-isolated functions.
  3. The two wire roles are mutually exclusive by policy. The provisioning system is configured to refuse to assign both Wire_Operator and Wire_Approver to the same identity. This is the static layer of segregation of duties: prevent the toxic combination from ever being granted.
  4. The dangerous bundle is gone. Branch_Ops — the role that silently mixed operations and initiate_wire — was decomposed. Operations duties that genuinely need wire origination get Wire_Operator explicitly (and are therefore blocked from also being Wire_Approver); the rest goes into narrower roles. No more permissions hiding inside a grab-bag role.

⚠️ Common Pitfall: Sam's first instinct, under time pressure, was to "just recreate r.kowalski's old combined access as a new role so the ops team isn't blocked." Elena stopped him: "That's how we got here. You'd be encoding the privilege creep as an official role — bottom-up role mining's classic trap. If a real job needs both wire-initiate and something else, we design that job's role deliberately and check it against the SoD rules — we don't bless the accident." Bottom-up mining reveals patterns; it must always be reconciled against a top-down view of what jobs should have, or it launders creep into policy.

Phase 4 — Segregation of duties at two layers

Clean roles fixed the static problem: with the wire roles mutually exclusive, no one could be assigned both. But Dana pressed on the failure mode that had nearly bitten them: "What if, in some emergency, we do grant one person both — a tiny branch where the same person has to cover initiation and approval for an afternoon? Or what if our role rule has a bug? The roles are one layer. I want the action protected too." This is the chapter's central insight: segregation of duties needs both a static layer (role design) and a dynamic layer (runtime policy at the PDP).

Sam designed the wire transfer as an explicit maker-checker workflow, with the runtime rules enforced by the wire system's authorization logic — its policy decision point — not merely by which roles exist:

   WIRE TRANSFER — maker-checker, enforced at the PDP on every request
   ┌──────────────┐        ┌──────────────┐        ┌──────────────┐
   │  INITIATE    │   ──►  │   APPROVE    │   ──►  │   RELEASE     │
   │  (maker)     │        │  (checker)   │        │  (execute)    │
   │ Wire_Operator│        │ Wire_Approver│        │   system      │
   └──────────────┘        └──────────────┘        └──────────────┘
   PDP rules checked when "approve wire #N" arrives:
     1. approver holds approve_wire?                      (RBAC check)
     2. approver_person  !=  initiator_person  of #N      (NO SELF-APPROVAL)
     3. request from managed device, on corp net, MFA?    (ABAC conditions)
     4. amount > $250,000  =>  require a SECOND approver   (dual approval)
   Even if one account somehow held BOTH wire roles, rule 2 still blocks it
   from approving its own initiation.  Static separation + dynamic check.

Figure CS1.1 — Meridian's wire transfer as a maker-checker workflow. Rules 1, 3, and 4 are about having the right role and context; rule 2 — approver ≠ initiator — is the runtime self-approval check that protects the action even if the role separation is ever breached. This is the layer Meridian had been missing; the branch's human habit had been doing rule 2's job informally.

Sam prototyped the core of rule 1 and rule 2 using the authz.py module from this chapter's checkpoint, extending it into a can_approve_wire check the wire system's PDP would call on every approval request:

# Prototype of the wire PDP's approval decision (extends bluekit/authz.py).
# Illustrative and hand-traced; NOT executed during authoring.
from authz import rbac_check, abac_eval   # Chapter 17 toolkit

def can_approve_wire(approver_id, approver_roles, wire, context, policy) -> bool:
    """Return True only if approval is permitted: right role, NOT the initiator,
    compliant context, and (for large amounts) a second distinct approver."""
    if not rbac_check(approver_roles, "approve_wire"):
        return False                                  # rule 1: role
    if approver_id == wire["initiated_by"]:
        return False                                  # rule 2: no self-approval
    if not abac_eval(context, policy):
        return False                                  # rule 3: device/net/MFA
    if wire["amount"] > 250_000 and wire.get("second_approver") in (None, approver_id):
        return False                                  # rule 4: dual approval for large
    return True

if __name__ == "__main__":
    pol = {"device": "managed", "network": "corp", "mfa": True}
    ctx = {"device": "managed", "network": "corp", "mfa": True}
    wire = {"initiated_by": "j.ortiz", "amount": 50_000, "second_approver": None}
    # A different, properly-roled approver in a compliant context: allowed.
    print(can_approve_wire("a.khan", {"Wire_Approver"}, wire, ctx, pol))   # True
    # The initiator trying to approve their own wire: blocked by rule 2.
    print(can_approve_wire("j.ortiz", {"Wire_Approver"}, wire, ctx, pol))  # False

# Expected output:
# True
# False

Trace it: in the first call, a.khan holds approve_wire (rule 1 passes), is not the initiator j.ortiz (rule 2 passes), the context is compliant (rule 3 passes), and the $50,000 amount is under the dual- approval threshold (rule 4 not triggered) — so the PDP returns True. In the second call, the initiator j.ortiz tries to approve his own wire; even though he holds approve_wire and his context is fine, rule 2 fires and the PDP returns False. That single rule — approver ≠ initiator, enforced at runtime — is the control whose absence had left Meridian relying on a human habit.

🔗 Connection: This is the §17.5 PDP/PEP architecture in miniature. The wire application is the enforcement point (it intercepts the approval request and imposes the verdict); can_approve_wire is the decision logic the decision point runs. Centralizing the rule here means the same segregation-of- duties logic protects every wire channel — branch, online, batch — instead of being re-implemented (and drifting) in each. It is also the seed of the zero-trust policy engine of Chapter 32, evaluating role and context on every sensitive request.

Phase 5 — Standing up the access review

The last piece was process, because controls that aren't re-checked decay. Elena defined Meridian's periodic access review (access recertification): on a schedule, the owner of each role affirmatively reviews who holds it and confirms or revokes each grant. The cadence is risk-based:

Review scope Cadence Reviewer What "real" looks like
Wire roles, domain admins, cardholder-data roles Quarterly Role owner + GRC Each grant individually justified or revoked; SoD conflicts auto-flagged
Branch operational roles (teller, manager, etc.) Semi-annual Branch manager Confirm role matches current job; remove stale coverage grants
All other access Annual Manager Recertify; remove on any role change since last review

Elena was emphatic about what makes a review real rather than theater: "If a reviewer clicks 'approve all' in thirty seconds, we've achieved nothing. A real review removes things. We measure each review by how much access it revoked — a review that revokes nothing, quarter after quarter, in an organization with turnover, is a review nobody's actually doing." The review process also runs an automated segregation-of-duties scan — the same combination-checking logic, applied across every account's flattened permissions — so that the next r.kowalski is flagged the moment a grant creates a toxic combination, not four years later by an auditor.

🔄 Check Your Understanding: Meridian fixed r.kowalski's account, redesigned the roles, added the runtime self-approval check, and stood up quarterly reviews. Suppose budget allowed only three of those four. Which one would you cut, and what residual risk would cutting it leave? (Hint: consider which controls are one-time versus ongoing, and which protects the action versus the assignment.)

Discussion Questions

  1. The designed role catalog was clean while individual accounts were not. What does this teach about how access audits should be scoped, and why is "show me the role definitions" insufficient evidence for an auditor asking about a specific control?
  2. Meridian had been protected by a human habit (always using a different approver) rather than a system rule. Argue both sides: when is it acceptable to rely on a procedural control, and when must it be enforced technically? What tipped the wire transfer into "must be technical"?
  3. Sam was tempted to recreate r.kowalski's combined access as an official role to avoid blocking the ops team. Walk through exactly why that would have been a mistake, naming the role-engineering trap it represents.
  4. Segregation of duties for wires was enforced at two layers (static role separation, runtime self-approval). For a different high-risk action of your choice (e.g., creating a vendor and approving its first payment), design both layers.
  5. Elena measures an access review by how much it revokes. What are the strengths and limits of that metric? Could it create a perverse incentive, and how would you guard against it?

Your Turn

Take an organization you know (or invent a small business with a money-moving or data-modifying process) and reproduce this remediation end to end, on one page: (a) build a small access-control matrix (4–6 roles × 4–6 high-value resources); (b) flatten two or three "long-tenured" accounts and read across their rows to find at least one toxic combination you deliberately plant; (c) redesign the roles with a base role, a hierarchy, and at least one mutually-exclusive pair; (d) define the segregation of duties for your highest-risk action at both layers (role design + a runtime rule), writing the runtime rule as a "permit if …" check; (e) propose a review cadence and state how you would measure that the reviews are real. If you cannot justify a grant in a phrase, that is the signal it should be revoked — note it.

Key Takeaways

  • An access review starts by reading across rows, not down columns: the resource-owner view ("who can touch this?") will not reveal that one account has accumulated powers across many roles. Privilege creep lives in the row.
  • A clean role catalog is not proof that access is appropriate — the danger is in assignments that accumulate across roles, so audits must examine flattened, per-account entitlements, not just role definitions.
  • Privilege creep manufactures toxic combinations that no single grant ever flagged; every grant can be individually approved and the violation still exists only in the combination — which is why explicit SoD rules and periodic reviews are irreplaceable.
  • Role engineering combines bottom-up mining (find real patterns and cruft) with top-down design (durable job functions); bottom-up alone launders creep into official roles. Use a base role, a hierarchy, walled-off high-risk functions, and mutually-exclusive role pairs.
  • Segregation of duties needs two layers: static (role design + provisioning rules that refuse the conflicting combination) and dynamic (runtime PDP rules that refuse the conflicting act, e.g., no self-approval). A human habit is not a substitute for the runtime rule.
  • The maker-checker workflow, enforced at the PDP on every request, is the concrete shape of SoD for a money-moving action — and it is the same PDP/PEP architecture that zero trust generalizes.
  • Access reviews must remove things to be real; measure them by revocations, run an automated SoD scan on every account, and set a risk-based cadence (quarterly for wire/admin/CDE roles).