Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Identity HBAC Policy

Identity Handler-Based Access Control (Identity HBAC) is a policy engine that controls which users may obtain tokens from which OAuth2 clients. It is modelled directly on FreeIPA’s HBAC rules — the same structure that governs SSH and PAM access — extended with OAuth2-specific axes.

Conceptual mapping

FreeIPA HBAC conceptIdentity HBAC conceptOAuth2 meaning
Who (user / user group)Identity SubjectAuthenticated end-user principal
Host / host groupIdentity HandlerOAuth2 client / application
Service / service groupScoped AccessRequested OAuth2 scopes
Access granted?Policy EnforcementToken issued?

Just as FreeIPA prevents a user from opening an SSH session on a host, the Identity HBAC policy prevents an OAuth2 client from receiving the user’s identity information. The OAuth2 client takes the place of the “host”.

Enforcement semantics

  • Empty rule set → allow-all. When no rules exist, every token request is permitted. This is the backward-compatible default: existing deployments work without any migration.
  • First rule creation starts enforcement. As soon as at least one live rule exists in the CRDT, every incoming authorization request is evaluated against the rule set and denied unless at least one rule explicitly allows it.
  • Implicit deny. A request that matches no rule is denied. There is no default-permit rule.
  • client_credentials grant is excluded. Machine-to-machine flows carry no user principal, so the user axis cannot be evaluated. HBAC enforcement is skipped entirely for client_credentials.

Policy axes

Each HBAC rule carries independent axes that are all evaluated conjunctively:

AxisCRDT fieldCategory flagNotes
Usersusers (RW-Set)user_category=allUID, group name, or LDAP DN
User groupsuser_groups (RW-Set)Group membership resolved at eval time
Clientsclients (RW-Set of client_id strings)client_category=allOAuth2 client_id
Allowed scopesallowed_scopes (RW-Set of scope strings)scope_category=allUnion of scopes across all matching rules
Source networkssource_networks (RW-Set of CIDR strings)network_category=allIf no CIDRs configured, axis is unconstrained
Device groupsdevice_groups (RW-Set)device_category=allIf no groups configured, axis is unconstrained
MFA bypassmfa_bypass (DW-Register)false = MFA step-up required
Required ACRrequired_acr (LWW-Register)SAML 2.0 ACR string; None = no constraint
Delegation targetsdelegation_targets (RW-Set of SPN strings)delegation_target_categoryKerberos SPNs permitted as target_service in OBO token exchange; unconstrained if category flag is true
Hosts (PAM/SSH)hosts, host_groupshost_category=allNot evaluated for OAuth2
Services (PAM/SSH)services, service_groupsservice_category=allNot evaluated for OAuth2

Client groups (client_groups) exist in the CRDT schema for FreeIPA compatibility but are never evaluated for OAuth2: ahdapa OAuth2 clients are not IPA LDAP objects and have no LDAP client_principal; the axis is always skipped. The field is hidden from the admin UI.

Scope evaluation

The granted_scopes returned by a successful evaluation is the intersection of the scopes requested by the client and the union of allowed_scopes across all matching rules. If any requested scope is not covered by any matching rule, the evaluation returns Deny.

Delegation target evaluation

OAuth2Context.target_service carries the Kerberos SPN supplied as target_service in an RFC 8693 token exchange request. When this field is Some(spn):

  1. The standard axes (user, client, scope, network, device, ACR) are evaluated first; rules that do not match those axes are excluded entirely.
  2. Among the rules that matched all standard axes, at least one must also satisfy the delegation axis: either delegation_target_category = true (wildcard) or delegation_targets contains the SPN string.
  3. If no rule satisfies the delegation axis → Deny.

This two-phase check ensures that network, device, and ACR constraints from the first pass also gate the delegation axis. A rule cannot permit a delegation target without also permitting the user/client/scope/network combination.

When target_service is None, the delegation axis is not evaluated and delegation_targets / delegation_target_category have no effect on the outcome.

The token exchange handler (src/routes/oauth2/advanced_grants.rs) also enforces a fail-closed rule: if target_service is present but no live HBAC rules exist at all, the request is denied before evaluate_oauth2 is called.

Two-rule pattern for OBO deployments

A common deployment pattern uses two rules:

  1. CC base ruleclient_category=all, user_category=all, scope_category=all, mfa_bypass=true, delegation_targets=["_cc_only"]. Grants client credentials tokens to all registered clients. The _cc_only sentinel SPN prevents this rule from satisfying the delegation axis for any real service principal: when a token exchange request specifies a real target_service, this rule is excluded in the delegation phase (phase 2), so it does not grant OBO access.

  2. OBO ruleclients=[agent_id], user_category=all, scope_category=all, mfa_bypass=true, delegation_targets=["host/correct-server.example.com"]. Grants OBO delegation for a specific agent to a specific backend only.

Both rules must set mfa_bypass=true — without it the evaluation returns mfa_required=true and the token exchange handler rejects the request because OBO and CC flows carry no AMR.

When a token exchange request carries target_service, Scenario 5 of the OBO demo explicitly supplies target_service="host/correct-server.example.com" so that the delegation axis is evaluated, the _cc_only sentinel rule is excluded, and only the OBO rule is eligible.

MFA and ACR

mfa_bypass is implemented as a DWRegister (disable-wins boolean register). A freshly created rule has an empty DWRegister — no enable-tags, no disable-tags — which evaluates to false. A false mfa_bypass means MFA is required when this rule matches.

When a matching rule has mfa_bypass = false (the default), the evaluation returns Allow { mfa_required: true, … }. The token endpoint must verify that the session’s ACR satisfies the required authentication strength before issuing the token. Machine-to-machine flows (client credentials, OBO) carry no AMR, so the token exchange handler denies any exchange where the best matching HBAC rule returns mfa_required: true. Any HBAC rule intended for M2M flows must therefore set mfa_bypass = true explicitly.

When a rule sets required_acr, the session must present exactly that ACR value, or the rule does not match at all.

CRDT merge semantics

Every field in an HBACRule uses a security-conservative CRDT primitive:

  • RW-Set (users, clients, scopes, networks, device groups): remove-wins on concurrent add and remove of the same member. A concurrent stale re-add cannot widen access.
  • DW-Register (enabled, category flags, mfa_bypass): disable-wins on concurrent enable and disable. A concurrent stale re-enable cannot permit access that a concurrent disable denied.
  • LWW-Register (name, description, required_acr): last write wins. These fields are administrative metadata, not security decisions.

Rule existence is an RW-Set of RuleIds. Deleting a rule removes its ID from the existence set; its content is retained in the CRDT map for merge correctness but is invisible to live_rules() and to evaluation.

Storage and sync

HBAC state is stored as an op-log in AppState.hbac_log (Arc<RwLock<OpLog>>). The op-log materialises into a RuleSet (hbac_crdt::RuleSet) which is mirrored into IdpCrdt.hbac_rules for gossip. On every mutation, the full RuleSet is persisted to the crdt_hbac_rules table as a single JSON blob row.

On startup, the server loads the persisted RuleSet from crdt_hbac_rules and wraps it in a new OpLog. After merging the persisted state into hbac_log.state, OpLog::restore_clock_from_state must be called immediately. Without this call, the local Lamport clock remains at 0; the next operation receives logical_ts = 1, which collides with tags already present in the persisted state. dedup_push silently drops the duplicate add-tags, leaving prior remove-tags in effect and making newly created rules permanently invisible. In AppState::new (src/routes/mod.rs) the call sequence is:

#![allow(unused)]
fn main() {
hbac_log.state.merge(&stored_hbac);
hbac_log.restore_clock_from_state();  // must follow merge
}

When a gossip push arrives, the received hbac_rules are merged into IdpCrdt.hbac_rules first, then mirrored back into hbac_log.state, and the merged state is persisted.

Admin API

All endpoints require a session cookie from an account with the hbac:read or hbac:write permission. Grant these via [rbac] configuration.

MethodPathPermissionDescription
GET/api/admin/hbachbac:readList all live rules (summary)
POST/api/admin/hbachbac:writeCreate a new rule
GET/api/admin/hbac/:idhbac:readGet rule detail
PUT/api/admin/hbac/:idhbac:writeUpdate (patch) a rule
DELETE/api/admin/hbac/:idhbac:writeDelete a rule
GET/api/admin/hbac/lookup/usershbac:readTypeahead: search users
GET/api/admin/hbac/lookup/groupshbac:readTypeahead: search user groups
GET/api/admin/hbac/lookup/hostshbac:readTypeahead: search IPA hosts
GET/api/admin/hbac/lookup/serviceshbac:readTypeahead: search IPA services
GET/api/admin/hbac/lookup/clientshbac:readTypeahead: search OAuth2 clients
GET/api/admin/clients/:id/hbacHBAC rules associated with a client

Create a rule

curl -s -b session.jar \
  -X POST -H 'Content-Type: application/json' \
  -d '{
    "name": "Alice can use MyApp for openid and profile",
    "description": "Restricts MyApp access to alice only",
    "enabled": true,
    "users": ["alice"],
    "clients": ["myapp-client-id"],
    "allowed_scopes": ["openid", "profile"]
  }' \
  https://idp.example.com/api/admin/hbac | python3 -m json.tool

Update a rule

# Add bob to an existing rule.
curl -s -b session.jar \
  -X PUT -H 'Content-Type: application/json' \
  -d '{"add_users": ["bob"]}' \
  https://idp.example.com/api/admin/hbac/<rule-id>

Enable / disable a rule

# Disable (no access granted by this rule while disabled).
curl -s -b session.jar \
  -X PUT -H 'Content-Type: application/json' \
  -d '{"enabled": false}' \
  https://idp.example.com/api/admin/hbac/<rule-id>

Admin WebUI

The admin WebUI exposes Identity HBAC policies under the “Identity HBAC policy” navigation item.

  • List page — shows all live rules with name, enabled status, and a summary of members.
  • Detail / editor page — two collapsible sections:
    • PAM / SSH — users, user groups, hosts, host groups, services, service groups; mirroring the classic FreeIPA HBAC editor.
    • OAuth2 — clients, allowed scopes, source networks, device groups, MFA bypass, required ACR.
  • Cross-links — users, groups, clients, and scopes shown in a rule detail page are hyperlinked to their respective admin pages.
  • Client detail page — shows which Identity HBAC policies reference a given client.

RBAC configuration

[[rbac.role]]
name        = "hbac-admin"
permissions = ["hbac:read", "hbac:write"]

[[rbac.group_role]]
group = "admins"
role  = "hbac-admin"

Accounts in the admins group (from the static users file or FreeIPA LDAP memberOf) receive hbac:read and hbac:write permissions. Read-only access can be granted by assigning only hbac:read.

FreeIPA HBAC migration

FreeIPA HBAC rules are stored in LDAP under cn=hbac,<suffix>. A migration script can export them via ipa hbacrule-find --all --raw and import them through the ahdapa admin API, mapping:

  • memberUserusers
  • memberGroupuser_groups
  • memberHosthosts / host_groups (PAM/SSH axis; preserved but not evaluated for OAuth2)
  • memberServiceservices / service_groups (PAM/SSH axis)
  • ipaEnabledFlagenabled

The hosts_base and services_base LDAP search paths in [ipa] (configuration keys ipa.hosts_base and ipa.services_base) are used by the typeahead lookup endpoints to resolve IPA host and service objects when building rules that cover the PAM/SSH axes.

Example: restrict a confidential client

Goal: only members of the finance-team group may obtain tokens for payroll-app, and only the openid, profile, and email scopes.

# Create the rule.
curl -s -b session.jar \
  -X POST -H 'Content-Type: application/json' \
  -d '{
    "name": "finance-team access to payroll-app",
    "enabled": true,
    "user_groups": ["finance-team"],
    "clients": ["payroll-app"],
    "allowed_scopes": ["openid", "profile", "email"]
  }' \
  https://idp.example.com/api/admin/hbac

# Verify: a user outside finance-team will now receive 403 at token issuance.

After this rule is created, any user not in finance-team who attempts to obtain a token for payroll-app will be denied, regardless of whether they have a valid session.