Enforcing Least Privilege for Flag Management

This how-to is part of RBAC and Flag Change Governance. Your flag console has one “admin” role, everyone who needs to toggle anything has it, and that single role can change any flag in production. This how-to replaces that with least privilege: roles bound to identity-provider groups, access scoped by environment and namespace, so a payments engineer cannot touch checkout flags and no one edits production without an explicit grant.

Least privilege is not a security nicety you bolt on after an incident — it is the property that bounds blast radius before anything goes wrong. A flag is a live production switch; a single over-broad admin role means every holder of that role is one fat-fingered toggle away from disabling checkout for every customer, and a single compromised session is a company-wide outage. The goal here is that the worst a role can do is bounded by design: the maximum damage from any one identity is the union of the specific environment-and-namespace cells it was explicitly granted, and nothing more. When you can state that boundary in a sentence and prove it with a test, you have least privilege; when you cannot, you have a global admin wearing a narrower name tag.

Access scoped by environment and namespace Instead of one global admin, roles are scoped: a team role can edit its own namespace in staging and prod, an editor can edit any namespace only in dev and staging, and no role has unrestricted prod-plus-all-namespaces access. environment → namespace dev / staging prod editor: any namespace payments: ops.payments.* payments: ops.payments.* no global prod editor requires elevation
The grid is the model: each grant is a specific environment crossed with a specific namespace, and the dangerous cell — edit anything in prod — simply does not exist as a standing role.

Prerequisites

Least-privilege prerequisites An IdP with groups, a namespace taxonomy, and a scopable permission model. IdP groups membership source Namespace taxonomy scopes mean something Scopable perms env + namespace
Least privilege is only as fine-grained as your namespaces — without the taxonomy, "scope by namespace" has nothing to scope to.

Step-by-Step Procedure

Step 1 — Bind roles to IdP groups, never individuals

Define each role’s grant and attach it to an identity-provider group so access appears and disappears with team membership automatically. The reason to bind to groups rather than individuals is lifecycle: when someone joins the payments team, their access to ops.payments.* should already exist the moment HR adds them to team-payments in the IdP, and it should vanish the instant they transfer out — without anyone filing a ticket against your flag console. Bind grants to named users and you inherit the oldest failure mode in access control: the leaver who kept their access because no one remembered to revoke it. Groups make revocation a side effect of the offboarding you already do.

Keep the grant file itself in version control and require review to change it — the grants are policy, and policy that can be edited without a second pair of eyes is not much of a control. A useful discipline is that the idp_group names in this file are the only coupling to your identity provider; everything downstream reasons about roles, environments, and namespaces, so swapping IdPs later touches one column, not your whole authorization model.

# grants.yaml — roles bound to groups
grants:
  - role: payments-editor
    idp_group: team-payments
    environments: [staging, prod]
    namespaces: ["ops.payments.*"]
  - role: web-editor
    idp_group: team-web
    environments: [dev, staging]         # prod needs separate elevation
    namespaces: ["web.*"]

Step 2 — Evaluate scope on every change

At change time, deny any change whose environment or flag namespace falls outside the actor’s grant. Both dimensions must match. The and in that check is load-bearing — an or would let a payments editor who legitimately holds prod for ops.payments.* also edit web.* in prod, because one dimension passed. Requiring both means a grant is a specific cell in the environment-by-namespace grid, not a union of a whole row and a whole column.

Enforce this on the write path, not in the UI. Hiding the toggle a user may not flip is good ergonomics, but if the API accepts the change anyway, you have obscurity, not a control — anyone with a token and curl bypasses it. Put in_scope in front of every mutation, including bulk edits, imports, and the “revert” button, and default to deny: if a flag key matches no grant pattern, the answer is no, not “unspecified, so allow.” One more subtlety worth getting right early — decide whether the environment on the change is the actor’s target environment or something inferred from the flag’s current state, and make it explicit; a change that promotes a value from staging to prod must be scoped against prod, the destination, or the elevation gate in Step 3 does nothing.

# scope_check.py — env AND namespace must both be in scope
import fnmatch

def in_scope(grant, env, flag_key) -> bool:
    env_ok = env in grant["environments"]
    ns_ok = any(fnmatch.fnmatch(flag_key, p) for p in grant["namespaces"])
    return env_ok and ns_ok

Step 3 — Require elevation for production

Make production access a deliberate, time-boxed elevation rather than a standing grant, so day-to-day work happens in lower environments and prod changes are conscious acts. The elevation is not a fourth role you hand out — it is a short-lived overlay on an existing scoped role. A payments editor who elevates gains prod for ops.payments.* for thirty minutes; they do not gain web.*, because the elevation widens the environment dimension only, never the namespace. That keeps the blast radius of an elevated session no larger than the person’s standing namespace remit, which is the whole point.

Require a reason and record it — the reason string is what turns a mystery prod change three weeks later into an answerable question, and it is the join key between this elevation and your audit trail. Set the window short enough that people cannot pre-elevate “just in case” and leave it open all afternoon; thirty minutes covers a real change and expires before it becomes a standing grant. Break-glass access for incidents is the one case where you may skip the approval on the elevation itself — but never skip the record: an unapproved emergency grant that is loudly logged and reviewed the next morning is fine; a silent one is the hole every audit finds.

# elevation.py — time-boxed prod grant, expires automatically
def grant_prod(actor, minutes=30, reason=""):
    require_role(actor, "release-manager")
    if not reason: raise Denied("prod elevation requires a reason")
    return TimeBoxedGrant(actor, env="prod", expires_in=minutes, reason=reason)

Step 4 — Review grants on a schedule

Periodically reconcile grants against current team membership and remove anything unused, so privilege does not accumulate silently over time. Privilege only ever ratchets upward without this step: a team spins up for a project and gets a namespace, the project ends, the grant stays; someone needs prod once, gets a role, keeps it forever. A quarterly reconciliation that removes any grant unused for ninety days turns that ratchet into a spring — access that stops being exercised falls away on its own. Ninety days is a deliberate choice: long enough to survive a normal release cadence and a holiday lull, short enough that a genuinely abandoned grant does not linger for two quarters.

Make the review low-friction or it will not happen. Surface unused grants automatically, default the decision to revoke, and make re-granting cheap — a grant that is one reviewed pull request to restore is easy to remove aggressively, whereas one that takes a week of tickets to get back tempts everyone to keep it “just in case.” Treat the reconciliation output as an alert, not a report you file: an unused prod grant is a finding to close, not a row to acknowledge.

# review.sh — flag grants unused in 90 days for removal
flagctl grants list --json \
  | jq -r '.[] | select(.last_used_days > 90) | "\(.role) \(.idp_group) unused \(.last_used_days)d"'
The four least-privilege steps Bind roles to IdP groups, evaluate scope on every change, require time-boxed elevation for production, then review grants on a schedule. 1 Bind to groups auto lifecycle 2 Scope check env + namespace 3 Elevate for prod time-boxed 4 Review grants prune unused
Steps 1–3 grant the minimum; step 4 keeps it minimal, because privilege that is never reviewed only ever grows.

Verification

Assert the scope boundary: a payments role is denied a web-namespace change and a prod change without elevation, and allowed its own namespace where granted. The denials are the tests that matter — anyone can confirm the happy path where an editor edits their own flags, but least privilege is defined entirely by what it refuses, so the assertions to guard in CI are assert not in_scope(...) and assert denied(...). Write one negative test per boundary you claim to enforce, and treat a regression that turns a deny into an allow as a release blocker, not a flaky test to retry. Parameterize these over every role in grants.yaml so a new team added to the config automatically inherits a cross-team denial test rather than depending on someone remembering to write one.

# scope.test.py
def test_least_privilege():
    g = grant("payments-editor")
    assert not in_scope(g, "prod", "web.checkout.x")   # wrong namespace
    assert in_scope(g, "prod", "ops.payments.processor")# own namespace, granted env
    assert denied(change("web.nav.x", actor="payments-eng"))  # cross-team blocked
Scope boundary enforced A payments role is denied a web-namespace change but allowed its own payments namespace, proving cross-team access is blocked. payments → web.* denied payments → ops.payments.* allowed
The cross-team denial is the property that matters — it is what stops one team's mistake or breach from reaching another team's flags.

Gotchas & Edge Cases

Three least-privilege edge cases Wildcard namespaces that grant too much, shared service accounts that erase individual accountability, and elevation that never expires. Greedy wildcard namespace "*" is not least privilege Shared accounts erase accountability use per-person Sticky elevation prod grant never expires
Sticky elevation is the quiet erosion — a "temporary" prod grant that never expires becomes a standing global admin by another name.

Troubleshooting & FAQ

A team needs occasional access to another team’s flags. Do we broaden the grant?

No — a broad standing grant for an occasional need is exactly what least privilege prevents. Use a time-boxed elevation for the specific occasion, scoped to the specific namespace, that expires on its own. The access exists when needed and not otherwise.

Managing per-namespace grants for many teams is a lot of config. Is it worth it?

The config scales with teams, not flags, and it is declarative so it reviews like code. The alternative — one broad admin role — trades a little config for an unbounded blast radius and no meaningful audit. Generate the grants from your team-to-namespace ownership map to keep it manageable.

How does least privilege interact with approval workflows?

They compose: least privilege decides who may request or approve a change, and the approval workflow decides whether a specific high-risk change proceeds. Scope narrows the set of eligible actors; approval adds a second check within that set. One rule keeps the two honest: an approver must be in scope for the change they approve. If a payments lead cannot edit web.*, they should not be able to approve a web.* change either — otherwise approval becomes a side door around the scope you just built.

How should CI and deployment pipelines authenticate to change flags?

Give each pipeline its own machine identity with a narrowly scoped grant — the same environment-and-namespace model you apply to people, not a shared admin token pasted into a secret. A deploy job that only ever flips web.* in staging gets exactly that grant, so a leaked pipeline credential cannot reach production or another team’s flags. Rotate the credential, prefer short-lived tokens minted per run over long-lived secrets, and log the pipeline’s changes under its own identity so automated changes are as attributable as human ones.

One senior engineer legitimately works across every team. Do we give them a global role?

Almost never. “Works across every team” is usually a handful of namespaces, not literally all of them, so grant that specific set. For the genuinely rare case that needs everything, use elevation rather than a standing global role: the access exists for the task and expires, so even a trusted person is not a permanent single point of catastrophic failure. A standing global admin is a target — for attackers and for accidents — regardless of how careful its holder is.

What is the difference between least privilege and separation of duties here?

Least privilege bounds how much any one identity can touch; separation of duties ensures no single identity can complete a sensitive action alone. They are complementary: scoping a payments editor to ops.payments.* is least privilege, while requiring a second person to approve that editor’s prod change is separation of duties. You want both — narrow scope limits blast radius, and a second signer limits the damage a single in-scope actor can do on purpose or by mistake.

Where do temporary contractors and vendors fit in this model?

Treat them exactly like employees but lean harder on expiry. Put them in an IdP group scoped to only the namespaces and environments their engagement requires, and set the group membership itself to expire at the contract end date so access ends automatically. Never grant a contractor production elevation as a standing capability; if their work touches prod, gate each occasion through the same time-boxed elevation everyone else uses, with a reason recorded.