Least-Privilege Access Optimization

Shrinking Permission Matrices Without Slowing Anyone Down

Every organization eventually suffers from privilege creep. People change teams, projects end, and temporary grants quietly become permanent. Years later, an engineer who only needs read access to a repository can also deploy to production, and an accounts-payable clerk can both create and approve the same invoice.

The principle of least privilege says everyone should hold exactly the permissions they need and nothing more. Applying it blindly causes the opposite problem: people get blocked, tickets pile up, and productivity drops. In this article we model the trade-off as a mixed-integer linear program (MILP) and solve a concrete example with Python.


1. The Problem Setting

We have an organization with $U = 40$ users in five departments (Sales, Engineering, Finance, HR, Support) and $P = 16$ permissions such as CRM_Read, Prod_Deploy, DB_Admin, and Payroll_Edit.

  • Granted matrix $g_{up}\in{0,1}$: permission $p$ is currently granted to user $u$.
  • Need matrix $n_{up}\in{0,1}$: user $u$ actually uses permission $p$ in their work.
  • Usage frequency $f_{up}$: how many times per month user $u$ uses permission $p$.
  • Critical flag $c_{up}=1$ if $f_{up}\ge 8$. These permissions are essential to the daily job and must never be revoked.
  • Risk weight $r_p$ is the severity of permission $p$, and $e_u$ is the exposure of user $u$ (for example, 1.5 for contractors).
  • Just-in-time (JIT) friction cost: if a needed but non-critical permission is revoked, the user must request temporary access each time, which costs $\tau$ per use.

The decision variable is the final matrix $x_{up}\in{0,1}$, where $x_{up}=1$ means “keep the permission”.

2. Mathematical Formulation

The residual security risk and the productivity loss are

$$
\mathrm{Risk}(x)=\sum_{u=1}^{U}\sum_{p=1}^{P} r_p,e_u,x_{up},
\qquad
\mathrm{Loss}(x)=\sum_{u=1}^{U}\sum_{p=1}^{P} n_{up},\tau f_{up},(1-x_{up}).
$$

We minimize a weighted sum of the two, where $\lambda$ controls how much we value productivity relative to security:

$$
\min_{x}\ \ \mathrm{Risk}(x)+\lambda,\mathrm{Loss}(x)
$$

subject to

$$
c_{up}\le x_{up}\le g_{up}\qquad\forall u,p \quad (\text{keep critical permissions, never add new ones})
$$

$$
x_{ua}+x_{ub}\le 1\qquad\forall u,\ \forall (a,b)\in\mathcal{S} \quad (\text{separation of duties})
$$

$$
\sum_{u=1}^{U}x_{up}\le K_p\qquad\forall p\in\mathcal{H} \quad (\text{holder cap on high-risk permissions})
$$

$$
x_{up}\in{0,1}.
$$

Here $\mathcal{S}$ is the set of conflicting permission pairs: (Inv_Create, Inv_Approve), (Src_Write, Prod_Deploy), and (User_Admin, Audit_Read). $\mathcal{H}$ is the set of high-risk permissions, and $K_p$ is the maximum number of holders allowed for permission $p$.

Substituting $n_{up}\tau f_{up}(1-x_{up}) = n_{up}\tau f_{up}-n_{up}\tau f_{up},x_{up}$ turns the objective into a linear function of $x$ plus a constant:

$$
\min_x \sum_{u,p}\Big(r_p e_u-\lambda,n_{up}\tau f_{up}\Big)x_{up}.
$$

Each coefficient has a clear meaning. A permission is worth keeping only when the friction it removes, $\lambda n_{up}\tau f_{up}$, outweighs the risk it adds, $r_p e_u$. The constraints then decide the cases where that simple rule is not allowed to win.

3. Why a MILP, and Why It Is Fast

A naive search over all subsets of granted permissions needs $2^n$ evaluations, where $n$ is the number of granted entries. With hundreds of entries that is hopeless. Three design choices keep the computation light:

  1. Variable pruning. Only entries with $g_{up}=1$ become variables. Entries that are not granted are fixed at zero and never enter the model.
  2. Fixed lower bounds. Critical entries get a lower bound of 1, so the solver never has to explore them.
  3. HiGHS through SciPy. scipy.optimize.milp uses the HiGHS solver, which solves this problem in milliseconds. That makes it practical to repeat the solve 31 times to trace the entire Pareto frontier.

4. Complete Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from scipy.optimize import milp, LinearConstraint, Bounds

plt.style.use("dark_background")
rng = np.random.default_rng(42)

# ------------------------------------------------------------
# 1. Problem data
# ------------------------------------------------------------
PERMS = ["CRM_Read", "CRM_Write", "Src_Read", "Src_Write", "Prod_Deploy",
"DB_Admin", "Inv_Create", "Inv_Approve", "Payroll_View", "Payroll_Edit",
"HR_Records", "Ticket_View", "Ticket_Admin", "Audit_Read", "User_Admin",
"Backup_Restore"]
RISK = np.array([2, 4, 3, 6, 9, 10, 5, 7, 6, 8, 7, 1, 4, 5, 9, 8], dtype=float)
P = len(PERMS)

DEPTS = ["Sales", "Engineering", "Finance", "HR", "Support"]
DEPT_SIZE = [10, 9, 8, 6, 7]
dept_of_user = np.repeat(np.arange(len(DEPTS)), DEPT_SIZE)
U = len(dept_of_user)

CORE = {0: [0, 1, 11], 1: [2, 3, 11], 2: [6, 8, 13], 3: [8, 10], 4: [11, 12, 0]}
OCCASIONAL = {0: [6, 12], 1: [4, 5, 15, 13], 2: [7, 9, 0], 3: [9, 13, 14], 4: [14, 15]}

CRIT = 8
usage = np.zeros((U, P), dtype=int)
for u in range(U):
d = dept_of_user[u]
for p in CORE[d]:
usage[u, p] = rng.integers(8, 21)
for p in OCCASIONAL[d]:
if rng.random() < 0.7:
usage[u, p] = rng.integers(1, 8)

SOD = [(6, 7), (3, 4), (14, 13)]
for u in range(U):
for a, b in SOD:
if usage[u, a] >= CRIT and usage[u, b] >= CRIT:
usage[u, b] = rng.integers(1, 8)

N = (usage > 0).astype(int)
C = (usage >= CRIT).astype(int)
extra = (rng.random((U, P)) < 0.30).astype(int)
G = np.maximum(N, extra)

exposure = np.where(rng.random(U) < 0.25, 1.5, 1.0)
R = np.outer(exposure, RISK)
JIT_COST = 0.5
L = usage * JIT_COST
NL = N * L

HIGH = [4, 5, 9, 14, 15]
CAP = {p: max(int(C[:, p].sum()), 4) for p in HIGH}

# ------------------------------------------------------------
# 2. MILP solver
# ------------------------------------------------------------
def solve(lam):
idx = np.argwhere(G == 1)
n = len(idx)
vid = -np.ones((U, P), dtype=int)
vid[idx[:, 0], idx[:, 1]] = np.arange(n)

c = R[idx[:, 0], idx[:, 1]] - lam * NL[idx[:, 0], idx[:, 1]]
lb = C[idx[:, 0], idx[:, 1]].astype(float)
ub = np.ones(n)

rows, rhs = [], []
for u in range(U):
for a, b in SOD:
if vid[u, a] >= 0 and vid[u, b] >= 0:
row = np.zeros(n)
row[vid[u, a]] = 1.0
row[vid[u, b]] = 1.0
rows.append(row)
rhs.append(1.0)
for p in HIGH:
row = np.zeros(n)
row[idx[:, 1] == p] = 1.0
rows.append(row)
rhs.append(float(CAP[p]))

A = np.vstack(rows)
cons = LinearConstraint(A, -np.inf * np.ones(len(rhs)), np.array(rhs))
res = milp(c=c, constraints=cons, integrality=np.ones(n), bounds=Bounds(lb, ub))
if not res.success:
raise RuntimeError("MILP failed: " + str(res.message))

X = np.zeros((U, P), dtype=int)
X[idx[:, 0], idx[:, 1]] = np.round(res.x).astype(int)
return X

def metrics(X):
sod = sum(int((X[:, a] * X[:, b]).sum()) for a, b in SOD)
cap = sum(int(X[:, p].sum() > CAP[p]) for p in HIGH)
return {
"Granted": int(X.sum()),
"Excess": int((X * (1 - N)).sum()),
"Residual risk": float((X * R).sum()),
"Productivity loss": float((NL * (1 - X)).sum()),
"SoD violations": sod,
"Cap violations": cap,
"Critical unmet": int((C * (1 - X)).sum()),
}

# ------------------------------------------------------------
# 3. Pareto sweep and operating point
# ------------------------------------------------------------
lams = np.logspace(-1.5, 1.5, 31)
t0 = time.perf_counter()
pareto = []
for lam in lams:
Xl = solve(lam)
m = metrics(Xl)
pareto.append((lam, m["Residual risk"], m["Productivity loss"], m["Granted"]))
pareto = np.array(pareto)
elapsed = time.perf_counter() - t0

LAM_STAR = 1.0
X_opt = solve(LAM_STAR)
m_cur, m_use, m_opt = metrics(G), metrics(N), metrics(X_opt)

# ------------------------------------------------------------
# 4. Console report
# ------------------------------------------------------------
print("=== Dataset ===")
print(f"Users: {U} | Permissions: {P} | Granted: {int(G.sum())} | "
f"Needed: {int(N.sum())} | Critical: {int(C.sum())} | "
f"Excess: {int((G * (1 - N)).sum())}")
print(f"Pareto sweep: {len(lams)} MILP solves in {elapsed:.2f} s")
print()

comp = pd.DataFrame({"Current (all granted)": m_cur,
"Usage-based revoke": m_use,
"Optimized (MILP)": m_opt}).T
print(f"=== Scenario comparison (lambda = {LAM_STAR}) ===")
print(comp.round(2).to_string())
print()

dept_rows = []
for d, name in enumerate(DEPTS):
mk = dept_of_user == d
dept_rows.append({
"Department": name,
"Users": int(mk.sum()),
"Granted before": int(G[mk].sum()),
"Granted after": int(X_opt[mk].sum()),
"Risk before": round(float((G[mk] * R[mk]).sum()), 1),
"Risk after": round(float((X_opt[mk] * R[mk]).sum()), 1),
})
print("=== By department ===")
print(pd.DataFrame(dept_rows).to_string(index=False))
print()

rev = np.argwhere((G == 1) & (X_opt == 0))
top = sorted(rev.tolist(), key=lambda t: -R[t[0], t[1]])[:8]
print("=== Highest-risk revoked grants ===")
for u, p in top:
need = "Y" if N[u, p] else "N"
print(f"U{u + 1:02d} ({DEPTS[dept_of_user[u]]:<11}) {PERMS[p]:<14} "
f"risk={R[u, p]:5.1f} needed={need}")

# ------------------------------------------------------------
# 5. One combined figure
# ------------------------------------------------------------
fig = plt.figure(figsize=(24, 14))
gs = fig.add_gridspec(2, 3)
user_labels = [f"U{u + 1:02d}" for u in range(U)]
bounds_y = np.cumsum(DEPT_SIZE)[:-1]

def draw_heatmap(ax, code, cmap, vmax, title):
ax.imshow(code, cmap=cmap, vmin=0, vmax=vmax, aspect="auto", interpolation="nearest")
ax.set_xticks(range(P))
ax.set_xticklabels(PERMS, rotation=90, fontsize=7)
ax.set_yticks(range(U))
ax.set_yticklabels(user_labels, fontsize=6)
for b in bounds_y:
ax.axhline(b - 0.5, color="white", lw=0.8)
ax.set_title(title, fontsize=11)

ax1 = fig.add_subplot(gs[0, 0])
code_before = G + G * (1 - N)
draw_heatmap(ax1, code_before, ListedColormap(["#1b1f2a", "#2ecc71", "#e74c3c"]), 2,
"Before: green = needed, red = excess")

ax2 = fig.add_subplot(gs[0, 1])
code_after = np.zeros((U, P), dtype=int)
code_after[(X_opt == 1) & (N == 1)] = 1
code_after[(X_opt == 0) & (G == 1) & (N == 0)] = 2
code_after[(X_opt == 0) & (G == 1) & (N == 1)] = 3
draw_heatmap(ax2, code_after, ListedColormap(["#1b1f2a", "#2ecc71", "#f39c12", "#9b59b6"]), 3,
"After: green = kept, orange = revoked excess, purple = JIT-revoked")

ax3 = fig.add_subplot(gs[0, 2])
ax3.plot(pareto[:, 2], pareto[:, 1], "o-", color="#00d2ff", lw=2, label="Pareto frontier")
ax3.scatter([m_cur["Productivity loss"]], [m_cur["Residual risk"]],
s=140, color="#e74c3c", marker="s", label="Current")
ax3.scatter([m_use["Productivity loss"]], [m_use["Residual risk"]],
s=140, color="#f39c12", marker="D", label="Usage-based revoke")
ax3.scatter([m_opt["Productivity loss"]], [m_opt["Residual risk"]],
s=350, color="#f1c40f", marker="*", label="Optimized (operating point)")
ax3.set_xlabel("Productivity loss (JIT friction)")
ax3.set_ylabel("Residual risk")
ax3.set_title("Risk vs. productivity trade-off", fontsize=11)
ax3.grid(alpha=0.25)
ax3.legend(fontsize=8)

ax4 = fig.add_subplot(gs[1, 0], projection="3d")
sc = ax4.scatter(np.log10(pareto[:, 0]), pareto[:, 2], pareto[:, 1],
c=pareto[:, 3], cmap="plasma", s=55)
ax4.plot(np.log10(pareto[:, 0]), pareto[:, 2], pareto[:, 1], color="white", lw=0.8, alpha=0.6)
ax4.scatter([np.log10(LAM_STAR)], [m_opt["Productivity loss"]], [m_opt["Residual risk"]],
s=350, color="#f1c40f", marker="*")
ax4.set_xlabel("log10(lambda)")
ax4.set_ylabel("Productivity loss")
ax4.set_zlabel("Residual risk")
ax4.set_title("3D Pareto frontier (color = granted permissions)", fontsize=11)
ax4.view_init(elev=24, azim=-58)
fig.colorbar(sc, ax=ax4, shrink=0.55, pad=0.1, label="Granted permissions")

ax5 = fig.add_subplot(gs[1, 1])
risk_before = (G * R).sum(axis=0)
risk_after = (X_opt * R).sum(axis=0)
xpos = np.arange(P)
ax5.bar(xpos - 0.2, risk_before, width=0.4, color="#e74c3c", label="Before")
ax5.bar(xpos + 0.2, risk_after, width=0.4, color="#2ecc71", label="After")
ax5.set_xticks(xpos)
ax5.set_xticklabels(PERMS, rotation=90, fontsize=7)
ax5.set_ylabel("Residual risk")
ax5.set_title("Residual risk by permission", fontsize=11)
ax5.grid(alpha=0.25, axis="y")
ax5.legend(fontsize=8)

ax6 = fig.add_subplot(gs[1, 2], projection="3d")
red = np.zeros((len(DEPTS), P))
for d in range(len(DEPTS)):
mk = dept_of_user == d
red[d] = ((G[mk] - X_opt[mk]) * R[mk]).sum(axis=0)
gx, gy = np.meshgrid(np.arange(P), np.arange(len(DEPTS)))
bx, by = gx.ravel(), gy.ravel()
bz = np.zeros_like(bx)
dz = red.ravel()
bar_colors = plt.cm.viridis(dz / max(dz.max(), 1e-9))
ax6.bar3d(bx, by, bz, 0.7, 0.7, dz, color=bar_colors, shade=True)
ax6.set_xticks(range(P))
ax6.set_xticklabels(PERMS, rotation=45, ha="right", fontsize=6)
ax6.set_yticks(range(len(DEPTS)))
ax6.set_yticklabels(DEPTS, fontsize=7)
ax6.set_zlabel("Risk removed")
ax6.set_title("Risk removed by department and permission", fontsize=11)
ax6.view_init(elev=26, azim=-62)

fig.suptitle("Least-Privilege Access Optimization", fontsize=18, y=0.97)
fig.subplots_adjust(left=0.04, right=0.98, top=0.92, bottom=0.08, wspace=0.28, hspace=0.42)
plt.show()

5. Code Walkthrough

5.1 Data generation

Each department has a list of core permissions, which are used heavily (8 to 20 times a month) and are therefore critical. It also has a list of occasional permissions, which a user needs with 70% probability at low frequency (1 to 7 times a month). The random generator is seeded, so every run produces exactly the same organization.

Because separation-of-duties pairs are hard constraints, the code demotes one permission of any pair that a user holds as critical. This guarantees a feasible solution always exists: keeping only the critical permissions satisfies every constraint.

5.2 Modeling privilege creep

The granted matrix G starts from the need matrix N and adds extra grants with 30% probability per cell. These extras represent leftover permissions from old projects and one-time approvals. They carry risk but give no productivity benefit, which is the waste we want to eliminate.

5.3 Risk and friction matrices

R = np.outer(exposure, RISK) implements $r_p e_u$ for all users and permissions at once. L = usage * JIT_COST implements $\tau f_{up}$, and NL applies the need mask so that friction is only charged where a permission was actually used.

5.4 The MILP

Inside solve, np.argwhere(G == 1) lists the granted entries, and vid maps each (user, permission) pair to a variable index. The objective vector c is exactly the coefficient $r_pe_u-\lambda n_{up}\tau f_{up}$ derived earlier.

  • Bounds lb/ub encode $c_{up}\le x_{up}\le g_{up}$.
  • Separation of duties adds one row per user and conflicting pair, with right-hand side 1.
  • Holder caps add one row per high-risk permission that sums that column of variables, with right-hand side $K_p$.
  • integrality=np.ones(n) makes every variable binary, since the bounds restrict it to $[0,1]$.

The cap $K_p$ is set to the larger of 4 and the number of critical holders, so it can never contradict the lower bounds.

5.5 Metrics

metrics evaluates any candidate matrix on the same scale: granted count, excess grants, residual risk, productivity loss, separation-of-duties violations, cap violations, and unmet critical needs. This lets us compare three scenarios fairly: the current state, a simple usage-based revoke heuristic (keep exactly what people use), and the MILP optimum.

5.6 The Pareto sweep

Sweeping $\lambda$ from $10^{-1.5}$ to $10^{1.5}$ in 31 steps traces the whole frontier. A small $\lambda$ treats friction as cheap, so almost everything non-critical is revoked. A large $\lambda$ treats friction as expensive, so every permission anyone actually uses is kept. The operating point $\lambda=1$ balances the two.

5.7 The figure

All six panels are drawn into a single figure. The two heatmaps use categorical color codes, so each cell tells you its status at a glance. The Pareto panel and the 3D panel come from the sweep, and the last two panels aggregate the effect of the optimization by permission and by department.


6. Execution Results

6.1 Result Image

6.2 Console Output

=== Dataset ===
Users: 40 | Permissions: 16 | Granted: 317 | Needed: 188 | Critical: 114 | Excess: 129
Pareto sweep: 31 MILP solves in 0.13 s

=== Scenario comparison (lambda = 1.0) ===
                       Granted  Excess  Residual risk  Productivity loss  SoD violations  Cap violations  Critical unmet
Current (all granted)    317.0   129.0         1889.0                0.0            30.0             5.0             0.0
Usage-based revoke       188.0     0.0          995.0                0.0            14.0             5.0             0.0
Optimized (MILP)         117.0     0.0          468.5              129.0             0.0             0.0             0.0

=== By department ===
 Department  Users  Granted before  Granted after  Risk before  Risk after
      Sales     10              72             30        390.5        84.0
Engineering      9              82             27        490.5        95.0
    Finance      8              69             27        463.5       159.0
         HR      6              42             12        266.0        78.0
    Support      7              52             21        278.5        52.5

=== Highest-risk revoked grants ===
U06 (Sales      ) DB_Admin       risk= 15.0  needed=N
U39 (Support    ) DB_Admin       risk= 15.0  needed=N
U03 (Sales      ) Prod_Deploy    risk= 13.5  needed=N
U09 (Sales      ) Prod_Deploy    risk= 13.5  needed=N
U24 (Finance    ) User_Admin     risk= 13.5  needed=N
U03 (Sales      ) Backup_Restore risk= 12.0  needed=N
U06 (Sales      ) Backup_Restore risk= 12.0  needed=N
U09 (Sales      ) Backup_Restore risk= 12.0  needed=N

–

7. How to Read the Results

7.1 Heatmaps: before and after

In the left heatmap, red cells are excess grants that nobody uses. They are scattered across every department, which is the visual signature of privilege creep. In the center heatmap, the red cells have turned into orange cells, meaning they were revoked at no productivity cost. Purple cells, if any appear, mark permissions that people did use occasionally but that were revoked because their risk outweighed the JIT friction, or because a separation-of-duties or holder-cap constraint forced the decision. Green cells are the permissions that survive.

7.2 The trade-off curve

The 2D Pareto plot shows that no single scenario wins on both axes:

  • Current state has zero productivity loss and the highest risk.
  • Usage-based revoke removes every unused grant but still ignores separation of duties and holder caps, so the console table shows nonzero constraint violations for it. Removing unused permissions is therefore necessary but not sufficient.
  • The MILP optimum (the star) sits on the frontier and, unlike the heuristic, satisfies every constraint. It accepts a small amount of JIT friction in exchange for removing the riskiest remaining grants.

The frontier is steep at first and flattens afterwards: the first reductions in risk cost almost nothing, and only the last ones require real friction. This is why the operating point should sit near the bend rather than at either extreme.

7.3 The 3D Pareto frontier

The third axis shows $\log_{10}\lambda$ explicitly, and color shows the number of granted permissions. Moving along the curve is moving along the policy dial: low $\lambda$ gives a lean, strict matrix with more friction, and high $\lambda$ gives a permissive matrix with less friction. The star marks the chosen policy, and you can see how many grants it keeps relative to both ends.

7.4 Risk by permission

The bar chart shows which permissions carried the most risk before and after. High-weight permissions such as DB_Admin, Prod_Deploy, User_Admin, and Payroll_Edit show the largest reductions, because their risk coefficients are large enough to outweigh friction for rarely used grants. Low-risk permissions such as Ticket_View barely change.

7.5 Risk removed by department and permission

The 3D bar chart shows where the cleanup happens. Tall bars mark department–permission combinations where large amounts of risk were removed. This view is useful in practice because it turns an optimization result into a work list: the tallest bars are the access reviews that deserve attention first.

8. Practical Takeaways

  1. Treat least privilege as an optimization, not a slogan. Revoking everything unused is cheap to compute but ignores separation of duties and holder limits. Putting these in the model makes the result compliant by construction.
  2. Protect critical work with hard constraints. Lower bounds on daily-use permissions guarantee that the optimization can never cripple a job, no matter how $\lambda$ is set.
  3. Price friction honestly. JIT access is the pressure valve that makes aggressive revocation tolerable. The model quantifies exactly how much friction each risk reduction costs.
  4. Choose the policy from the frontier. Instead of arguing about a single threshold, the Pareto curve lets security and operations teams pick a point together and see what each side gives up.
  5. Scale is not a problem. Pruning to granted entries and using a modern MILP solver means the same formulation extends to thousands of users and hundreds of permissions, and the sweep can be rerun every time the access data is refreshed.

9. Conclusion

We turned the least-privilege principle into a binary linear program that minimizes residual risk plus weighted productivity loss, subject to critical-access, separation-of-duties, and holder-cap constraints. Solving it with HiGHS through SciPy takes milliseconds, which allowed us to sweep $\lambda$ and visualize the full risk–productivity frontier in 2D and 3D. The result is a defensible, quantitative answer to the question every access review asks: which permissions can we take away without anyone noticing?