CAM Reconciliation Security & Access Controls
A CAM reconciliation is a chain of financial decisions — which vendor invoice lands in which recoverable pool, whether a controllable cost breaches its contractual ceiling, whose pro-rata share absorbs a year-end true-up — and every link in that chain is an editable record that ends up on a tenant statement. When any of those records can be changed without a role check, a threshold gate, or a tamper-evident log entry, the reconciliation stops being defensible: an auditor asks who changed this expense pool and on what authority, and the answer is a shrug. This page, part of the CAM Architecture & Lease Clause Taxonomy reference architecture, specifies the access-control layer that makes that question answerable — role-segregated write paths, lease-scoped permission evaluation, dual-approval thresholds on high-value overrides, and a cryptographically chained audit log — so that property managers, real estate accountants, and the CRE engineers who build the reconciliation engine can prove that every number was produced by an authorized action.
Prerequisites & Data Contracts
Access control is meaningless unless it can answer access to what, under which lease, at what dollar value, so this layer sits on top of three upstream data contracts that must already exist before a single permission is evaluated.
First, an authoritative identity and role source. Every actor — human or service account — resolves to a stable principal_id and one or more roles. Roles are not job titles; they are named bundles of permissions that map to reconciliation functions, so a “reviewing accountant” role and a “tenant-statement generator” service both carry only the verbs they need.
Second, a lease-scoped authorization context. The reconciliation engine consumes structured lease facts from the lease abstraction database: which properties a principal is assigned to, which tenant leases fall under those properties, and the negotiated parameters that turn into gates — the capitalization threshold, the controllable-expense caps, and the override ceiling above which a second approver is required. A permission check that ignores this context produces the classic failure mode: an accountant with legitimate write access to Property A silently edits Property B because the role check passed and nobody scoped it to the lease.
Third, a canonical expense schema. The records this layer guards — GL-coded, recoverable-flagged line items — arrive already conformed by schema validation for parsed expense data and classified by GL code mapping for CAM expenses. Access control does not re-validate shape; it assumes a trustworthy record and governs mutation of it. Monetary fields on those records are Decimal, never float, because the override thresholds this layer enforces are exact dollar comparisons and a binary-float drift of a fraction of a cent must never flip a gate.
The data contract between these three sources is deliberately narrow: given a principal_id, an action, and the resource being touched (with its property, lease, and amount), the layer returns an allow/deny decision plus the audit record that decision generated. Everything else — how invoices were parsed, how pools were computed — is upstream and out of scope.
Rule Design: Least Privilege, Lease Scope, and a Hash-Chained Log
The access model is the intersection of three independent tests, and a request is permitted only when all three pass. Collapsing any one of them is where real reconciliations leak.
Verb-level least privilege. Each role grants an explicit set of (action) capabilities — read_statement, write_mapping, override_allocation, approve_reconciliation, export_tenant_statement. A property manager reads drafts and writes tenant correspondence but cannot rewrite a GL mapping; an accountant writes mappings and reconciles pools but a single accountant cannot both request and approve a high-value override; an automation engineer runs the pipeline against staging with synthetic data and holds no production write verbs at all. This mirrors the segregation defined in the child page on setting up role-based access for CAM data, which drills into the concrete role matrix and middleware wiring.
Lease-scoped resource binding. Holding write_mapping is necessary but not sufficient; the principal must also be assigned to the property that owns the lease the resource belongs to. This is row-level scope, enforced in code rather than left to a UI filter, so a compromised or over-broad token cannot reach a lease outside its assignment.
Value-gated dual approval. An override whose dollar impact meets or exceeds the lease’s negotiated ceiling requires a second, distinct approver. The threshold comparison is exact Decimal arithmetic against a per-lease limit read from the abstraction. This is the control that stops a single actor from quietly moving a five-figure capital replacement into the operating pool.
The audit log that records every one of these decisions is tamper-evident by construction. Each entry commits to the entire history before it through a hash chain: the digest of entry folds in the digest of entry , so altering any past record invalidates every digest after it.
where is a SHA-256 keyed HMAC, is the event payload (principal, action, resource id, before/after values, timestamp), and is byte concatenation. Because is a keyed root and every subsequent digest is keyed, an attacker who edits history cannot recompute a consistent chain without the log’s secret key — the property that lets the chain survive third-party scrutiny during a tenant audit.
Python Implementation
The module below is a self-contained decision core: pydantic models for the principal, the guarded resource, and the immutable audit entry; a pure authorize function that runs the three tests in order; and an AuditChain that appends each decision as a keyed, hash-linked record. All monetary values are Decimal per the site standard, and the chain uses only the Python standard library’s hashlib and hmac modules.
from __future__ import annotations
import hmac
import json
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class Action(str, Enum):
READ_STATEMENT = "read_statement"
WRITE_MAPPING = "write_mapping"
OVERRIDE_ALLOCATION = "override_allocation"
APPROVE_RECONCILIATION = "approve_reconciliation"
EXPORT_STATEMENT = "export_statement"
# Role -> the verbs it may perform. A "reviewing accountant" can approve but is
# intentionally denied WRITE_MAPPING so request and approval never share a hand.
ROLE_GRANTS: dict[str, frozenset[Action]] = {
"property_manager": frozenset({Action.READ_STATEMENT}),
"accountant": frozenset(
{Action.READ_STATEMENT, Action.WRITE_MAPPING, Action.OVERRIDE_ALLOCATION}
),
"reviewing_accountant": frozenset(
{Action.READ_STATEMENT, Action.APPROVE_RECONCILIATION}
),
"statement_service": frozenset({Action.READ_STATEMENT, Action.EXPORT_STATEMENT}),
"automation_engineer": frozenset(), # production write verbs live only in staging
}
class Principal(BaseModel):
principal_id: str
roles: frozenset[str]
property_ids: frozenset[str] # lease scope: properties this actor may touch
def granted_actions(self) -> frozenset[Action]:
actions: set[Action] = set()
for role in self.roles:
actions |= ROLE_GRANTS.get(role, frozenset())
return frozenset(actions)
class Resource(BaseModel):
resource_id: str
property_id: str
lease_id: str
amount: Decimal = Field(default=Decimal("0.00"))
override_ceiling: Decimal = Field(default=Decimal("0.00")) # from lease abstraction
second_approver_id: Optional[str] = None # who signed off, if anyone
class Decision(BaseModel):
allowed: bool
reason: str
def authorize(principal: Principal, action: Action, resource: Resource) -> Decision:
"""Evaluate least-privilege, lease scope, and value-gated dual approval in order.
Returns a Decision the caller must honour *and* log. The function is pure: it
reads no globals and mutates nothing, so its verdict is fully reproducible from
its arguments during an audit.
"""
# 1. Verb-level least privilege.
if action not in principal.granted_actions():
return Decision(allowed=False, reason=f"role lacks {action.value}")
# 2. Lease-scoped resource binding (row-level, enforced here not in the UI).
if resource.property_id not in principal.property_ids:
return Decision(
allowed=False,
reason=f"principal not scoped to property {resource.property_id}",
)
# 3. Value-gated dual approval for material overrides.
if action is Action.OVERRIDE_ALLOCATION and resource.amount >= resource.override_ceiling:
approver = resource.second_approver_id
if approver is None or approver == principal.principal_id:
return Decision(
allowed=False,
reason="override at/above ceiling requires a distinct second approver",
)
return Decision(allowed=True, reason="ok")
class AuditEntry(BaseModel):
seq: int
ts: str
principal_id: str
action: Action
resource_id: str
allowed: bool
reason: str
before: Optional[str] = None
after: Optional[str] = None
prev_hash: str
entry_hash: str
class AuditChain:
"""Append-only, HMAC-SHA256 hash-chained audit log.
Each entry commits to the digest of the entry before it, so editing any past
record breaks every digest that follows and `verify()` fails.
"""
def __init__(self, secret_key: bytes) -> None:
self._key = secret_key
self._entries: list[AuditEntry] = []
self._last_hash = self._digest(b"genesis")
def _digest(self, payload: bytes) -> str:
return hmac.new(self._key, payload, "sha256").hexdigest()
def append(
self,
principal: Principal,
action: Action,
resource: Resource,
decision: Decision,
before: Optional[str] = None,
after: Optional[str] = None,
) -> AuditEntry:
seq = len(self._entries)
ts = datetime.now(timezone.utc).isoformat()
body = {
"seq": seq,
"ts": ts,
"principal_id": principal.principal_id,
"action": action.value,
"resource_id": resource.resource_id,
"allowed": decision.allowed,
"reason": decision.reason,
"before": before,
"after": after,
"prev_hash": self._last_hash,
}
payload = json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
entry_hash = self._digest(self._last_hash.encode("utf-8") + payload)
entry = AuditEntry(**body, entry_hash=entry_hash)
self._entries.append(entry)
self._last_hash = entry_hash
return entry
def verify(self) -> bool:
prev = self._digest(b"genesis")
for e in self._entries:
body = e.model_dump(exclude={"entry_hash"})
body["action"] = e.action.value
payload = json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
expected = self._digest(prev.encode("utf-8") + payload)
if not hmac.compare_digest(expected, e.entry_hash):
return False
prev = e.entry_hash
return True
Every reconciliation mutation runs the same two-step ritual: call authorize, then append the decision — allowed or denied — to the chain. A denied attempt is still a logged event, because a record of who tried to move a number outside their authority is exactly what an auditor and a disputing tenant want to see.
Validation Rules & Edge Cases
The failure modes here are subtle because a broken access check does not raise an exception; it silently permits the wrong write. The gates below are the ones that catch the leaks specific to CAM reconciliation.
- Self-approval on overrides. The dual-approval test explicitly rejects
second_approver_id == principal.principal_id. Without that equality check, an accountant could name themselves as the second approver and satisfy the letter of the rule while defeating its purpose. - Threshold boundary arithmetic. The comparison is
amount >= override_ceiling, and both sides areDecimal. Afloatceiling of10000.00can compare as9999.9999999after arithmetic and let an at-limit override slip through ungated. Quantize every monetary field to two places on ingestion and keep itDecimalend to end. - Role union widening. A principal holding two roles receives the union of their grants. Audit the union, not the individual roles — a “read-only reviewer” role combined with a legacy “importer” role can accidentally reconstitute full write access.
- Stale lease scope after portfolio moves. When a property changes hands, a principal’s
property_idsmust be revoked in the same transaction that reassigns the lease, or a former manager retains write scope. This ties directly into the change-tracking discipline of standardizing CAM taxonomies across portfolios. - Chain gaps and reordering.
verify()fails if any entry is altered, but it must also treat a missingseqor an out-of-order timestamp as corruption. Persist entries to append-only storage and reject any write that is notseq = len(chain). - Denied-but-unlogged. The most dangerous bug is an early
returnon a denied decision that skips theappend. Enforce logging in the caller so that every verdict, allow or deny, produces exactly one chain entry.
Integration Points
This layer is a chokepoint, not an endpoint: it wraps the mutating operations of the reconciliation engine rather than living beside them. Upstream, it consumes conformed records from the ingestion pipeline and lease parameters from the abstraction database. Downstream, three consumers depend on the decisions it emits.
The reconciliation engine itself calls authorize before applying any GL mapping edit or allocation override, so the pro-rata allocation algorithms only ever run on inputs whose provenance is signed into the chain. The tenant-statement generator reads the same chain to stamp each statement with the hash of the reconciliation snapshot it was produced from, giving a tenant a verifiable link between the number they were billed and the state of the books at close. And the compliance export attaches the chain’s head digest to the archived reconciliation, so a later reviewer can confirm nothing was retro-edited after sign-off. In each case the audit chain is the shared source of truth: the access layer writes it, everyone downstream reads it.
Testing & Verification
Access-control logic earns trust only through adversarial tests — the suite should try to break in, not merely confirm the happy path. Structure fixtures around principals of varying scope and resources at, below, and above each threshold, and assert both the verdict and the resulting audit state.
from decimal import Decimal
def make_accountant() -> Principal:
return Principal(
principal_id="acct-1",
roles=frozenset({"accountant"}),
property_ids=frozenset({"prop-A"}),
)
def test_override_at_ceiling_needs_distinct_approver() -> None:
p = make_accountant()
r = Resource(
resource_id="alloc-9",
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 be rejected
)
d = authorize(p, Action.OVERRIDE_ALLOCATION, r)
assert d.allowed is False
assert "second approver" in d.reason
def test_out_of_scope_property_is_denied() -> None:
p = make_accountant()
r = Resource(resource_id="m-3", property_id="prop-B", lease_id="lease-2")
assert authorize(p, Action.WRITE_MAPPING, r).allowed is False
def test_chain_detects_tampering() -> None:
chain = AuditChain(secret_key=b"unit-test-key")
p = make_accountant()
r = Resource(resource_id="m-1", property_id="prop-A", lease_id="lease-2")
d = authorize(p, Action.WRITE_MAPPING, r)
chain.append(p, Action.WRITE_MAPPING, r, d, before="R&M", after="Capital")
assert chain.verify() is True
chain._entries[0].after = "Operating" # simulate a retroactive edit
assert chain.verify() is False
The threshold tests must pin exact Decimal values on both sides of the boundary — one cent below the ceiling should pass single-approver, exactly at the ceiling should require two — so the numerical-tolerance handling stays honest. The tampering test is the load-bearing one: it proves the chain does what the whole layer exists to promise. Run verify() in CI against a golden fixture chain and again on every production archive before it is handed to an auditor.
Frequently Asked Questions
Why enforce lease scope in code when the UI already filters by property?
A UI filter is a convenience, not a control — it shapes what a user sees, not what their token can reach. Row-level scope in authorize means a broad or stolen credential still cannot mutate a lease outside its assignment, because the deny happens at the application layer before any write is attempted.
What stops an accountant from approving their own high-value override?
The dual-approval gate compares the second approver against the requester and rejects them when they match, and the reviewing_accountant role that can approve is deliberately not granted write_mapping. Request authority and approval authority are held by different roles, so no single principal can do both on a material override.
How does the hash-chained log survive a determined insider?
Each entry’s digest is a keyed HMAC that folds in the previous digest, so editing any past record breaks every digest after it and verify() fails. An insider who wants to rewrite history silently would need the chain’s secret key and would have to recompute every subsequent entry — and the head digest archived at sign-off pins the state the tenant statements were built from.
Why must the override threshold use Decimal instead of float?
The gate is an exact dollar comparison against a lease-negotiated ceiling. Binary floating point cannot represent most cent values exactly, so a float ceiling can drift a fraction of a cent and let an at-limit override slip through ungated. Decimal, quantized to two places, keeps the boundary penny-exact and the decision reproducible.
Is a denied attempt worth logging? Yes — a denied write is often the most informative record in the chain. It documents who tried to move a number outside their authority and when, which is precisely what an auditor reviewing controls and a tenant disputing a charge want to see.
Where This Fits
Securing CAM reconciliation is not a firewall bolted on after the math; it is a decision core that every mutation passes through — least privilege deciding whether the verb is allowed, lease scope deciding whether this actor may touch this lease, and value-gated dual approval deciding whether one signature is enough — with every verdict committed to a hash-chained log that no insider can quietly rewrite. Built this way, the reconciliation stops being a spreadsheet anyone can retro-edit and becomes an auditable system where each recoverable dollar traces to an authorized action. It draws its lease parameters from the lease abstraction database, guards the recoverable categories defined when defining CAM expense categories in commercial leases, and hands signed, provenance-stamped pools to the expense allocation rule engines that produce tenant statements. Cryptographic guidance and the measurement standards published by the Building Owners and Managers Association inform how those statements withstand third-party audit.
Related
- CAM Architecture & Lease Clause Taxonomy — the parent architecture this access-control layer secures, from lease language to reconciliation engine.
- Setting up role-based access for CAM data — the concrete role matrix, middleware interceptors, and zero-trust wiring behind this model.
- Building a lease abstraction database — the versioned source of the lease-scoped parameters and override ceilings this layer reads.
- Standardizing CAM taxonomies across portfolios — how scope and rule versions stay consistent as properties change hands.
- Schema validation for parsed expense data — the upstream gate that guarantees the conformed records this layer governs.