0%

Pulling Permissions Out of the Menu: Design Trade-offs in an Authorization Kernel

🌐 Language: English Version | 中文版

Context: This article covers the authorization side of an IAM stack — deciding what a given user may do inside an application. The authentication side is described in Enterprise Unified Identity Governance Architecture with OAuth2/OIDC and Per-Client Authorization Isolation at the Gateway. The two sides share one source of truth for identity and organization, with the boundary drawn between them.

In many admin systems, permissions grow out of the menu: the menu comes first, an access level is attached to each menu item, and roles are then bound to menus. The order is natural, because the earliest requirement is simply “different people see different menus”.

The trouble starts when requirements move one step further. Buttons on a page need to be controlled individually, and so do the APIs exposed to third parties. Neither has a place in a menu-based model, so an otherwise clear model starts accumulating patches.

While converging an authorization layer into a standalone component, I went through these trade-offs again. This article records the decisions that mattered most, and why the alternative was not taken each time.

None of them depend on a specific technology stack. The component can be a library inside a framework or a standalone service; whether it is implemented in Java, Go, or Python does not change any of these judgments. What has to be worked out is the model itself and its boundaries.

TL;DR

  • The primary model moves from menu to a standalone permission; the menu becomes one consumer of permissions.
  • Stable identifiers are separated from display names, and identifiers are immutable once generated.
  • Authorization writes use declare-scope-then-overwrite semantics, so multiple managers of the same config do not clobber each other.
  • Exactly one deny case is supported; everything else is explicitly deferred.
  • Identity and organization data are not copied locally, and group permissions are not materialized.
  • Auditing is an extension point, not a built-in feature.

1. Starting Point: Permissions Growing Out of the Menu

The pre-change model was roughly three things:

  • A binding table between roles, menus, and permissions;
  • A field on the menu expressing the access level;
  • A hardcoded admin identifier that short-circuits several checks.

Each of these works on its own. Together they expose three problems.

First, button and API permissions have nowhere to live. A menu describes navigation items. An “Export” button on a page, or an endpoint called by a third party, is not a navigation item. Forcing them into the menu means relying on convention to tell which menu rows are actually buttons, and the model starts losing its explanatory power.

Second, menu changes leak into permission semantics. The menu carries presentation concerns (hierarchy, ordering, icon, route) and authorization concerns at the same time — two things with completely different rates of change, bound to the same record. Reordering a menu touches the same data as granting access.

Third, the hardcoded admin identifier bypasses the whole model. Once there is a branch that says “allow everything if admin”, the permission system is no longer reasoning-friendly: you cannot answer “why can this person access this” from the data, only from the code.

All three point at the same thing: the menu was carrying a responsibility that was not its own.

2. Decision 1: Permission Becomes the Primary Model, Menu Becomes a Consumer

The first decision is to make permission the primary model.

A permission is the smallest authorization unit, with a type field distinguishing three uses: menu access, page action, and API call. The three are identical for authorization computation; the distinction exists only for grouping in the admin UI, classifying server-side checks, and making logs readable.

The menu drops back to an ordinary navigation configuration that references a permission by identifier:

flowchart LR
    U[User] --> R[Role]
    O[Organization] --> R
    G[Group] --> R
    R --> P[Permission]
    P -. references .-> M[Menu]

Menu visibility is decided by two explicit states: visible once authenticated, or visible only when a specific permission is held.

One detail took some deliberation at the time. There is a principle: business semantics must be modeled explicitly — never encode meaning in a null value. And a menu that is visible once authenticated genuinely has no permission attached.

The two do not conflict: the semantics are carried by the explicit access-state field, and the absent permission only means “this field does not apply here”. Reverse it — use “no permission attached” to mean “visible once authenticated” — and the meaning hides inside the null value; the moment a third access mode appears, the implicit convention breaks.

The direct gain is that permissions no longer depend on the menu’s existence: a permission can exist before its menu, and several menus can point at the same permission. The menu becomes a projection of permissions in the presentation layer.

3. Decision 2: Stable Identifier vs. Display Name

Both roles and menus are split into two fields:

  • Stable identifier: referenced by code, configuration, seed data, and logs;
  • Display name: editable at any time.

The rule: a stable identifier is immutable once generated. Users can edit names, descriptions, and statuses freely, but the identifier is fixed from the moment it is created.

Identifiers are generated by the system rather than typed by the user, and carry an unpredictable random suffix so that identically named roles do not collide during a merge or migration.

Why not let users fill it in? Because the stable identifier is the referenced party. Permission checks in code reference an identifier like “export orders”, seed data references the role identifier, and logs write it too. Allowing edits means one rename invalidates every reference in code — and it fails at runtime, with no compile-time signal.

The cost is that identifiers are not human-readable and must be displayed through the name field. That cost is far smaller than runtime reference breakage.

4. Decision 3: Declare the Scope Before Overwriting

The role authorization screen has one unavoidable question: does the submitted payload represent a full list, or an incremental change?

Full overwrite is simple to implement and matches “what you see is what you get”, but it has a precondition — this role’s permissions are managed from exactly one place. As soon as two modules configure permissions on the same role, the first submitter’s changes are silently wiped by the second, with no warning.

Incremental changes avoid that, but they require distinguishing “added”, “removed”, and “unchanged”, and they cannot express “clear everything”: an empty set could mean either “change nothing” or “revoke all”.

The approach chosen here is declare the scope, then overwrite:

  1. The caller declares which permissions it governs (the managed scope);
  2. It then states which of them are currently selected (the selected set);
  3. Writes delete only the bindings inside the managed scope; bindings outside it stay untouched;
  4. Bindings inside the scope are then rebuilt from the selected set.

Each module therefore sees the full state of its own slice — semantically still an overwrite, so the implementation stays simple — while the blast radius is limited to the scope it declared. “Clear” also gets an unambiguous meaning: the selected set is empty while the managed scope stays unchanged.

A few supporting constraints:

  • The selected set must be a subset of the managed scope; anything outside is rejected;
  • Menu-type permissions cannot go through this entry point, avoiding two paths mutating the same data;
  • The role is locked before writing, so nothing interleaves between delete and insert;
  • The managed scope is determined server-side and is not accepted from the client.

I have since run into the same class of problem elsewhere: whenever multiple parties co-manage one piece of configuration, the same dilemma appears — full overwrite clobbers, incremental changes cannot express “clear”. “Managed scope plus selected set” makes the scope explicit and gets both benefits.

One boundary trade-off: when permission registration hits a concurrent conflict, the component does not retry or upsert internally — it fails and lets the caller retry. Registration usually happens inside the caller’s own transaction, and an internal retry would blur the transaction boundary: which write actually took effect may be understood differently by the caller and by the component. Handing the decision back keeps the caller’s retry semantics clear.

5. Decision 4: Keep Exactly One Deny Case

The first version uses a pure allow-union model: roles from all sources are unioned, with no deny.

The single exception is this scenario:

  • An organization is granted a role;
  • Nearly everyone in that organization inherits it;
  • Exactly one person needs to be excluded.

Bindings from organizations, groups, and similar sources express allow only; a deny type is kept on the user-direct relationship, and a match removes the corresponding role from the union.

flowchart TD
    A[User direct grant] --> U[Union of roles]
    B[Organization inheritance] --> U
    C[Group inheritance] --> U
    U --> E[Remove roles hit by user-level deny]
    E --> F[Filter out disabled roles]
    F --> G[Expand into permission set]

Explicitly out of scope: role-to-permission deny, priority arbitration among multiple rules, independent deny evaluation for actions versus APIs, and generic expression-based policy.

The reason: the real requirements collected cluster almost entirely on the single case above, while a generic policy engine turns “what permissions does this person end up with” from a table lookup into an inference that needs an evaluator. The latter is far more expensive to debug — you must first understand how rules are evaluated before you can answer why one specific user lacks one specific permission.

Do the one case now. If requirements genuinely spread later, an evaluator can be introduced then. Going the other way — building it and then trying to remove it — is much harder.

6. Decision 5: No Local Copy of Identity or Org, No Materialized Group Permissions

This component is positioned as an in-application authorization kernel; the source of truth for identity and organization lives in a unified identity service. The boundary is:

  • Identity service: user identity, organization structure, system admission, and gateway-side token validation plus header propagation (see Per-Client Authorization Isolation at the Gateway for the isolation mechanism);
  • Authorization kernel: roles, permissions, menu tree, bindings, permission evaluation, and the audit extension point.

Copying user or organization master data into the authorization side is explicitly prohibited. Authorization tables store identifier references only — no names, departments, or hierarchy levels.

Groups (project teams, cross-department collaborations, and other non-hierarchical collections) get a particular trade-off: group roles are not materialized into user-direct relationships. When a user joins or leaves a group, permissions take effect or lapse through the current result of the membership query — no sync job needs to run.

Materialization makes evaluation faster: only one user-direct table to query. But it introduces a harder question: after membership changes, when and by whom is the materialized result updated? Sync delay, sync failure, and partial success all produce a gap between effective permissions and configured permissions, and that gap is very hard to notice.

The cost of not materializing is one extra membership lookup per resolution. The first version accepts that cost.

Since nothing is stored locally, resolving permissions requires calling the identity service. An adapter interface isolates that dependency and allows a stub implementation in tests — the integrating system only has to provide that one implementation.

7. Decision 6: Auditing Is an Extension Point, Not a Built-in Feature

Authorization changes need an audit trail, but where the trail goes differs per system: some write their own audit table, some publish to a message queue, some feed an external audit center.

So the approach here is:

  • Publish a standardized audit event after a successful write, and nothing more;
  • Ship a no-op implementation by default, so startup is unaffected when auditing is not wired up;
  • Create no audit tables and embed no storage logic specific to one business system;
  • Audit failures must not propagate back into the authorization flow; fault tolerance belongs to the integrating system.

The event model fixes a few fields — actor, event type, target, outcome, timestamp, and trace ID. Event types are narrowed to what the first version needs: create/update/delete of roles and menus, plus the various binding changes.

The key trade-off is separating publishing from persisting. The kernel decides what counts as an authorization change and which fields the event carries; where it lands is the integrating system’s decision. Wiring up a message queue or an external audit center means adding one implementation, with no change to the core logic.

8. Explicitly Deferred

The first version carries an explicit non-goals list. The items with the largest impact:

  • Distributed cache and cross-node invalidation: no external cache in the first version; caching is optional and process-local by default. Permission versioning and cross-node invalidation broadcast are deferred.
  • Data permissions and query rewriting: row-level and column-level concerns are a different class of problem; mixing them with functional permissions complicates both models.
  • Complex deny and attribute-based policy: see Decision 4.
  • Admin UI: the kernel provides capability only; the UI belongs to the business system.

My habit now is to write down “what we are not doing” alongside what we are doing. These items were not overlooked — they were deferred deliberately, once the cost was understood. When the time comes to build them, at least the original reason for not building them is on record.

9. Key Takeaways

Looking back, these decisions share one thread: settle what things are before discussing what they do.

A permission is not an attribute hanging off a menu; it is a standalone entity that multiple consumers can reference. A stable identifier is not a display name; it is a contract referenced by code. An authorization write is not a single write; it needs a declared scope first. Identity and organization are not local data that can be copied; they are an external source of truth that must be asked each time. Auditing is not a feature; it is an integration point.

None of these problems is hard to bypass at the feature level — one more field, one more branch, one more table solves the immediate need. But after enough bypasses the model loses its explanatory power bit by bit, until one day nobody can explain why a given user has a given permission.

Recording the decisions and their reasons now means that later, looking back at which ones held up and which were wrong will be far clearer than staring at a finished design.

Further Reading