Skip to content

ADR-005: Use In-Code RBAC, Reject OPA

Status: Accepted
Date: 2024-12-01
Deciders: Development Team, Security Lead

TL;DR

Implement role-based access control directly in Python code. Reject Open Policy Agent (OPA) as too complex for current needs.

Context

Need authorization system for 6 user roles (co2.user.std, co2.user.principal, co2.backoffice.admin, etc.) controlling who can view/create/edit emission records and manage units.

Options: In-code RBAC vs external policy engine (OPA).

Decision

Use in-code RBAC with User.roles JSON field and helper methods.

Reject OPA - operational complexity outweighs benefits.

Why in-code RBAC wins:

  • Simple to implement and debug
  • No external service dependency
  • Zero network latency (<1ms vs 5-20ms)
  • Role hierarchy sufficient for requirements
  • Python-native testing (pytest)
  • Fewer operational failure points

What tipped the balance: @domq: "We are adopting a full Python code approach for now, even if it means revisiting this decision later if there is demand for it. Ideally, we will only rewrite the code that controls access." cf issue #16

Alternatives Considered

Open Policy Agent (OPA)
✗ Operational complexity, network calls, learn Rego, monitoring overhead
✓ Centralized policies, runtime updates, microservices-ready

Comparison:

Criterion In-Code RBAC OPA
Latency <1ms 5-20ms
Debugging Easy (Python) Hard (Rego)
Dependencies None External service
Complexity Low High
Team Skills Python Learn Rego

Consequences

Positive:

  • Simple implementation: user.has_role("co2.service.mgr")
  • Zero infrastructure overhead
  • Fast development (no Rego learning)
  • In-memory permission checks
  • Easy onboarding

Negative:

  • Permission logic coupled to code
  • Role changes require deployment
  • No policy reuse across services (but no other services)
  • Less centralized auditability

Mitigation:

  • Role assignments in database (no redeploy to grant roles)
  • Document role definitions clearly
  • Log all permission denials

When to reconsider OPA:

  • Multiple microservices need shared policies
  • Complex attribute-based access control (ABAC)
  • Compliance requires centralized policy audit trail

Implementation

class User(Base):
    roles: Mapped[List[str]] = mapped_column(JSON, default=list)

    def has_role(self, role: str) -> bool:
        return role in (self.roles or [])

    def has_any_role(self, roles: List[str]) -> bool:
        return bool(set(self.roles or []).intersection(roles))

@router.post("/resources")
async def create_resource(data: ResourceCreate, user: User = Depends(get_current_user)):
    if not user.has_any_role(["co2.user.principal", "co2.service.mgr"]):
        raise HTTPException(403, "Insufficient permissions")
    # ... create resource

References