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 concept | Identity HBAC concept | OAuth2 meaning |
|---|---|---|
| Who (user / user group) | Identity Subject | Authenticated end-user principal |
| Host / host group | Identity Handler | OAuth2 client / application |
| Service / service group | Scoped Access | Requested OAuth2 scopes |
| Access granted? | Policy Enforcement | Token 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_credentialsgrant is excluded. Machine-to-machine flows carry no user principal, so the user axis cannot be evaluated. HBAC enforcement is skipped entirely forclient_credentials.
Policy axes
Each HBAC rule carries independent axes that are all evaluated conjunctively:
| Axis | CRDT field | Category flag | Notes |
|---|---|---|---|
| Users | users (RW-Set) | user_category=all | UID, group name, or LDAP DN |
| User groups | user_groups (RW-Set) | — | Group membership resolved at eval time |
| Clients | clients (RW-Set of client_id strings) | client_category=all | OAuth2 client_id |
| Allowed scopes | allowed_scopes (RW-Set of scope strings) | scope_category=all | Union of scopes across all matching rules |
| Source networks | source_networks (RW-Set of CIDR strings) | network_category=all | If no CIDRs configured, axis is unconstrained |
| Device groups | device_groups (RW-Set) | device_category=all | If no groups configured, axis is unconstrained |
| MFA bypass | mfa_bypass (DW-Register) | — | false = MFA step-up required |
| Required ACR | required_acr (LWW-Register) | — | SAML 2.0 ACR string; None = no constraint |
| Delegation targets | delegation_targets (RW-Set of SPN strings) | delegation_target_category | Kerberos SPNs permitted as target_service in OBO token exchange; unconstrained if category flag is true |
| Hosts (PAM/SSH) | hosts, host_groups | host_category=all | Not evaluated for OAuth2 |
| Services (PAM/SSH) | services, service_groups | service_category=all | Not 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):
- The standard axes (user, client, scope, network, device, ACR) are evaluated first; rules that do not match those axes are excluded entirely.
- Among the rules that matched all standard axes, at least one must also
satisfy the delegation axis: either
delegation_target_category = true(wildcard) ordelegation_targetscontains the SPN string. - 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:
-
CC base rule —
client_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_onlysentinel SPN prevents this rule from satisfying the delegation axis for any real service principal: when a token exchange request specifies a realtarget_service, this rule is excluded in the delegation phase (phase 2), so it does not grant OBO access. -
OBO rule —
clients=[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.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /api/admin/hbac | hbac:read | List all live rules (summary) |
POST | /api/admin/hbac | hbac:write | Create a new rule |
GET | /api/admin/hbac/:id | hbac:read | Get rule detail |
PUT | /api/admin/hbac/:id | hbac:write | Update (patch) a rule |
DELETE | /api/admin/hbac/:id | hbac:write | Delete a rule |
GET | /api/admin/hbac/lookup/users | hbac:read | Typeahead: search users |
GET | /api/admin/hbac/lookup/groups | hbac:read | Typeahead: search user groups |
GET | /api/admin/hbac/lookup/hosts | hbac:read | Typeahead: search IPA hosts |
GET | /api/admin/hbac/lookup/services | hbac:read | Typeahead: search IPA services |
GET | /api/admin/hbac/lookup/clients | hbac:read | Typeahead: search OAuth2 clients |
GET | /api/admin/clients/:id/hbac | — | HBAC 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:
memberUser→usersmemberGroup→user_groupsmemberHost→hosts/host_groups(PAM/SSH axis; preserved but not evaluated for OAuth2)memberService→services/service_groups(PAM/SSH axis)ipaEnabledFlag→enabled
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.