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.
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.
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
evaluatefunction returnsallowed=Falseon 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_idscheck must live inevaluate, 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_idsin 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_ceilingcomparison is an exact dollar test. Afloatceiling of10000.00can read as9999.9999999after arithmetic and let an at-limit override slip through ungated. Carry every monetary field asDecimal, 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.
Related
- CAM Reconciliation Security & Access Controls — the parent model this build implements, including the tamper-evident audit chain and dual-approval decision core.
- Building a lease abstraction database — the versioned source of the property scope and override ceilings the ABAC checks read.
- Standardizing CAM taxonomies across portfolios — how role scope and rule versions stay consistent as properties change hands.
- Managing expense caps and controllable limits — the negotiated ceilings that turn into the value gates enforced here.