Key Takeaways
- Decide how identity will shape your data model during the initial design. A security review at the end can catch a missing check or a bad scope, but it can't restructure a schema that was never built to explain who had access and when.
- Once your access questions are about ownership and validity, a graph fits better than a role table. In our role tables, "who owns this account" required a chain of joins. In Neo4j, it's one traversal.
- About eighty thousand people own our service accounts. Keeping those owners engaged through job changes and departures is most of the governance work, because an account whose owner has moved on is a credential nobody is watching.
- When revocation travels over an event stream, it's eventually consistent. Know your worst-case staleness, cap it with a maximum cache age, and tell your security team what it is.
- Merging attestation, rotation, and vaulting into one workflow cut time-to-revoke from about three days to under one.
After the Login
For a single application, identity is mostly a vendor decision. You pick an identity provider, wire up SSO, validate tokens at the edge, and move on with the features. For one application and one team, that's the right call. It stops being enough as the organization grows. Login keeps working fine. Everything after that gets difficult: who approved a given access and whether it should still exist when an auditor asks about it months later.
This article draws on building and operating an identity and access management platform in regulated financial services, where a wrong entitlement becomes an audit finding. My argument is that at enterprise scale, identity has to be treated as part of the architecture. Teams that add it late tend to find it conflicts with decisions they've already locked in. I'll assume you have experience shipping OAuth 2.0 or SSO. What follows covers the design problems that appear once many services, and many owners, are involved.
Identity as an Architectural Concern
Identity belongs in architecture discussions because it constrains decisions that look unrelated. The data model is where this shows up first. Once you have to explain and audit access, "who can touch this resource" needs to be a relationship you can query with history, because auditors ask what was true six months ago as well as what is true today. Service boundaries and event flows raise their own problems; each gets its own section below.
Regulation adds another constraint. Here's a useful test: Could you answer "Who had access to this system on 14 March?" from your data model alone, without grepping logs across several tools? If not, an audit wasn't part of the design.
A security review can catch a missing check or a misconfigured scope, but it can't retrofit a schema that was never built to answer that question. The cheapest way to pass that test is to record every grant, revocation, and attestation as an event with a timestamp and an actor from the start. Then the auditor's question is one query against the event log.
Where Flat RBAC Breaks Down
The Role-Based Access Control (RBAC) model is used by default and is usually sufficient. It strains when your questions shift from "Does this user have this role?" to how things relate to each other. Our entities are people, the service accounts they own, and the accesses those accounts hold. The questions we kept asking were: "Who owns this account?", "Is this access still valid?", and "What does this person control?"
All three are relationship questions. Ownership is a relationship. An access has a validity window and an attestation history attached to it. A person who leaves or changes roles pulls a set of owned accounts along behind them. In a role table, you rebuild each of those links with joins every time you ask. In a graph, each link is an edge and the query walks them.
That's why our backend models these relationships in Neo4j, queried through GraphQL. GraphQL fits because a query's nesting mirrors the traversal: this account, then its owner, and then its access. A caller asks for exactly the slice of the graph it needs, from an account down to its valid accesses. Nobody handwrites traversal logic or over-fetches a large object and filters it in memory. Figure 1 shows the shape of the underlying model.

Figure 1. Ownership-and-access graph modeled in Neo4j, showing how flat RBAC breaks down when access needs expiration and nuance. (Image source: created by the author.)
Before the query, here is the model it runs against. There are three kinds of nodes: Person, ServiceAccount, and Resource. There are two associations between them. First, a person OWNS a service account, while second, a service account HAS_ACCESS to a resource. The association, HAS_ACCESS, possesses some properties such as expiration, the date for when the access will be no longer valid, and lastAttested, which displays the last date when the access was attested by the owner. Validity is an attribute of the edge rather than of any node since it refers to the particular combination of the account and resource nodes.
This query, as shown below, poses a question we frequently ask ourselves: Who is the owner of this account and which of its accesses are currently valid? Looking through the query from the top, the first MATCH gets the account based on its ID and follows the OWNS relationship until it reaches the owner. The next OPTIONAL MATCH follows the HAS_ACCESS relationship towards each of the resources, and the WHERE clause appended to it filters out any access where the expiry date has expired. Because it is an OPTIONAL MATCH, an account having no accesses left will yield its owner anyway. Finally, the RETURN clause yields the owner’s name along with all the accesses left with their resources, expiry dates, and last attested date in a single array.
Neo4J Cypher Query
// Who owns this account, and which of its accesses are still valid today?
MATCH (p:Person)-[:OWNS]->(a:ServiceAccount {id: $accountId})
OPTIONAL MATCH (a)-[g:HAS_ACCESS]->(r:Resource)
WHERE g.expires >= date()
RETURN p.name AS owner,
collect({
resource: r.name,
expires: g.expires,
lastAttested: g.lastAttested
}) AS validAccesses
Written as SQL against role tables, the same question needs every link lined up by hand:
-- Who owns this account, and which accesses are still valid today?
SELECT owner.name, res.name, g.expires, g.last_attested
FROM service_account acct
JOIN account_owner ao ON ao.account_id = acct.id
JOIN person owner ON owner.id = ao.person_id
LEFT JOIN account_grant g
ON g.account_id = acct.id
AND g.expires >= CURRENT_DATE
LEFT JOIN resource res ON res.id = g.resource_id
WHERE acct.id = :accountId;
Even here, it's easy to get it wrong. Put the expiry check in the WHERE clause instead of the join, and accounts with no valid grants vanish from the result, owner and all. The query works, but it still leaves three problems out of view. Ownership is a single join in this example, but ownership can be delegated, and once it can, that join becomes a walk up a chain of unknown length. In the graph, the same chain is a variable-length path in one pattern. The expiry check also has to be repeated in every query that touches grants. Any query that leaves it out returns access that should have been removed. In the graph, the check sits inside the traversal, so a consumer can't skip it by accident. Finally, when a person leaves, the accounts they own aren't in front of you, and you have to go looking for them. In the graph they're one hop away, because "owns" is an edge. Both queries are simplified from what we run in production.
But Neo4j has inherent costs. It's one more datastore to operate, and traversal performance depends heavily on how you model things. A badly shaped graph can be slower than the join it replaced. It was still worth it for us, because ownership and validity are things we traverse constantly. The flat version pushes the hard part onto whoever writes the next query.
Service Accounts: Eighty Thousand Owners, Three Tools
Comparing everything in the platform, the service accounts took the most governance work. Every service account is owned by a real person: Roughly eighty thousand people review, rotate, and vault the accounts they own. An account’s safety depends mostly on that owner: whether they're still engaged and whether they've changed teams or left the company. An account whose owner has moved on is exactly the stale, over-privileged credential that nobody notices until it's misused.
Before the rebuild, the three things an owner had to do (attestation, rotation, and vaulting) lived in three separate tools. An owner had to remember which tool did what. That's a small annoyance for someone who does it daily. For someone who does it twice a year, or who inherited an account when a colleague left, it's a real failure mode.
The rebuild put all three owner requirements into a single workflow, so an owner sees one screen with what they own and what's due. Getting there took more than wiring screens together. Each tool had been built with its own assumptions about schedules and ownership, and once they shared a workflow, those assumptions had to be reconciled. Figure 2 shows the revocation workflow before and after the consolidation: Three separate tools, with revocation taking about three days, became one unified workflow that revokes in under a day.

Figure 2. Revocation workflow before and after tool consolidation. (Image source: created by author.)
Integrating a Vault We Didn't Own
In enterprise identity work, the risky integrations are usually the ones where your system depends on a system you don't govern. For us, that was vaulting. We already handled attestation and rotation well before the rebuild. We had written that code ourselves, so we knew how it failed. Vaulting ran in a separate tool, owned by another team, on infrastructure we couldn't change. Folding it into our workflow meant a critical control now depended on their release schedule and their outages.
The hard part was deciding what our workflow should believe when their API didn't answer, or answered slowly, or answered after our call had already timed out. Those are not edge cases when you're doing this for tens of thousands of owners. They happen every day, somewhere. So we made the integration "fail closed". If the vault was unreachable, the workflow didn't report the step as complete, and an owner was never told a credential was vaulted when it wasn't. That sounds obvious on paper. In practice it meant our side couldn't treat "we sent the request" as success. We had to confirm the vault's own state, reconcile it against ours, and treat any gap between the two as something our side resolves.
Propagating Identity Across Service Boundaries
If every service makes its own access decisions, authorization logic ends up scattered across dozens of codebases, where nobody can audit it. When a rule changes, you hunt for it through every service that has implemented its own version, and you can miss one. What works is letting identity context travel across boundaries while the decision stays in a dedicated policy decision point. Identity propagates through scoped claims using OAuth 2.0 token exchange, so a service acting for a caller gets a narrowed token instead of reusing a broad one.
The controller asks, and the policy decision point decides as shown in the following code snippet:
Java Code
// Authorization delegated to a policy layer, not hard-coded in controllers.
// Illustrative Spring Security style; exact API varies by version.
@Bean
SecurityFilterChain filterChain(HttpSecurity http, PolicyDecisionPoint pdp) throws Exception {
http.authorizeHttpRequests(auth -> auth
.anyRequest().access((authentication, context) -> {
AccessRequest req = AccessRequest.from(authentication.get(), context.getRequest());
return new AuthorizationDecision(pdp.permits(req));
})
);
return http.build();
}
The controller never sees the rules, so changing a rule requires changing the PDP and nothing else.
Across a service hop, the incoming token is exchanged for a scoped one before the downstream call:
Java Code
// Narrow the caller's token to just what the downstream call needs,
// via OAuth 2.0 token exchange (RFC 8693). Illustrative.
TokenExchangeRequest exchange = TokenExchangeRequest.builder()
.subjectToken(incomingAccessToken)
.audience("downstream-service")
.scope("accounts:read") // scoped down, not the caller's full grant
.build();
String downstreamToken = tokenExchangeClient.exchange(exchange).accessToken();
The token for each hop records its audience and scope, so the audit log shows what every call was for.
Revocation Over Kafka
Entitlement changes propagate as events using Kafka. A grant, a revocation, or a change of ownership becomes a message that interested services consume. That's a sensible design for a distributed system and it indicates revocation is eventually consistent. Access stops once consumers process the event and invalidate any cached decisions, which happens some time after the revocation is recorded. That gap should be measured and capped before anyone describes revocation to a security stakeholder.
A consumer handling a revocation invalidates the cached decision, so the next check re-resolves from the source of truth:
Java Code
// Kafka consumer: an entitlement revocation invalidates the cached decision.
// Illustrative; run against your own broker and cache.
@KafkaListener(topics = "entitlement-events", groupId = "authz-cache")
public void onEntitlementEvent(EntitlementEvent event) {
if (event.type() == REVOKED) {
decisionCache.invalidate(event.accountId(), event.resourceId());
auditLog.record(event); // the revocation is itself an audited fact
}
}
Events can be lost, so the cache also has a time limit. A cached decision is served only while it's younger than a maximum age. After that, it's re-resolved from the source of truth whether or not an event arrived. That maximum age is the worst case scenario for a dropped event. Before we relied on the design, I simulated the consumer and cache to confirm that a missed event never kept access live past it.
Choosing the maximum age involves a real trade-off. A short maximum age causes stale access to expire quickly, which is what security wants, but every expiry sends another check back to the source of truth. At our volume that load isn't free. A long maximum age is easy on the system and worse for revocation, because a dropped event has longer to matter. You pick a number you can live with on both sides, then state it plainly: A lost event leaves access live for at most that long. That's a sentence a security team can plan around.
The cache window was the small part, though. The larger delay was procedural. Before the rebuild, fully revoking access took around three days, most of the time being spent on steps a person had to complete in separate tools. With a single workflow, access revocation takes under a day.
When This Is Overkill
For a single app with a few roles and one team, you can skip all of this. Flat RBAC in your primary database is the right choice, and Neo4j, Kafka, and a policy layer would be over-engineering you'll regret maintaining. The threshold is organizational: many services making access decisions, service accounts that outnumber and outlive the people minding them, ownership that has to be re-verified, and an audit obligation with teeth. Here's how I would weigh each piece:
| Component | Worth It When | Skip It When |
| Graph database | You traverse ownership and validity constantly | Whether a user holds a role is all you need to ask |
| Event stream for entitlements | Access changes must reach many services, and you can state the staleness bound | A handful of services read access from one database |
| Central policy layer | Authorization logic would otherwise spread across many codebases | One app owns all of its own rules |
What's Next
Authorization is moving out of individual services and into policy engines that can be tested on their own. For service accounts, the zero-trust version of this is continuous verification: an account re-proves its entitlements on a schedule instead of keeping them by default. Our service account rebuild was a manual version of the same idea, because attestation is reverification with a human in the loop. The interesting question is how much of that human step can be made automatic without losing the accountability that a named owner provides.