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

HBAC Integration

On a Linux estate managed by FreeIPA, who may log in where is already decided by HBAC rules: the coding-agents group may reach sshd on development hosts, never on production. When an AI agent then asks, over RFC 9396, to run cargo on that host, two questions must both be answered:

  1. May this user reach this service on this host? (HBAC)
  2. Are the requested authorization_details acceptable? (rules)

With the hbac feature, authz_details_rs::hbac::HostScopedPolicy answers both at once, reusing your existing hbac-rs policy instead of re-encoding host checks as $.host field conditions.

[dependencies]
authz-details-rs = { version = "0.2", features = ["hbac"] }
hbac-rs = "0.2"

Composing HBAC with rules

A HostScopedRequest pairs an HbacRequest (user, target host, service) with the requested details. The two dimensions are combined with the workspace-wide CombineMode, HBAC first:

#![allow(unused)]
fn main() {
use authz_details_rs::hbac::{HostScopedPolicy, HostScopedRequest};
use authz_details_rs::prelude::*;
use hbac_rs::{HbacPolicy, HbacRequest, HbacRule};
use serde_json::json;

let mut hbac = HbacPolicy::new();
hbac.add_rule(
    HbacRule::builder("coding-agents-on-dev")
        .user_group("coding-agents")
        .host("dev01.example.com")
        .service("sshd")
        .enabled(true)
        .build()
        .unwrap(),
)
.unwrap();

let mut rules = AuthzDetailsPolicy::new();
rules
    .add_rule(
        AuthzDetailsRule::builder("unprivileged-build-tools")
            .type_id("linux_exec")
            .field("$.privileged").eq(false)
            .field("$.commands[*]").is_in(["cargo", "git", "make"])
            .build()
            .unwrap(),
    )
    .unwrap();

let policy = HostScopedPolicy::new(hbac, rules, CombineMode::And);
let agent = Subject::builder().id("agent").role("coding-agents").build().unwrap();
let cargo = || {
    vec![AuthorizationDetail::new(
        "linux_exec",
        json!({"actions": ["run"], "commands": ["cargo"], "privileged": false}),
    )]
};

let on_dev = HostScopedRequest::new(HbacRequest::new(agent.clone(), "dev01.example.com", "sshd"), cargo());
let result = policy.evaluate(&on_dev);
assert!(result.is_allowed());
assert_eq!(result.hbac.matched_rules, ["coding-agents-on-dev"]);
assert_eq!(result.details.matched_rules, ["unprivileged-build-tools"]);

// Same command on a production host: HBAC has no rule for it
let on_prod = HostScopedRequest::new(HbacRequest::new(agent, "prod01.example.com", "sshd"), cargo());
assert!(!policy.check_access(&on_prod));
}

The result carries both dimensions’ results (hbac with its matched HBAC rules, details with the rules’ verdict), so a denial can be attributed to the right policy. evaluate_at / check_access_at evaluate at a fixed Timestamp; HBAC temporal rules and authz-details temporal rules then use the same instant.

CombineModeOutcome
AndHBAC and the rules must both allow (the usual choice)
OrEither may allow
PreferFirstHBAC decides when an HBAC rule matched; otherwise the rules decide
PreferSecondThe rules decide when a rule applies; otherwise HBAC decides

Python

authz_details.HostScopedPolicy takes an hbac_rs.HbacPolicy and a rule policy. The HBAC rules (including temporal and disabled rules) are copied when the policy is created; subjects may be acls_rs.Subject or hbac_rs.Subject objects, and host/service groups are keyword arguments:

import hbac_rs
from acls_rs import Subject
from authz_details import AuthzDetailsPolicy, AuthzDetailsRule, CombineMode, HostScopedPolicy

hbac = hbac_rs.HbacPolicy()
hbac.add_rule(
    hbac_rs.HbacRuleBuilder("coding-agents-on-dev")
    .user_group("coding-agents")
    .host_group("dev-hosts")
    .service("sshd")
    .enabled(True)
    .build()
)

rules = AuthzDetailsPolicy([
    AuthzDetailsRule.builder("unprivileged-build-tools")
    .type_id("linux_exec")
    .field("$.privileged").eq(False)
    .field("$.commands[*]").is_in(["cargo", "git", "make"])
    .build()
])

policy = HostScopedPolicy(hbac, rules, CombineMode.AND)

agent = Subject("agent")
agent.add_role("coding-agents")
cargo = [{"type": "linux_exec", "actions": ["run"], "commands": ["cargo"], "privileged": False}]

result = policy.evaluate(agent, "dev01.example.com", "sshd", cargo, host_groups=["dev-hosts"])
assert result and result.hbac_matched_rules == ["coding-agents-on-dev"]
assert not policy.check_access(agent, "prod01.example.com", "sshd", cargo)

The result exposes allowed, hbac_allowed, hbac_matched_rules and the rules’ details result; evaluate_at / check_access_at take a timestamp. Like hbac_rs.HbacPolicy, a HostScopedPolicy is bound to the thread that created it.

Composing with other policies

HostScopedPolicy is built from the shared composition primitives in acls_rs::policy::composition (see the acls-rs Bridge). Both HbacPolicy and AuthzDetailsPolicy implement PolicyDimension, so you can combine them – or any other dimension – with combine directly when you need a different shape of request.

Next steps

The runnable agent_linux_workstation example (cargo run -p authz-details-rs --features hbac --example agent_linux_workstation) walks through a coding agent’s consent, delegation, task-scoped tokens and host enforcement end to end.