Setting Up Role-Based Access for CAM Data

The moment a CAM reconciliation grows past a single accountant working in one spreadsheet, you have an access-control problem: a property manager needs to upload vendor invoices but must never rewrite an allocation percentage, an automation service account needs to run the pipeline but must never touch production books, and a reviewing accountant needs to approve a five-figure override without also being able to request it. This page is the hands-on build guide for the model specified in CAM Reconciliation Security & Access Controls — it turns that decision core into a concrete role-to-permission matrix, a request-time enforcement layer you can drop into a FastAPI service, and the lease-scoped checks that keep an authorized actor inside the properties they actually manage. The goal is a default-deny system where every write to a recoverable pool is traceable to a role that was allowed to make it.

The layered role, lease-scope and value gates a request passes before access is granted An access request descends through three ordered gates. The role gate (RBAC) checks the verb against the principal's union of grants; the lease-scope gate (ABAC) checks the property against the actor's assignment; the value gate checks that a material override carries a distinct second approver. Clearing all three grants scoped access. A failed role or scope gate is denied and logged to the audit chain, and a failed value gate is routed to the review queue. Access request 1 · Role gate — RBAC verb in union of grants? 2 · Lease scope — ABAC property in assignment? 3 · Value gate override ≥ ceiling → 2nd approver? Grant scoped access Deny & log → hash-chained audit Route to review queue yes yes pass no no fail
The cheap role gate runs first, then lease scope, then the value gate — a default-deny chain. Every denial is written to the hash-chained audit log, and a record missing its lease or property is held in PENDING_ABSTRACTION for a quarantine reviewer rather than granted.

Context & When to Use This Approach

A flat admin / user split survives exactly until your second property and your first service account. Role-based access control (RBAC) becomes the right tool the moment distinct job functions touch the same reconciliation records with different legitimate authority — a property manager in one region, an accountant closing pools in another, a Python job normalizing utilities overnight, and an auditor who may read everything but change nothing. Reach for this build when onboarding a second human role, when you expose an API that a batch pipeline calls, or the first time an auditor asks “who is allowed to move an expense between the recoverable and non-recoverable pool?” and you cannot answer with a table.

RBAC answers who may perform which verb. It is deliberately coarse, and it is not the whole story: CAM authority is also lease-specific, because the same accountant who may legitimately edit Property A has no business editing Property B, and an override that is fine at $500 requires a second signature at $50,000. Those conditions are attribute-based access control (ABAC), and the design here layers a thin ABAC check on top of the role matrix rather than trying to encode every lease clause as a role. The role grants come first as a fast, cacheable gate; the lease-scoped and value-gated checks run only after the verb is allowed. Both depend on structured lease facts already normalized in the lease abstraction database and on the recoverable-versus-non-recoverable split established when defining CAM expense categories in commercial leases — a permission is meaningless until it can name the category and lease it applies to.

Role-to-permission matrix with the separation-of-duties seam highlighted Five roles down the side, nine permission verbs across the top. A granted cell carries a check; a denied cell is empty. property_manager holds read_statement and upload_invoice; accountant holds read_statement, write_mapping, allocate and override_allocation; reviewing_accountant holds read_statement, approve_reconciliation and quarantine_review; statement_service holds read_statement and export_statement; automation_engineer holds only run_pipeline_staging. The override_allocation and approve_reconciliation columns are shaded amber to mark the separation of duties: the role that requests an override is never the role that approves it. read_statement upload_invoice write_mapping allocate override_allocation approve_reconciliation export_statement quarantine_review run_pipeline_staging property_manager accountant reviewing_accountant statement_service automation_engineer granted denied separation of duties (request ≠ approve)
Each role is the least-privilege bundle of verbs its job needs. The amber columns are the separation-of-duties seam: the accountant may request an override_allocation, but only the reviewing_accountant may approve_reconciliation — and that role is deliberately denied write_mapping, so no single principal can both make and bless a material change.

Step-by-Step Implementation

The build is a single small module: enumerate the verbs, declare the matrix, model the actor, then enforce at the request boundary. Every step is copy-paste runnable and typed, and all monetary comparisons use Decimal so an override threshold never drifts a fraction of a cent.

Step 1 — Enumerate permission verbs, not job titles. Permissions map to reconciliation functions, so the same verb means the same thing whether a human or a service account holds it. Keep the set small and specific; a verb like override_allocation is the one an auditor will scrutinize.

from __future__ import annotations

from enum import Enum


class Permission(str, Enum):
    READ_STATEMENT = "read_statement"
    UPLOAD_INVOICE = "upload_invoice"
    WRITE_MAPPING = "write_mapping"
    ALLOCATE = "allocate"
    OVERRIDE_ALLOCATION = "override_allocation"
    APPROVE_RECONCILIATION = "approve_reconciliation"
    EXPORT_STATEMENT = "export_statement"
    QUARANTINE_REVIEW = "quarantine_review"
    RUN_PIPELINE_STAGING = "run_pipeline_staging"

Step 2 — Declare the role-to-permission matrix. Each role is a named bundle of verbs. Note the separation of duties baked in: accountant can request an override but the reviewing_accountant role that can APPROVE_RECONCILIATION is deliberately denied WRITE_MAPPING, so no single principal can both make and bless a material change. The automation_engineer holds only a staging verb — production write authority never lives in an automation role.

ROLE_MATRIX: dict[str, frozenset[Permission]] = {
    "property_manager": frozenset({
        Permission.READ_STATEMENT,
        Permission.UPLOAD_INVOICE,          # feeds ingestion; cannot allocate
    }),
    "accountant": frozenset({
        Permission.READ_STATEMENT,
        Permission.WRITE_MAPPING,
        Permission.ALLOCATE,
        Permission.OVERRIDE_ALLOCATION,     # may *request* an override
    }),
    "reviewing_accountant": frozenset({
        Permission.READ_STATEMENT,
        Permission.APPROVE_RECONCILIATION,  # may *approve*, cannot write
        Permission.QUARANTINE_REVIEW,
    }),
    "statement_service": frozenset({
        Permission.READ_STATEMENT,
        Permission.EXPORT_STATEMENT,
    }),
    "automation_engineer": frozenset({
        Permission.RUN_PIPELINE_STAGING,    # synthetic data only
    }),
}

Step 3 — Model the actor with roles and lease scope. A Principal resolves to a stable id, one or more roles, and the set of properties it is assigned to. The union of role grants gives its effective permissions; the property set is the row-level scope the ABAC check will enforce.

from pydantic import BaseModel


class Principal(BaseModel):
    principal_id: str
    roles: frozenset[str]
    property_ids: frozenset[str]            # lease scope this actor may touch

    def granted_permissions(self) -> frozenset[Permission]:
        """Union of every role's grants — audit the union, not each role."""
        allowed: set[Permission] = set()
        for role in self.roles:
            allowed |= ROLE_MATRIX.get(role, frozenset())
        return frozenset(allowed)

Step 4 — Write the layered decision function. Run the cheap role gate first, then lease scope, then the value gate for material overrides. Returning a structured decision (rather than raising) means a denied attempt is still a value the caller can log, which is exactly what an auditor wants to see.

from decimal import Decimal
from typing import Optional


class AccessRequest(BaseModel):
    permission: Permission
    property_id: str
    lease_id: str
    amount: Decimal = Decimal("0.00")          # dollar impact of the action
    override_ceiling: Decimal = Decimal("0.00")  # from the lease abstraction
    second_approver_id: Optional[str] = None


class Decision(BaseModel):
    allowed: bool
    reason: str


def evaluate(principal: Principal, req: AccessRequest) -> Decision:
    # 1. RBAC: is the verb in this principal's union of grants?
    if req.permission not in principal.granted_permissions():
        return Decision(allowed=False, reason=f"role lacks {req.permission.value}")

    # 2. ABAC lease scope: assigned to the property that owns this lease?
    if req.property_id not in principal.property_ids:
        return Decision(allowed=False, reason=f"out of scope: {req.property_id}")

    # 3. Value gate: a material override needs a distinct second approver.
    if req.permission is Permission.OVERRIDE_ALLOCATION and req.amount >= req.override_ceiling:
        approver = req.second_approver_id
        if approver is None or approver == principal.principal_id:
            return Decision(allowed=False, reason="override at/above ceiling needs a distinct approver")

    return Decision(allowed=True, reason="ok")

Step 5 — Enforce at the request boundary with a FastAPI dependency. Wire the check as an interceptor so no route handler can forget it. The dependency decodes the principal from the request’s verified token claims, runs evaluate, and short-circuits with a 403 before any mutation is attempted. The resource context — property, lease, amount — comes from the parsed request body.

from fastapi import Depends, HTTPException, Request


def principal_from_claims(request: Request) -> Principal:
    """Build the Principal from already-verified JWT claims on the request.

    Token signature/expiry are validated by upstream auth middleware; here we
    only trust the claims it attached to request.state.
    """
    claims = request.state.claims
    return Principal(
        principal_id=claims["sub"],
        roles=frozenset(claims.get("roles", [])),
        property_ids=frozenset(claims.get("property_ids", [])),
    )


def require(permission: Permission):
    """Return a dependency that admits the request only if `evaluate` allows it."""
    async def _guard(request: Request) -> Principal:
        principal = principal_from_claims(request)
        body = await request.json()
        req = AccessRequest(
            permission=permission,
            property_id=body["property_id"],
            lease_id=body["lease_id"],
            amount=Decimal(str(body.get("amount", "0.00"))),
            override_ceiling=Decimal(str(body.get("override_ceiling", "0.00"))),
            second_approver_id=body.get("second_approver_id"),
        )
        decision = evaluate(principal, req)
        # Every verdict — allow or deny — is appended to the audit chain here.
        if not decision.allowed:
            raise HTTPException(status_code=403, detail=decision.reason)
        return principal
    return _guard

Step 6 — Guard each mutating route. The route now reads as a policy statement: this endpoint writes a GL mapping and therefore demands WRITE_MAPPING, evaluated against the lease in the payload.

from fastapi import FastAPI

app = FastAPI()


@app.post("/reconciliation/mapping")
async def write_mapping(
    principal: Principal = Depends(require(Permission.WRITE_MAPPING)),
) -> dict[str, str]:
    # Reaching here means RBAC + lease scope already passed.
    return {"status": "applied", "by": principal.principal_id}

When a required field such as lease_id or property_id is missing from an uploaded invoice, do not fall through to open access — route the record to a PENDING_ABSTRACTION state that only a holder of QUARANTINE_REVIEW can clear, exactly as the parent model prescribes. This keeps an unmapped expense out of the recoverable pool until a reviewer attaches it to a real taxonomy node.

Gotchas & Known Limitations

  • Default-deny, always. The evaluate function returns allowed=False on every path that is not explicitly permitted. A permission engine that defaults to allow on an unrecognized verb or an empty role set is a breach waiting for an edge case; assert the deny-by-default property in tests.
  • Role union widening. A principal holding two roles gets the union of their grants. A harmless “read-only reviewer” combined with a legacy “importer” can silently reconstitute full write access — audit the effective union, not the individual roles.
  • Self-approval defeats separation of duties. The value gate rejects second_approver_id == principal_id. Without that equality check an accountant names themselves as the second approver and satisfies the letter of the rule while defeating it.
  • Enforce lease scope in code, not the UI. A dropdown that only shows a manager’s properties shapes what they see, not what their token can reach. The property_id not in principal.property_ids check must live in evaluate, so a broad or stolen token still cannot mutate a lease outside its assignment. This is where the scope discipline of standardizing CAM taxonomies across portfolios pays off.
  • Stale scope after a portfolio move. When a property changes hands, revoke the former manager’s property_ids in the same transaction that reassigns the lease, or they retain write scope over books they no longer own.
  • Decimal thresholds, never float. The amount >= override_ceiling comparison is an exact dollar test. A float ceiling of 10000.00 can read as 9999.9999999 after arithmetic and let an at-limit override slip through ungated. Carry every monetary field as Decimal, quantized to two places.
  • Service accounts are least-privilege too. The automation role holds one staging verb. Resist the temptation to grant it WRITE_MAPPING “just for the nightly job” — that is how an unattended script ends up with the most dangerous permission in the system.

Verification

Trust the layer only after it survives an adversarial suite that tries to break in rather than confirming the happy path. Structure fixtures around principals of varying scope and requests at, below, and above each threshold, and assert on the verdict and its reason.

from decimal import Decimal


def make_accountant() -> Principal:
    return Principal(
        principal_id="acct-1",
        roles=frozenset({"accountant"}),
        property_ids=frozenset({"prop-A"}),
    )


def test_verb_not_granted_is_denied() -> None:
    p = make_accountant()
    req = AccessRequest(permission=Permission.APPROVE_RECONCILIATION,
                        property_id="prop-A", lease_id="lease-7")
    assert evaluate(p, req).allowed is False


def test_out_of_scope_property_is_denied() -> None:
    p = make_accountant()
    req = AccessRequest(permission=Permission.WRITE_MAPPING,
                        property_id="prop-B", lease_id="lease-2")
    d = evaluate(p, req)
    assert d.allowed is False and "out of scope" in d.reason


def test_override_at_ceiling_needs_distinct_approver() -> None:
    p = make_accountant()
    req = AccessRequest(
        permission=Permission.OVERRIDE_ALLOCATION,
        property_id="prop-A", lease_id="lease-7",
        amount=Decimal("10000.00"),
        override_ceiling=Decimal("10000.00"),
        second_approver_id="acct-1",          # same person: must fail
    )
    assert evaluate(p, req).allowed is False


def test_override_below_ceiling_passes_single_approver() -> None:
    p = make_accountant()
    req = AccessRequest(
        permission=Permission.OVERRIDE_ALLOCATION,
        property_id="prop-A", lease_id="lease-7",
        amount=Decimal("9999.99"),
        override_ceiling=Decimal("10000.00"),
    )
    assert evaluate(p, req).allowed is True

Pin the boundary tests to exact Decimal values on both sides — one cent below the ceiling passes single-approver, exactly at the ceiling demands two — so the numerical handling stays honest. Run the deny-by-default and separation-of-duties cases in CI on every change to ROLE_MATRIX; a widened grant is precisely the mistake that slips through code review but fails a test that enumerates who can approve.

Wired this way, role-based access stops being a UI convenience and becomes an enforced boundary that hands provenance-stamped pools to the pro rata allocation algorithms downstream — return to CAM Reconciliation Security & Access Controls for the hash-chained audit log that records every decision this matrix produces.