RBAC

Role-based access control. Who may approve pre-clearance, fire the kill switch, view PII, destroy audit records.

The SDK is a single-tenant library but an RIA is rarely a single person: a trader, a compliance officer, a principal / owner, sometimes an external auditor. Critical actions — approving a pre-clearance, firing the kill switch, reading a tax packet, destroying an archived audit batch — must be attributable to an authenticated Principal and permitted by policy.

horizon.auth is deliberately small. It does not manage users (plug in your OAuth / SSO at the edge); it enforces permissions.

Import

python
from horizon.auth import (
    Principal,
    Role,
    Permission,
    Policy,
    PermissionDenied,
    current_principal,
    with_principal,
)

Principal

python
alice = Principal.create(
    "alice",
    Role.ComplianceOfficer,
    display_name="Alice Chen",
    attributes={"mfa": "totp"},
)

Frozen dataclass. roles is a frozenset; a principal can hold multiple. attributes is free-form and unused by the SDK — carry tenant id, MFA level, issuer, session id, whatever the policy extension needs.

Roles

RoleIntent
AdminFull access, including audit destruction and venue credential writes.
PrincipalOwner / PM day-to-day. Everything except audit destruction and credential writes.
TraderSubmit, cancel, amend orders. Read own reports.
ComplianceOfficerApprove / reject pre-clearance. Edit restrictions. Sign off suitability. Fire the kill switch. View PII. Read audit log.
AuditorRead-only access to everything.
ViewerRead client statements and best-ex reports. No PII, no audit log.

The default role → permission matrix lives in horizon.auth.roles.DEFAULT_ROLE_PERMISSIONS and can be overridden per-deployment.

Permissions

Atomic capabilities, namespaced <subject>.<verb>:

order.submit             preclearance.approve      audit.view
order.cancel             preclearance.reject       audit.destroy
order.amend              restrictions.write        audit.anchor.write

risk.kill_switch.fire    suitability.sign_off      system.config.write
risk.kill_switch.reset   account.create            system.venue_credentials.write
risk.limits.edit         account.update
                         account.update
                         account.close
client.pii.view
report.statement.generate
report.tax_packet.generate
report.adv.view
report.best_ex.view

Full enum: horizon.auth.Permission.

Policy

python
policy = Policy(audit_log=audit_log)

# Deny-by-default.
policy.allows(alice, Permission.PreclearanceApprove)   # True
policy.allows(alice, Permission.AuditLogDestroy)       # False

# Raise on deny; logs an AccessDenied audit event.
policy.check(alice, Permission.OrderSubmit)            # raises PermissionDenied

policy.check() raises PermissionDenied and writes an AuditCategory.AccessDenied event to the audit log. Successful checks are not logged by default (would balloon the log for little forensic value); subclass Policy and override _on_allow if you want positive affirmation.

Customising the matrix

python
policy = Policy()
# Give traders read access to best-ex in this deployment.
policy.grant(Role.Trader, Permission.AuditLogView)
# Revoke a default.
policy.revoke(Role.Viewer, Permission.StatementGenerate)

Or construct with an entirely custom matrix:

python
policy = Policy(role_permissions={
    Role.Trader: frozenset({Permission.OrderSubmit, Permission.OrderCancel}),
    Role.ComplianceOfficer: frozenset({
        Permission.PreclearanceApprove, Permission.PreclearanceReject,
    }),
})

Wiring into pre-clearance

Pass principal= + policy= to the approve / reject helpers; see Pre-clearance workflow.

python
from horizon.compliance import approve_preclearance

approve_preclearance(
    queue, request_id=req_id,
    decided_by="alice",      # ignored when principal is set
    principal=alice,
    policy=policy,
    audit_log=audit_log,
)

When a principal is provided, PendingRequest.decided_by is attributed to principal.user_id rather than the free-form decided_by kwarg, so the audit trail stays honest.

Context propagation

Prefer passing principal= explicitly. When that is awkward (CLI handlers, request-scoped code) the ContextVar helper works:

python
from horizon.auth import with_principal, current_principal

with with_principal(alice):
    current_principal()            # -> alice
    # downstream code reads current_principal() if it wants to opt in

Nothing in the core pipeline reads current_principal() automatically. This is deliberate — explicit is safer for security-sensitive calls.

Audit categories

CategoryFires when
AccessDeniedpolicy.check() raises PermissionDenied.
AccessGrantedReserved. Default Policy does not emit; custom subclasses can.
RoleChangedReserved for external role-provisioning code to emit when roles are granted or revoked.

Out of scope

  • User / credential storage. Plug in an identity provider at the edge. Its output is a Principal.
  • Session management. authenticated_at is a best-effort stamp; session lifetimes belong to the IdP.
  • ABAC beyond role membership. Example: “trader may cancel only their own orders.” Subclass Policy and implement allows() against principal.attributes.
  • Runtime role mutation persistence. policy.grant / revoke mutate an in-memory view; persist externally if needed.

Related