Back to Engineering
Engineering Case Study

Identity and Access Management with Least-Privilege Enforcement

A reference architecture for IAM that treats every credential as attack surface: short-lived tokens, policy-as-code, and provable least privilege instead of static roles.

OIDC/OAuth2SPIFFE/SPIREOPA/RegoSCIMAWS IAM / GCP IAMVault/KMSPAM (Just-in-Time access)Audit logging
PyramidLedger Engineering10 min read

The problem: privilege accumulates, it doesn't decay

Every IAM system starts clean and rots the same way. A contractor gets S3 read access for a two-week migration and keeps it for two years. An on-call engineer is granted production database write access during an incident and nobody revokes it afterward. A service account is created with AdministratorAccess because the actual permission set needed wasn't known yet, and "we'll tighten it later" never happens because tightening a live credential risks an outage. The result, in almost every environment we've assessed, is the same shape: a small number of identities that actually need broad access, and a long tail of identities — human and machine — that were granted broad access once, for a reason that no longer applies, and nobody owns the decision to remove it.

Least privilege is not a policy statement, it's an operational property that has to be continuously re-earned. The architectural question is not "how do we grant access" — every IAM system does that well — but "how do we make over-provisioned access expensive to create and cheap to detect," and "how do we make access expire by default rather than accumulate by default." That reframing drives most of the design decisions below.

Core model: identity, not accounts, as the unit of trust

The architecture separates three concerns that most legacy IAM systems conflate: authentication (who are you), authorization (what are you allowed to do), and provisioning (how did you come to exist as an identity at all). Human identity federates through OIDC from a single identity provider — no local accounts in downstream systems, ever, because local accounts are exactly the credentials that survive an offboarding process that only updates the IdP. Workload identity is handled separately and is arguably the harder problem: services authenticate to each other using short-lived, cryptographically attested identity documents (SPIFFE SVIDs or equivalent cloud-native workload identity), not long-lived API keys baked into config or environment variables. A static credential that doesn't expire is a liability the moment it's created, regardless of how tightly its policy is scoped, because the policy can be correct today and wrong in six months while the key sits unchanged.

Authorization decisions are externalized from application code into a policy engine (OPA/Rego or an equivalent cloud-native policy layer) evaluated at the point of enforcement — API gateway, service mesh sidecar, or database proxy — rather than scattered as `if user.role == 'admin'` checks across a codebase. This matters for least privilege specifically because it makes the *current, effective* permission set queryable and auditable as a single artifact, instead of requiring a code review to reconstruct what a role can actually do. A policy is something you can diff, test, and require a second reviewer to approve; an `if` statement buried in a controller usually isn't reviewed as an access-control change at all.

Provisioning is the third leg and the one most often skipped in reference discussions: identities and their entitlements are defined declaratively (Terraform for infrastructure roles, SCIM for SaaS and directory-synced access) and reconciled against a source of truth on a schedule, not just at creation time. Drift — a permission added by hand in a console during an incident — is detected and either reverted or explicitly re-approved, not silently absorbed into the baseline.

Enforcement path: default-deny, time-boxed, and scoped to the resource

Every request — human or service — resolves through the same sequence: authenticate identity, evaluate policy against the specific resource and action being requested, and log the decision regardless of outcome. The default posture is deny; every grant is an explicit, reviewable exception rather than an implicit consequence of role membership. This sounds obvious stated abstractly, but it rules out a common pattern: coarse roles like `developer` or `backend-team` that bundle together dozens of unrelated permissions because it was easier to create one role than to model the actual access pattern. Coarse roles are the single biggest source of unnecessary privilege in production IAM systems, because they grant access to resources a given person will never touch, and nobody notices because the unused grant never causes an incident — it just sits there as risk.

For privileged and break-glass access specifically — production database writes, infrastructure changes, customer data access for support — the architecture uses just-in-time elevation rather than standing privilege. An engineer requests time-boxed access to a specific resource for a specific, logged reason; a policy engine or a human approver grants it for a bounded window (an hour, a shift, the duration of an incident); the grant expires automatically rather than requiring a manual revoke step. This inverts the default failure mode: instead of access lingering until someone remembers to remove it, access disappears unless someone actively extends it, which is the direction you want the failure mode to point in a system nobody watches continuously.

Service-to-service authorization follows the same resource-scoped principle rather than network-perimeter trust: a service's identity token is scoped to the specific downstream API and even the specific operation it needs, not to "anything inside the VPC." Network segmentation (private subnets, security groups, service mesh mTLS) is still layered in as defense in depth, but it is not treated as the authorization boundary — a compromised pod inside a trusted network should still be blocked by policy from calling an API it has no legitimate reason to call. Treating network location as a proxy for authorization is exactly the assumption that lets lateral movement succeed after an initial foothold.

Making privilege visible: audit as a first-class output, not an afterthought

A least-privilege system is only as good as its ability to answer, cheaply and continuously, "who can currently do X, and why." That requires every authorization decision — grant, deny, and policy evaluation — to be written to an append-only, tamper-evident log, separate from application logs, with enough context (identity, resource, policy version, decision) to reconstruct the reasoning after the fact without needing to interview the person who set it up. Access reviews then become a query against this log plus the current policy state, rather than a quarterly spreadsheet exercise where a manager rubber-stamps a list of permissions they don't fully understand — the latter is common in practice and provides almost no real assurance, because the reviewer has no way to distinguish "used weekly" from "granted three years ago and never touched."

Unused-privilege detection is the other half: comparing granted permissions against actually-exercised permissions over a rolling window surfaces the long tail directly, rather than relying on a human to notice. A permission that hasn't been used in ninety days is a strong, mechanically-detectable candidate for revocation, and automating that detection — flagging it for owner confirmation rather than silently ripping it out — is far more effective at reducing standing privilege over time than any amount of policy review, because it doesn't depend on anyone remembering the original justification.

Trade-offs we made, and what we would not do

Just-in-time elevation and short-lived credentials add latency and friction to legitimate work — an engineer who needs production access mid-incident is now blocked on a request-and-approve step, however fast, at exactly the moment speed matters most. We treat this as an acceptable and deliberate trade: the approval path for declared incidents is pre-authorized and near-instant (a break-glass role activated by an incident-management system, not a Slack approval chain), while routine access outside an incident goes through the slower, reviewed path. The alternative — leaving privileged access standing at all times so it's never in the way — trades a small, constant convenience for a much larger, continuous attack surface, and we don't think that trade is worth it for anything touching production data or infrastructure.

We would not build a policy engine that's only enforced at the application layer with no independent enforcement point (API gateway, mesh, or database proxy), because a single bug in application code then becomes a full authorization bypass; defense needs at least one enforcement layer the application can't accidentally disable. We also would not rely on cloud-provider IAM consoles as the system of record for permissions — console-granted access is exactly the drift that reconciliation is meant to catch, and treating infrastructure-as-code as authoritative only works if manual changes are actively detected and reverted, not merely discouraged by policy memo. Finally, we are deliberately cautious about attribute-based access control (ABAC) systems that compute authorization from a large number of dynamic attributes: they're more expressive than role-based models, but a policy that depends on ten interacting attributes is much harder for a human reviewer to reason about at a glance, and an unreviewable policy is a policy nobody actually reviews. We use ABAC selectively, for the small number of high-value decisions where the expressiveness is worth the audit cost, and keep everything else on simpler, coarser-grained role and resource scoping that a reviewer can verify by reading it.

Building something like this?

We engineer secure, regulated, and AI-driven systems at this depth. Tell us what you are building and we will help you architect it.

Start Your Project