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.

The authorize decision core feeding a hash-chained audit log A request is evaluated by three gates in order — verb-level least privilege, lease-scoped resource binding, and value-gated dual approval. Only a request that clears all three becomes an allowed write, and every decision, permitted or denied, is appended to an append-only HMAC hash chain where each entry commits to the digest of the entry before it. Request action on resource 1 · Least privilege role grants the action? 2 · Lease scope assigned to property? 3 · Dual approval 2nd approver ≥ ceiling Allow write every verdict — allow or deny — logged append Hash-chained audit log e₀ h₀ = H(root) e₁ h₁ = H(h₀ ∥ e₁) e₂ h₂ = H(h₁ ∥ e₂)

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 nn folds in the digest of entry n1n-1, so altering any past record invalidates every digest after it.

hn=H(hn1serialize(en))h_n = H\big(h_{n-1} \parallel \text{serialize}(e_n)\big)

where HH is a SHA-256 keyed HMAC, ene_n is the event payload (principal, action, resource id, before/after values, timestamp), and \parallel is byte concatenation. Because h0h_0 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.

Role-to-action permission matrix with the dual-approval gate on overrides Each role is granted only the verbs its reconciliation function needs. Request authority (write_mapping) and approval authority (approve_reconciliation) never sit in the same role, and the accountant's override_allocation grant is gated so a material override requires a distinct second approver. read_statement write_mapping override_allocation approve_reconciliation export_statement Property manager Accountant Reviewing accountant Automation engineer staging / synthetic only Statement service 2 approvers allowed denied dual-approval gate

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 are Decimal. A float ceiling of 10000.00 can compare as 9999.9999999 after arithmetic and let an at-limit override slip through ungated. Quantize every monetary field to two places on ingestion and keep it Decimal end 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_ids must 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 missing seq or an out-of-order timestamp as corruption. Persist entries to append-only storage and reject any write that is not seq = len(chain).
  • Denied-but-unlogged. The most dangerous bug is an early return on a denied decision that skips the append. 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.