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
from horizon.auth import (
Principal,
Role,
Permission,
Policy,
PermissionDenied,
current_principal,
with_principal,
)
Principal
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
| Role | Intent |
|---|---|
Admin | Full access, including audit destruction and venue credential writes. |
Principal | Owner / PM day-to-day. Everything except audit destruction and credential writes. |
Trader | Submit, cancel, amend orders. Read own reports. |
ComplianceOfficer | Approve / reject pre-clearance. Edit restrictions. Sign off suitability. Fire the kill switch. View PII. Read audit log. |
Auditor | Read-only access to everything. |
Viewer | Read 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
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
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:
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.
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:
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
| Category | Fires when |
|---|---|
AccessDenied | policy.check() raises PermissionDenied. |
AccessGranted | Reserved. Default Policy does not emit; custom subclasses can. |
RoleChanged | Reserved 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_atis a best-effort stamp; session lifetimes belong to the IdP. - ABAC beyond role membership. Example: “trader may cancel only their own orders.” Subclass
Policyand implementallows()againstprincipal.attributes. - Runtime role mutation persistence.
policy.grant/revokemutate an in-memory view; persist externally if needed.
Related
- Pre-clearance workflow — first gated caller.
- Audit trail — where denials land.
- Retention policy —
AuditLogDestroyis Admin-only by default.