Security & Access Boundaries

Lease abstraction pipelines process some of the most sensitive documents a real estate operator holds: signed commercial and residential agreements carrying rent schedules, guarantor identities, bank details, and personally identifiable information, all spread across many distinct portfolios and tenant organizations. This page sits inside Core Architecture & Lease Taxonomy, and it is the stage that decides who may see which extracted fact and under what role — the access layer that wraps every other stage in the architecture. Get it wrong and one mis-scoped query exposes another landlord’s rent roll; get it right and automation can run at portfolio scale without ever crossing a tenant boundary.

The specific workflow challenge this page solves: how do you enforce granular, least-privilege permissions on abstraction output without fragmenting the pipeline into one isolated copy per consumer? Property managers need read/write on their own active leases, third-party auditors need read-only with a full audit trail, and automated parsers need a narrow service scope that can never read raw financial fields. The production answer is a single authorization gate placed at the pipeline entry point that maps organizational roles to lease sensitivity, isolates by tenant and portfolio, redacts restricted fields in place, and emits an immutable audit record for every decision. Everything below is the engineering of that gate.

Authorization gate: three sequential boundary checks, redaction on grant, and an immutable audit log for every outcome An authenticated request passes through three independent checks in order: sensitivity-versus-role, tenant boundary, and portfolio boundary. Any single failure denies the request, raises a PermissionError, and writes to the audit log. When all three pass, restricted financial and PII fields are redacted before the record reaches downstream consumers, and the grant is also written to the same append-only audit log. Request authenticated UserContext 1 · Sensitivity restricted needs ADMIN / AUDIT pass 2 · Tenant tenant_id in scope every role pass 3 · Portfolio portfolio_id in scope grant Redact mask money + PII non-privileged Consumers safe, redacted record deny → PermissionError deny deny grant logged Immutable audit log — append-only structured JSON user_id · lease_id · tenant_id · deciding rule · granted · timestamp — every outcome, grant and deny

Scope and where this fits

This page owns the authorization and audit boundary that every other stage in the taxonomy leans on. It assumes records have already been shaped by metadata normalization standards so that fields like tenant_id, portfolio_id, and sensitivity tags arrive in known types, and it assumes clauses have been typed by clause classification systems so the gate can reason about which payloads carry PII or financial terms. It does not own how records are persisted — that contract belongs to lease data models, which define the ownership hierarchy and sensitivity classification the gate reads. When access is denied or a record is incomplete, the request is handed to fallback routing logic rather than silently dropped.

The deepest implementation detail — the database-layer and application-layer isolation patterns that make multi-tenant storage safe under concurrent query load — lives in Designing Secure Multi-Tenant Lease Storage with Role-Based Access. This page covers the gate; that page covers where the gated data rests.

Prerequisites and environment setup

The reference implementation targets Python 3.11+ and pydantic v2 for the boundary models, matching the validation convention used across the architecture. Authorization logic should be a pure, side-effect-free decision function so it can be unit-tested exhaustively and reused identically by API handlers, batch workers, and event consumers.

python >= 3.11
pydantic >= 2.6      # boundary models + field_validator / model_validator
structlog >= 24.1    # structured, JSON-serializable audit events
python -m venv .venv && source .venv/bin/activate
pip install "pydantic>=2.6" "structlog>=24.1"

Two environment assumptions matter. First, the principal (the authenticated user or service identity) is resolved upstream — token validation and identity proofing are not this layer’s job; this layer consumes an already-authenticated UserContext. Second, every lease record carries a non-null tenant_id, portfolio_id, and sensitivity_level by the time it reaches the gate; records missing those fields are a normalization failure and must be rejected at the boundary, never assumed safe.

Authorization decision table

Access is the conjunction of three independent checks. All three must pass; any single failure denies and audits. Expressing the policy as a table keeps it reviewable by non-engineers and testable as data:

Sensitivity READ_ONLY EDIT AUDIT ADMIN
standard read read + write read + audit log full
confidential read (redacted) read + write (redacted) read + audit log full
restricted deny deny read + audit log full

Two cross-cutting rules sit on top of the table. The tenant boundary rule denies any request whose lease tenant_id is not in the principal’s assigned tenants, regardless of role — an admin of tenant A is a stranger to tenant B. The portfolio boundary rule does the same one level down, so a manager scoped to one portfolio cannot read a sibling portfolio inside the same tenant. Field-level redaction then applies to every non-ADMIN/AUDIT principal that clears the gate, masking financial and PII fields before the record continues downstream.

Sensitivity-versus-role authorization matrix with tenant and portfolio boundary overrides A three-row by four-column matrix. Rows are sensitivity levels (standard, confidential, restricted); columns are roles (READ_ONLY, EDIT, AUDIT, ADMIN). Grant cells are tinted, redacted-grant cells are marked with a dashed outline, and deny cells use the warning colour. Below the matrix, two override callouts state that the tenant and portfolio boundaries deny any out-of-scope lease for every role, admins included, and are applied before the matrix grants. sensitivity ↓    role → READ_ONLY EDIT AUDIT ADMIN standard confidential restricted read read + write read + audit full read redacted read + write redacted read + audit full deny deny read + audit full Override rules — evaluated before any grant above; they deny for every role, admins included: Tenant boundary lease.tenant_id ∉ assigned tenants → deny, regardless of role — an admin of tenant A is a stranger to tenant B. Portfolio boundary lease.portfolio_id ∉ assigned portfolios → deny, one level down — siblings inside one tenant stay isolated.

Primary implementation: the authorization gate

The gate below models its inputs as pydantic v2 records so malformed contexts are rejected before any policy runs — a request that lost its tenant_id in transit can never accidentally evaluate as allowed. The validator itself stays pure: it returns a structured AccessDecision and never mutates state, while audit emission is the caller’s responsibility, which keeps the policy trivially unit-testable.

from __future__ import annotations

import structlog
from datetime import datetime, timezone
from enum import Enum
from typing import Any

from pydantic import BaseModel, field_validator

log = structlog.get_logger("lease.access")


class AccessLevel(str, Enum):
    READ_ONLY = "read"
    EDIT = "edit"
    AUDIT = "audit"
    ADMIN = "admin"


class SensitivityLevel(str, Enum):
    STANDARD = "standard"
    CONFIDENTIAL = "confidential"
    RESTRICTED = "restricted"


# Roles permitted to view restricted leases and unredacted fields.
PRIVILEGED_ROLES = frozenset({AccessLevel.ADMIN, AccessLevel.AUDIT})

# Fields masked for every non-privileged principal.
RESTRICTED_FIELDS = frozenset(
    {"rent_amount", "security_deposit", "tenant_ssn", "guarantor_info", "bank_account"}
)


class LeaseContext(BaseModel):
    lease_id: str
    tenant_id: str
    portfolio_id: str
    sensitivity_level: SensitivityLevel
    classification_tags: list[str] = []
    metadata: dict[str, Any] = {}

    @field_validator("tenant_id", "portfolio_id")
    @classmethod
    def _non_empty(cls, v: str) -> str:
        # A blank scope must never default to "match anything".
        if not v or not v.strip():
            raise ValueError("tenant_id and portfolio_id are required scope keys")
        return v


class UserContext(BaseModel):
    user_id: str
    role: AccessLevel
    assigned_tenant_ids: frozenset[str]
    assigned_portfolio_ids: frozenset[str]


class AccessDecision(BaseModel):
    granted: bool
    reason: str
    rule: str  # which check decided the outcome, for the audit record


class AccessGate:
    """Pure least-privilege authorization for lease abstraction requests."""

    def evaluate(self, user: UserContext, lease: LeaseContext) -> AccessDecision:
        # 1. Sensitivity vs. role — restricted leases need a privileged role.
        if (
            lease.sensitivity_level is SensitivityLevel.RESTRICTED
            and user.role not in PRIVILEGED_ROLES
        ):
            return AccessDecision(
                granted=False,
                reason="restricted lease requires ADMIN or AUDIT role",
                rule="sensitivity",
            )

        # 2. Tenant boundary — overrides role entirely; admins are scoped too.
        if lease.tenant_id not in user.assigned_tenant_ids:
            return AccessDecision(
                granted=False,
                reason="tenant boundary violation",
                rule="tenant",
            )

        # 3. Portfolio boundary — isolates sibling portfolios within a tenant.
        if lease.portfolio_id not in user.assigned_portfolio_ids:
            return AccessDecision(
                granted=False,
                reason="portfolio boundary violation",
                rule="portfolio",
            )

        return AccessDecision(granted=True, reason="all boundaries satisfied", rule="grant")

    def redact(self, metadata: dict[str, Any], role: AccessLevel) -> dict[str, Any]:
        """Mask financial and PII fields for non-privileged principals."""
        if role in PRIVILEGED_ROLES:
            return dict(metadata)
        return {
            k: ("[REDACTED]" if k in RESTRICTED_FIELDS else v)
            for k, v in metadata.items()
        }


def authorize(gate: AccessGate, user: UserContext, lease: LeaseContext) -> dict[str, Any]:
    """Run the gate, emit an immutable audit event, and return safe output."""
    decision = gate.evaluate(user, lease)
    log.info(
        "access.decision",
        ts=datetime.now(timezone.utc).isoformat(),
        user_id=user.user_id,
        role=user.role.value,
        lease_id=lease.lease_id,
        tenant_id=lease.tenant_id,
        granted=decision.granted,
        rule=decision.rule,
        reason=decision.reason,
    )
    if not decision.granted:
        raise PermissionError(decision.reason)
    return gate.redact(lease.metadata, user.role)

Notice the lease-specific edge cases baked in. The tenant check runs after the sensitivity check but is independent of role, so an admin can never read across tenants — a common and dangerous default in naive RBAC. Redaction operates on the metadata dictionary by key, so a confidential lease an EDIT user is allowed to modify still returns with rent_amount masked, separating “may write the record” from “may read the money”. And frozenset scopes make membership checks O(1) and the contexts hashable for caching hot principals.

Validation and quality gates

Authorization is only as trustworthy as the inputs it runs on. Three gates protect it.

Schema enforcement at the boundary. Construct LeaseContext/UserContext through pydantic, never as bare dicts. A record arriving without tenant_id raises ValidationError and is routed to a dead-letter queue tagged with the field path, rather than entering the gate where an empty scope might evaluate ambiguously.

from pydantic import ValidationError

def safe_authorize(gate, raw_user: dict, raw_lease: dict) -> dict[str, Any] | None:
    try:
        user = UserContext.model_validate(raw_user)
        lease = LeaseContext.model_validate(raw_lease)
    except ValidationError as exc:
        log.error("access.malformed_input", errors=exc.errors())
        # Never fail open: a record we cannot validate is a record we deny.
        send_to_dead_letter(raw_lease, reason="schema", detail=exc.errors())
        return None
    return authorize(gate, user, lease)

Fail-closed defaults. Every uncertain path denies. A missing sensitivity tag is treated as restricted, an unknown role is treated as no role, and an exception inside the gate becomes a denial, never a grant. The redaction set is an allowlist failure: if a new financial field is added to the schema but not to RESTRICTED_FIELDS, a contract test (below) fails the build rather than leaking it silently.

Audit immutability. Audit events are append-only and serialized as structured JSON so they stream cleanly to a SIEM or compliance store. The audit record always captures the deciding rule, which makes denials explainable during a compliance review — “denied by tenant boundary” is actionable; “403” is not. Records that fail validation or fall below a confidence threshold are diverted through fallback routing logic to a manual-review queue, so a low-confidence sensitivity classification never becomes an accidental grant.

A small contract test pins the leak-prevention invariant so the redaction allowlist cannot drift out of sync with the financial schema:

def test_no_financial_field_leaks_to_unprivileged_role():
    gate = AccessGate()
    financial = {"rent_amount": 125000.0, "security_deposit": 50000.0, "bank_account": "..."}
    for role in (AccessLevel.READ_ONLY, AccessLevel.EDIT):
        out = gate.redact(financial, role)
        assert all(out[k] == "[REDACTED]" for k in financial), f"{role} saw raw money"

Troubleshooting

A user reads a lease in the wrong tenant. Symptom: cross-tenant data appears in a query result. Cause: the boundary check was skipped because the principal’s role was ADMIN and the code short-circuited on role before checking tenant. Fix: keep the three checks ordered as written — tenant and portfolio boundaries must run for every role, including admin.

Restricted financial fields appear unredacted. Symptom: rent_amount shows real numbers for a READ_ONLY user. Cause: a new financial field was added to the schema but not to RESTRICTED_FIELDS, or redaction ran before the field was renamed by metadata normalization standards. Fix: run redaction on the normalized record, and let the contract test gate the allowlist on every build.

The gate intermittently grants then denies the same request. Symptom: flapping decisions for one principal. Cause: scopes are being mutated in place by a caching layer between requests, or a set is being rebuilt non-deterministically. Fix: treat UserContext as immutable, use frozenset scopes, and resolve the principal once per request.

Audit log is missing the denials you most need. Symptom: granted requests are logged but denials are not. Cause: the audit emit sits after the PermissionError raise instead of before it. Fix: log the decision before raising, as in authorize above, so every outcome — grant and deny — is recorded.

A blank tenant_id matches everything. Symptom: a record with an empty scope is readable by every principal. Cause: an empty string passed membership because the assigned set also contained an empty string, or the field was never validated. Fix: the field_validator rejects blank scope keys at construction, so the record dead-letters instead of evaluating.

Service parsers can read raw PII. Symptom: an automated extraction worker logs unredacted SSNs. Cause: the worker was assigned ADMIN for convenience. Fix: give service identities a dedicated narrow role outside PRIVILEGED_ROLES, so redaction always applies to machine consumers.

Performance and scale notes

The gate is pure arithmetic over small sets, so it is never the bottleneck — the surrounding I/O is. For large portfolios, resolve a principal’s assigned_tenant_ids/assigned_portfolio_ids once per request and cache the UserContext for the request lifetime rather than re-querying on every lease; because the model is immutable and hashable, it caches cleanly. When authorizing a batch of thousands of leases for one principal, evaluate in a tight loop with the scopes already resolved — frozenset membership keeps each decision O(1) — and stream results rather than materializing the whole authorized set in memory. Push only redacted, serialized records onto the downstream write path so raw financial fields never enter shared caches. The same async batch patterns that scale extraction apply to bulk authorization; coordinating them across stages is covered in the parsing-side workflows. Standards alignment (least-privilege access control and audit accountability, as framed by common federal and application-security baselines) is satisfied structurally here: every decision is least-privilege by construction and every decision is audited.

Frequently asked questions

How do I let auditors read everything without giving them write access?

Model audit as its own role inside PRIVILEGED_ROLES that bypasses redaction and the restricted-sensitivity bar but carries no edit permission in your write path. The gate grants the read and unmasks fields; your persistence layer rejects mutations from the AUDIT role. Keep the two concerns separate so "may see the money" never implies "may change the money".

Should the access boundary live in the API layer or the database layer?

Both, defensively. The gate shown here enforces policy in the application so decisions are testable and auditable, while row-level isolation in the store is the backstop that survives an application bug. The database-layer patterns — schema-per-tenant, row-level security, and scoped connection pools — are detailed in the multi-tenant storage page linked below.

What should happen when a lease arrives with no sensitivity tag?

Treat the absence as restricted and route the record to manual review through fallback routing rather than admitting it as standard. Failing closed costs a review; failing open costs a breach. A missing tag is a normalization defect, so the dead-letter record should carry the field path so the upstream stage can be fixed.

How do automated parsers fit the role model without over-privileging them?

Give each service identity a dedicated narrow role that is explicitly outside PRIVILEGED_ROLES, so redaction always applies and restricted leases are denied. The parser receives only the fields it needs to do its job, and any raw financial or PII value it would otherwise touch comes back masked, which keeps machine logs clean of sensitive data.

← Back to Core Architecture & Lease Taxonomy

← Back to Core Architecture & Lease Taxonomy