Designing Secure Multi-Tenant Lease Storage with Role-Based Access

The narrow decision this page resolves: when many landlord organizations share one lease abstraction database, which physical isolation model do you choose, and how do you bolt clause-level role-based access onto it without one mis-scoped query exposing another operator’s rent roll? The answer is rarely “turn on row-level security and move on.” Commercial leases carry amendments that trigger role escalations, exhibits flagged as restricted, and concurrent redline writes from external counsel — workloads that break naive single-predicate isolation. This page picks an isolation strategy on its merits and shows a hybrid storage layer with clause-level RBAC in runnable Python.

Where this sits in the abstraction architecture

This is the deepest implementation detail under Security & Access Boundaries, the stage of Core Architecture & Lease Taxonomy that decides who may read which extracted fact. The authorization gate — the pure decision function that maps a role to a lease sensitivity — lives in that parent page; this page covers where the gated data physically rests and how the store itself refuses to leak across tenants. It assumes records already carry typed scope keys from metadata normalization standards (every row has a non-null tenant_id, portfolio_id, and confidentiality_level), that clauses have been typed by clause classification systems so the store knows which payloads carry PII or financial terms, and that the canonical ownership hierarchy is defined by lease data models. When a write is denied or a sensitivity tag is missing, the request is handed to fallback routing logic rather than silently dropped.

Hybrid multi-tenant lease storage: partition gate, access matrix, append-only versioning, and audit log An authenticated request (JWT carrying a scope array, tenant_id, and role) enters a deny-by-default tenant partition gate. If the row's tenant_id matches the caller, it passes to the application-layer lease_access_matrix gate, which checks whether the role grants the requested clause category. A grant appends an immutable, SHA-256-chained version row. A tenant mismatch or denied scope diverts to the audit sink, which masks the denial as a 404 so no cross-tenant existence signal leaks. Both the committed write and every denial then write to a single append-only audit log capturing who, what, and when. Authenticated request JWT · scope[] · role tenant_id Partition tenant_id match? Access matrix role ∋ category? Append version row immutable · supersede SHA-256 chain deny by default tenant matches role grants tenant mismatch scope denied Audit sink deny-by-default · mask as 404 no cross-tenant existence signal Append-only audit log every outcome — granted and denied principal · action · tenant_id · timestamp version committed denial recorded

Isolation strategies compared

Tenant isolation is a spectrum from “one shared table, trust the WHERE clause” to “one physical database per tenant.” Each step trades query convenience and operational cost for a smaller blast radius when a bug slips through. The table below is the decision you actually have to make before writing any storage code:

Strategy Isolation strength Cross-tenant query cost Blast radius of one bug Per-tenant ops cost Where it fits
Shared table + tenant_id predicate Weak — one missing WHERE leaks everything Trivial Whole platform Lowest Prototypes only
PostgreSQL row-level security (RLS) Medium — the database enforces the predicate Low A single table Low Read-heavy apps, simple roles
Hybrid: shared table + app-layer access matrix Strong — partitioned rows plus clause-level RBAC Low A single tenant Low–medium Recommended default
Schema-per-tenant Strong — physical namespace split Medium (cross-schema joins) A single tenant Medium Regulated mid-size portfolios
Database-per-tenant Strongest — full physical separation High (no shared analytics) A single tenant Highest Enterprise / data-residency

RLS deserves a specific caution: it is the industry baseline and it works well for simple read filters, but it frequently fails when a lease amendment triggers a cascading role escalation, or when a concurrent write bypasses the tenant predicate inside a function running as a privileged role. It also has no native notion of clause-level permission — it isolates rows, not the financial exhibit inside a row. For a lease platform where legal_counsel may branch versions but leasing_agent may not, you need policy above the row. That is why the recommended default is a hybrid: keep the shared lease_documents table partitioned by tenant_id for cheap portfolio analytics, and layer an explicit lease_access_matrix that maps roles to specific clause categories in the application.

The following minimal reproducible example enforces tenant scoping and clause-level RBAC using FastAPI dependency injection and SQLAlchemy 2.x. It implements immutable append-only versioning, deterministic SHA-256 hashing for an auditable chain of custody, and explicit JWT-scope validation with deny-by-default semantics. Soft-deletes are eliminated entirely: a lease version is never mutated or removed, only superseded, which closes the rollback-exploit window and gives compliance an immutable record that aligns with cryptographic integrity baselines for legal and financial recordkeeping.

import hashlib
import uuid
from datetime import datetime, timezone
from typing import List, Literal, Optional
from contextlib import asynccontextmanager

from fastapi import FastAPI, Depends, HTTPException, status, Request
from sqlalchemy import (
    Column, String, Text, DateTime, Boolean, create_engine, select, and_
)
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from pydantic import BaseModel, Field

# ---------------------------------------------------------------------------
# Database Configuration (Production-Ready Connection Pooling)
# ---------------------------------------------------------------------------
DATABASE_URL = "postgresql+psycopg2://user:pass@localhost/proptech_db"
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_size=10, max_overflow=20)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

class Base(DeclarativeBase):
    pass

# ---------------------------------------------------------------------------
# ORM Models
# ---------------------------------------------------------------------------
class LeaseDocument(Base):
    __tablename__ = "lease_documents"
    id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
    tenant_id = Column(String, index=True, nullable=False)
    lease_type = Column(String, nullable=False)
    jurisdiction = Column(String, nullable=False)
    confidentiality_level = Column(String, nullable=False)
    content_hash = Column(String, unique=True, nullable=False)
    version_id = Column(String, nullable=False)
    raw_content = Column(Text, nullable=False)
    created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

class AccessMatrix(Base):
    __tablename__ = "lease_access_matrix"
    id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
    role = Column(String, nullable=False, unique=True)
    allowed_clause_categories = Column(String, nullable=False)  # JSON array in production
    can_write_drafts = Column(Boolean, default=False)
    can_approve_redlines = Column(Boolean, default=False)

# ---------------------------------------------------------------------------
# Pydantic Schemas & Context
# ---------------------------------------------------------------------------
class LeaseAccessContext(BaseModel):
    tenant_id: str
    role: Literal["asset_manager", "leasing_agent", "legal_counsel", "compliance_auditor"]
    scopes: List[str]

class LeaseCreateRequest(BaseModel):
    lease_type: str
    jurisdiction: str
    confidentiality_level: str
    raw_content: str

# ---------------------------------------------------------------------------
# Dependency Injection & RBAC Enforcement
# ---------------------------------------------------------------------------
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

def extract_access_context(request: Request) -> LeaseAccessContext:
    auth_header = request.headers.get("Authorization")
    if not auth_header:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing auth token")

    # In production: verify JWT signature, extract claims, validate expiry
    # Mocked for structural clarity
    claims = {
        "tenant_id": "tenant_001",
        "role": "leasing_agent",
        "scopes": ["read_active", "write_drafts"]
    }
    return LeaseAccessContext(**claims)

def enforce_scope(ctx: LeaseAccessContext, required_scope: str) -> None:
    if required_scope not in ctx.scopes:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=f"Role '{ctx.role}' lacks required scope: {required_scope}"
        )

def compute_sha256(content: str) -> str:
    """Deterministic SHA-256 hashing for immutable version tracking."""
    return hashlib.sha256(content.encode("utf-8")).hexdigest()

# ---------------------------------------------------------------------------
# FastAPI Application
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
    Base.metadata.create_all(bind=engine)
    yield

app = FastAPI(title="PropTech Lease Storage API", lifespan=lifespan)

@app.post("/leases", status_code=status.HTTP_201_CREATED)
def create_lease_version(
    payload: LeaseCreateRequest,
    db: Session = Depends(get_db),
    ctx: LeaseAccessContext = Depends(extract_access_context)
) -> dict:
    enforce_scope(ctx, "write_drafts")

    content_hash = compute_sha256(payload.raw_content)

    # Enforce immutable append-only policy
    existing = db.execute(
        select(LeaseDocument).where(LeaseDocument.content_hash == content_hash)
    ).scalar_one_or_none()

    if existing:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Identical lease version already exists. Append-only policy enforced."
        )

    new_doc = LeaseDocument(
        tenant_id=ctx.tenant_id,
        lease_type=payload.lease_type,
        jurisdiction=payload.jurisdiction,
        confidentiality_level=payload.confidentiality_level,
        content_hash=content_hash,
        version_id=str(uuid.uuid4()),
        raw_content=payload.raw_content
    )
    db.add(new_doc)
    db.commit()
    db.refresh(new_doc)

    return {"id": new_doc.id, "version_id": new_doc.version_id, "hash": new_doc.content_hash}

@app.get("/leases/{lease_id}")
def get_lease(
    lease_id: str,
    db: Session = Depends(get_db),
    ctx: LeaseAccessContext = Depends(extract_access_context)
) -> dict:
    enforce_scope(ctx, "read_active")

    doc = db.get(LeaseDocument, lease_id)
    if not doc or doc.tenant_id != ctx.tenant_id:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lease not found or access denied")

    return {
        "id": doc.id,
        "lease_type": doc.lease_type,
        "jurisdiction": doc.jurisdiction,
        "confidentiality_level": doc.confidentiality_level,
        "version_id": doc.version_id,
        "created_at": doc.created_at
    }

Note that DeclarativeBase in SQLAlchemy 2.x is imported from sqlalchemy.orm and used as a base class directly — the older declarative_base() factory pattern still works but is deprecated. The example uses the current idiomatic form. The critical isolation line is in get_lease: the doc.tenant_id != ctx.tenant_id check returns 404 rather than 403, so a probing principal cannot even confirm that a lease exists in another tenant — the store leaks no existence signal across the boundary.

Explicit role mapping for property workflows

Role definitions must map explicitly to property-management operations rather than relying on implicit inheritance chains or hierarchical group nesting. Ambiguous boundaries are the primary vector for unauthorized clause modification and cross-tenant exposure. Define operational scopes as:

  • asset_manager — full CRUD on base lease terms, financial exhibits, and the inputs to escalation formula mapping such as CPI bases and percentage-rent breakpoints.
  • leasing_agent — read-only on active leases, write only on draft amendments; explicitly blocked from legal-clause overrides and historical version mutation.
  • legal_counsel — clause-classification access, redline approval authority, and version-control branching privileges.
  • compliance_auditor — immutable read across all tenants in scope, strictly prohibited from metadata mutation or content alteration.

Map these roles directly to JWT claims carrying explicit scope arrays, and enforce deny-by-default at the middleware layer: reject any request that lacks a verifiable tenant predicate or role scope. Never allow runtime role escalation without cryptographic re-verification of the issuing authority. This zero-trust posture keeps privilege boundaries rigid even during multi-party negotiation cycles involving external counsel.

Edge cases specific to commercial leases

Commercial portfolios break naive isolation in ways residential single-family data never does:

  • Amendment riders that escalate roles. A first-amendment rider can convert a leasing_agent’s draft into a binding term that only legal_counsel may approve. Model the amendment as a new append-only version whose write scope is re-evaluated against the rider’s clause categories — never mutate the base version in place.
  • Tracked-changes redlines. A .docx redline from outside counsel arrives with confidentiality_level not yet set. Treat an unset sensitivity as restricted and route it through fallback logic rather than defaulting to standard; admit it only after the tag is normalized.
  • Assignment and sublease. When a lease is assigned, the controlling tenant_id can change. Do not rewrite history — write a new version under the new owner and keep the prior chain immutable so the chain of custody survives the assignment.
  • Multi-portfolio tenants. A single landlord org may span several portfolio_ids with different counsel. Scope compliance_auditor reads to the assigned portfolios, not the whole tenant, so a sibling portfolio’s restricted exhibits stay invisible.
  • Confidential financial exhibits. Percentage-rent and co-tenancy exhibits are frequently restricted even inside an otherwise standard lease. Because the lease_access_matrix keys on clause category, the exhibit can be masked while the rest of the document remains readable to a leasing_agent.

When to escalate

The hybrid model is the right default, but it has explicit failure conditions where you should escalate to a stronger pattern or to human review:

  • Data-residency or contractual physical-isolation requirements. If a tenant’s contract or a jurisdiction’s regulation mandates that data never share a database, escalate from the hybrid shared table to schema-per-tenant or database-per-tenant — the comparison table above is the decision aid.
  • Low-confidence sensitivity classification. When the upstream classifier’s confidence in a confidentiality_level drops below threshold, do not let the store guess. Divert the record through fallback routing logic to a manual-review queue; failing closed costs a review, failing open costs a breach.
  • Concurrent amendment storms. During peak leasing seasons, simultaneous redline submissions on the same lease cause version races. Add circuit breakers and optimistic-concurrency checks on content_hash; if contention persists, escalate the lease to a single-writer review workflow rather than letting two append-only chains diverge.
  • Cross-tenant analytics demand. If product needs portfolio benchmarking across tenants, do not relax row isolation to get it. Build a separate, aggregated, de-identified store fed by the audit stream, leaving the operational store strictly partitioned.

Frequently asked questions

Is PostgreSQL row-level security enough on its own for lease storage?

Rarely. RLS isolates rows well for simple read filters, but it has no concept of clause-level permission and can be bypassed inside functions running as a privileged role or during amendment-driven role escalations. Use it as a defensive backstop beneath the application-layer access matrix, not as the only boundary.

How do I handle a lease amendment that overrides a base clause?

Write the amendment as a new append-only version with its own SHA-256 hash and re-evaluate the write scope against the amendment's clause categories. Never mutate the base version in place. The base remains immutable in the chain of custody, and the override is expressed as a supersession, which keeps the audit trail intact and rollback exploits closed.

Should multi-tenant isolation live in the application or the database?

Both, defensively. The application enforces clause-level RBAC and deny-by-default scoping so decisions are testable and auditable, while a database mechanism — RLS, schema-per-tenant, or a separate database — is the backstop that survives an application bug. The right database layer depends on your residency and blast-radius requirements; see the strategy comparison above.

Why return 404 instead of 403 on a cross-tenant read?

A 403 confirms that the requested lease exists, which is itself a cross-tenant information leak — a probing principal could enumerate another operator's lease IDs. Returning 404 when the row's tenant_id does not match the caller's scope reveals nothing about existence, so the store leaks no signal across the boundary.

  • Security & Access Boundaries — the parent stage and the pure authorization gate that decides every access this store enforces.
  • Lease Data Models — the canonical ownership hierarchy and sensitivity classification the storage layer reads.
  • Metadata Normalization Standards — guarantees tenant_id, portfolio_id, and confidentiality tags arrive in the types the store expects.
  • Fallback Routing Logic — where denied writes and low-confidence sensitivity classifications are diverted for review.

← Back to Security & Access Boundaries

← Back to Security & Access Boundaries