Skip to content

Obligations & Challenges

This page documents the built-in obligation types enforced by BasicObligationChecker and shows how to extend the checker with custom policies (e.g., geo-fencing). It also clarifies how to target obligations to a specific decision effect (permit vs deny) and how challenge hints are surfaced to the PEP layer.

Compatibility

The checker accepts both legacy and modern raw decision shapes:

  • Legacy: {"decision": "permit" | "deny", "obligations": [...]}
  • Modern: {"effect": "permit" | "deny", "allowed": bool, "obligations": [...]}

Rules of thumb:

  • Any non-permit decision fails closed: (ok=False, challenge=None).
  • When effect is permit, obligations targeted at permit are evaluated; if any fails, (ok=False, challenge=...).
  • Obligations can explicitly target an effect via on: "permit" | "deny"; those not matching the current effect are ignored.

Obligations targeting deny

Obligations may target the deny branch via on: "deny". This is useful to surface a machine-readable challenge (e.g., http_basic) even when the PDP already decided to deny. The PEP can then translate it into WWW-Authenticate headers or other UX.

Built-in obligation types

Unless noted, these apply when on: "permit".

  • require_mfachallenge="mfa" when context.attrs["mfa"] is falsy.
  • require_level (attrs.min) → challenge="step_up" when context.attrs["auth_level"] < min.
  • http_challenge (on: permit|deny, attrs.scheme = Basic|Bearer|Digest) → challenge="http_basic" | "http_bearer" | "http_digest"; unknown/omitted scheme → http_auth.
  • require_consent (optional attrs.key) → challenge="consent" when consent is missing.

  • With a key: expect context.attrs["consent"][key] is True.

  • Without a key: expect any truthy context.attrs["consent"].
  • require_terms_acceptchallenge="tos" when context.attrs["tos_accepted"] is falsy.
  • require_captchachallenge="captcha" when context.attrs["captcha_passed"] is falsy.
  • require_reauth (attrs.max_age) → challenge="reauth" when context.attrs["reauth_age_seconds"] > max_age.
  • require_age_verifiedchallenge="age_verification" when context.attrs["age_verified"] is falsy.

Policy examples

YAML — MFA on permit

# A permit rule that requires MFA before access is actually granted.
obligations:
  - on: permit
    type: require_mfa

JSON — HTTP challenge on deny

{
  "obligations": [
    {
      "on": "deny",
      "type": "http_challenge",
      "attrs": { "scheme": "Basic" }
    }
  ]
}

YAML — Geo-fencing (custom extension example)

# Example obligation we will implement via a custom checker:
# Allow only if user's geo is in the allowed set.
obligations:
  - on: permit
    type: require_geo
    attrs:
      allow: ["EU", "US"]

Expected context for geo: context.attrs["geo"] should contain a short region code (e.g., "EU", "US", "APAC").

Extending the checker (custom obligations)

To add your own obligation types (e.g., require_geo), subclass BasicObligationChecker and handle your type. Always call super().check(...) first to preserve built-ins and fail-closed semantics.

# src/myapp/obligations.py
from rbacx.core.obligations import BasicObligationChecker

class CustomObligationChecker(BasicObligationChecker):
    def check(self, decision, context):
        # Let the base checker evaluate built-ins first.
        ok, ch = super().check(decision, context)
        if not ok:
            return ok, ch

        # Determine current effect in the same manner as the base checker does:
        effect = decision.get("effect")
        if effect is None:
            effect = "permit" if decision.get("decision") == "permit" else "deny"
        if effect not in ("permit", "deny"):
            effect = "deny"  # fail-closed

        ctx = getattr(context, "attrs", context) or {}
        obligations = decision.get("obligations") or []

        for ob in obligations:
            if (ob or {}).get("on", "permit") != effect:
                continue
            if (ob or {}).get("type") == "require_geo":
                allow_list = set(((ob.get("attrs") or {}).get("allow") or []))
                if not allow_list:
                    # No allow-list means fail-closed
                    return False, "geo"
                if ctx.get("geo") not in allow_list:
                    return False, "geo"

        return True, None

Then wire your checker into the Guard (where you construct your PDP/PEP integration). The Guard should consume (ok, challenge) and, on failure, flip permit → deny, adding the challenge to the final Decision.

Context contract (quick reference)

The checker reads the following context.attrs[...] keys when relevant:

  • mfa: bool
  • auth_level: int
  • consent: bool | {str: bool}
  • tos_accepted: bool
  • captcha_passed: bool
  • reauth_age_seconds: int
  • age_verified: bool
  • geo: str (custom example)

Notes & best practices

  • Keep obligation handlers pure (no I/O) and quick; they run in the request path.
  • Use on: "deny" to add diagnostics/UX to denials (e.g., prompt client re-auth).
  • When parsing numeric attrs (e.g., min, max_age), default invalid values to 0 and fail closed.
  • Unknown type values should be treated as advice and ignored by the checker unless you explicitly implement them.