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.
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.
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.
Related
- Designing Secure Multi-Tenant Lease Storage with Role-Based Access — the database- and application-layer isolation patterns beneath this gate.
- Lease Data Models — the canonical schema that defines the ownership hierarchy and sensitivity tags the gate reads.
- Metadata Normalization Standards — guarantees scope keys and sensitivity fields arrive in the types the gate expects.
- Clause Classification Systems — the upstream stage that flags which clauses carry PII or financial terms.
- Fallback Routing Logic — where denied, incomplete, or low-confidence records are diverted for review.