Implementing Role-Based Access for Clerk Portals in Municipal Permit Workflows

This guide is part of the Core Architecture & Code Taxonomy for Municipal Permits track, which covers the structural backbone that holds a permitting platform together. Where the JSON schema for building permits defines what a permit record looks like, role-based access control (RBAC) defines who may read or mutate each part of it as the record moves through intake, review, inspection, and issuance.

Permalink to this section Problem Statement and Scope

In a municipal clerk portal, access is not a security afterthought bolted on at launch — it is part of the routing engine. The same permission model that hides a Social Security number on a contractor application also decides which review queue an intake clerk can push an application into, whether a field inspector can issue a stop-work order, and who is allowed to override a denial. When that model is implemented as scattered if user.is_staff checks, three failure modes appear almost immediately:

  • Silent over-exposure. A clerk opens a record they have no statutory right to view because the UI hid a button but the API never enforced it. Under most state public-records and privacy statutes this is a reportable breach.
  • Workflow deadlock. A permit cannot advance because the only role authorized for the next transition was never granted to anyone in that office, so applications pile up in a queue with no owner.
  • Unauditable decisions. A permit is approved, a citizen disputes it, and there is no tamper-evident record of which actor held which permission at the moment of adjudication.

The people who feel these failures are concrete: municipal clerks who get locked out of (or wrongly into) records mid-shift; Python automation builders who have to make routing deterministic across residential, commercial, and infrastructure permit categories; and compliance officers who must answer a records request or a state audit with a defensible trail.

The inputs to this subsystem are an authenticated identity (from your municipal SSO/IdP), a target resource (a permit record and its lifecycle state), and a requested action. The output is a binary allow/deny decision plus a filtered view of the resource — never the raw row — and an immutable audit event describing the evaluation.

State-aware RBAC enforcement pipeline for a municipal clerk portal An inbound request from the municipal SSO/IdP flows left-to-right through three enforcement stages. The middleware policy gate resolves the actor's role to a scope set. The resource guard evaluates the request against the permit's lifecycle state and produces a state-aware allow or deny. The field filter applies schema clearance tags to redact restricted attributes. The output is a masked, serialized payload — never the raw row. Each enforcement stage also writes a hash-chained, immutable event to an append-only audit log recording timestamp, actor, resolved scopes, and the allow/deny decision. identity + action role → scopes state-aware allow masked payload Inbound request from SSO / IdP Policy gate middleware role → scope set Resource guard permit lifecycle allow / deny Field filter clearance tags redact / mask Masked view serialized payload writes audit event Append-only audit log · hash-chained, immutable timestamp · actor · resolved scopes · allow / deny decision

Permalink to this section Prerequisites

This implementation targets Python 3.10+ (the examples use match/case and StrEnum-style enums). It is framework-agnostic at the policy layer; the middleware example uses FastAPI/Starlette, but the same evaluator drops into Django ASGI middleware.

pip install "fastapi>=0.110" "pydantic>=2.6" "casbin>=1.36" "PyJWT[crypto]>=2.8" "structlog>=24.1"

Environment assumptions:

  • An identity provider that issues signed tokens carrying a stable sub (the actor) and a role (or group) claim. Do not trust a role claim that the client could mint; bind roles server-side keyed on sub where your IdP cannot be fully trusted.
  • Read access to your permit datastore and to the permit code taxonomy so that permission rules can reference the same code sets the rest of the platform versions.
  • A write path to an append-only log or SIEM for audit events.

Permalink to this section Step 1 — Model Roles Against the Permit Lifecycle

Anchor roles to lifecycle stages, not to job titles. A title changes with an org chart; a lifecycle transition is defined by ordinance. Municipal workflows typically need at least five tiers, each owning specific state transitions:

from enum import Enum


class PermitState(str, Enum):
    DRAFT = "draft"
    SUBMITTED = "submitted"
    UNDER_REVIEW = "under_review"
    INSPECTION = "inspection"
    ISSUED = "issued"
    DENIED = "denied"


class Role(str, Enum):
    APPLICANT = "applicant"          # draft, pay fees, track status
    INTAKE_CLERK = "intake_clerk"    # validate, request docs, route to review
    PLAN_REVIEWER = "plan_reviewer"  # read/write schematics, zoning, checklists
    FIELD_INSPECTOR = "field_inspector"  # log visits, attach evidence, conditional approve
    SUPERVISOR = "supervisor"        # override, audit review, final adjudication


# Which roles may drive which transitions. Encoding this as data — not as
# branching code — is what makes the system defensible in a records dispute.
TRANSITIONS: dict[tuple[PermitState, PermitState], set[Role]] = {
    (PermitState.DRAFT, PermitState.SUBMITTED): {Role.APPLICANT},
    (PermitState.SUBMITTED, PermitState.UNDER_REVIEW): {Role.INTAKE_CLERK},
    (PermitState.UNDER_REVIEW, PermitState.INSPECTION): {Role.PLAN_REVIEWER},
    (PermitState.INSPECTION, PermitState.ISSUED): {Role.FIELD_INSPECTOR, Role.SUPERVISOR},
    (PermitState.UNDER_REVIEW, PermitState.DENIED): {Role.PLAN_REVIEWER, Role.SUPERVISOR},
}


def may_transition(role: Role, src: PermitState, dst: PermitState) -> bool:
    return role in TRANSITIONS.get((src, dst), set())

Keeping transitions in a table means an ordinance change becomes a data change reviewed in a pull request, not a code rewrite — the same discipline applied when versioning permit code taxonomies for annual updates.

Permalink to this section Step 2 — Externalize the Policy as Code

Decouple the rules from the application. A policy-as-code model lets compliance officers change permission matrices in declarative files reviewed in version control, rather than editing compiled binaries. Casbin gives you an RBAC model file plus a policy file; the application only ever asks “can this subject do this action on this object?”

# rbac_model.conf — the shape of the policy, not the policy itself.
#
# [request_definition]
# r = sub, obj, act
# [policy_definition]
# p = sub, obj, act
# [role_definition]
# g = _, _
# [matchers]
# m = g(r.sub, p.sub) && keyMatch(r.obj, p.obj) && r.act == p.act

import casbin


_enforcer: casbin.Enforcer | None = None


def get_enforcer() -> casbin.Enforcer:
    """Load the model + policy once and cache it. Reload atomically on update."""
    global _enforcer
    if _enforcer is None:
        _enforcer = casbin.Enforcer("rbac_model.conf", "rbac_policy.csv")
    return _enforcer


def can(subject: str, resource: str, action: str) -> bool:
    return get_enforcer().enforce(subject, resource, action)

When a new policy file is deployed, reload the manifest atomically into an in-memory copy and swap the reference, so active clerk sessions never observe a half-loaded matrix. A try/except around new_enforcer.load_policy() that keeps the previous enforcer on failure gives you a graceful fallback and zero-downtime updates.

Permalink to this section Step 3 — Enforce at the Middleware Boundary

Route-level checks alone leak. Enforce policy in middleware that intercepts every request before it reaches a handler, so authorization is uniform and auditable rather than re-implemented per endpoint.

import jwt
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()
JWKS_CACHE_TTL_SECONDS = 240  # keep under 5 min; excessive JWKS polling adds latency


def resolve_role(request: Request) -> Role:
    token = request.headers.get("authorization", "").removeprefix("Bearer ").strip()
    claims = jwt.decode(token, key=_public_key(), algorithms=["RS256"],
                        audience="clerk-portal", issuer="https://idp.city.gov/")
    return Role(claims["role"])  # raises if the claim is not a known role


@app.middleware("http")
async def authorize(request: Request, call_next):
    # Public, unauthenticated routes (status lookup) are allow-listed up front.
    if request.url.path.startswith("/public/"):
        return await call_next(request)
    try:
        role = resolve_role(request)
    except (jwt.InvalidTokenError, KeyError, ValueError):
        return JSONResponse({"detail": "unauthenticated"}, status_code=401)

    action = {"GET": "read", "POST": "write", "PATCH": "write",
              "DELETE": "delete"}.get(request.method, "read")
    if not can(role.value, request.url.path, action):
        return JSONResponse({"detail": "forbidden"}, status_code=403)

    request.state.role = role  # downstream handlers reuse the resolved role
    return await call_next(request)

This same scope-isolation pattern extends outward to non-staff consumers: when you expose programmatic endpoints to contractors or GIS vendors, apply the identical evaluator to verified certificate identities, as detailed in securing municipal API endpoints for third-party integrations.

Permalink to this section Step 4 — Filter at the Data Level

A request can be allowed to read a record yet still must not see every field of it. Drive field-level masking from the schema, tagging sensitive attributes so the serializer strips or redacts them per role — this keeps authorization next to the data definition rather than smeared across views.

from typing import Any
from pydantic import BaseModel, Field


class PermitRecord(BaseModel):
    permit_id: str
    parcel_id: str
    status: PermitState
    # `clearance` marks the minimum role tier allowed to see the field.
    applicant_ssn: str = Field(json_schema_extra={"clearance": "supervisor"})
    contractor_license: str = Field(json_schema_extra={"clearance": "intake_clerk"})
    review_notes: str = Field(default="", json_schema_extra={"clearance": "plan_reviewer"})


TIER_RANK = {Role.APPLICANT: 0, Role.INTAKE_CLERK: 1, Role.PLAN_REVIEWER: 2,
             Role.FIELD_INSPECTOR: 2, Role.SUPERVISOR: 3}
CLEARANCE_RANK = {"applicant": 0, "intake_clerk": 1, "plan_reviewer": 2, "supervisor": 3}


def project_for_role(record: PermitRecord, role: Role) -> dict[str, Any]:
    """Return only the fields this role is cleared to see; mask the rest."""
    out: dict[str, Any] = {}
    for name, field in record.model_fields.items():
        extra = field.json_schema_extra or {}
        needed = CLEARANCE_RANK.get(extra.get("clearance", "applicant"), 0)
        out[name] = getattr(record, name) if TIER_RANK[role] >= needed else "***redacted***"
    return out

Because the clearance tag lives in the schema, a new field added during a JSON schema revision is masked by default until someone deliberately tags it readable — fail-closed, not fail-open.

Permalink to this section Step 5 — Emit an Immutable Audit Event

Every permission evaluation, role assignment, and lifecycle transition must be logged with a timestamp, the actor, the resolved scopes, and the decision. State record-retention laws and public-records requests depend on it.

import hashlib
import json
import structlog

log = structlog.get_logger("rbac.audit")


def audit(actor: str, action: str, resource: str, decision: str,
          prev_hash: str) -> str:
    """Hash-chain each entry so tampering breaks the chain (forensic integrity)."""
    entry = {
        "actor": actor, "action": action, "resource": resource,
        "decision": decision, "prev_hash": prev_hash,
    }
    entry_hash = hashlib.sha256(
        (prev_hash + json.dumps(entry, sort_keys=True)).encode()
    ).hexdigest()
    log.info("rbac_decision", **entry, entry_hash=entry_hash)
    return entry_hash  # becomes prev_hash for the next entry

Stream these to an append-only store or SIEM. Redact raw token payloads and certificate serials at the ingestion layer so the audit trail itself never becomes a leak.

Permalink to this section Configuration Reference

Parameter Type Default Municipal-context notes
token_ttl_seconds int 900 Cap staff sessions at 15 min; shorter for supervisor override tokens.
jwks_cache_ttl_seconds int 240 Keep under 5 min; high polling rate-limits the IdP and adds p95 latency.
policy_reload_strategy str "atomic_swap" Load into a new enforcer, swap the reference; keep old on failure.
default_field_clearance str "supervisor" Untagged fields fail closed — visible only to the top tier.
public_path_prefixes list[str] ["/public/"] Allow-list for unauthenticated status lookups; everything else is denied.
audit_sink str "append_only" append_only or siem; never a mutable table.
override_requires_reason bool True Supervisor overrides must carry a free-text justification for the audit trail.

Permalink to this section Error Handling and Edge Cases

Municipal environments break authorization in specific, recurring ways. Handle each explicitly rather than letting it surface as a 500.

  • Unknown or stale role claim. An IdP group is renamed and tokens now carry a role your enum does not know. Role(claims["role"]) raises ValueError; the middleware catches it and returns 401 rather than crashing. Alert on the rate of these — it usually signals an IdP migration in progress.
  • Clock skew on short-lived tokens. A 15-minute token rejected as expired because the portal host’s NTP drifted. Allow a small leeway in jwt.decode and monitor host time sync.
  • Policy file fails to load. A malformed rbac_policy.csv deploy must not open the gates. Keep the previously loaded enforcer and refuse to swap; emit a high-severity audit event so the bad deploy is caught.
  • Transition with no owner. A (src, dst) pair maps to a role nobody in the office holds, so permits stall. Add a startup assertion that every reachable transition has at least one assigned staff member, and surface stalled-queue counts to a monitor.
  • Legacy system degradation. When the primary datastore is down for patching, read-only status endpoints should still answer from cache. Wire the deny path to a graceful fallback rather than a cascading failure, using the patterns in building fallback routing for legacy system downtime.

Permalink to this section Testing and Verification

Authorization logic is exactly the kind of code that must be tested as data: enumerate role × transition combinations and assert the decision, so an ordinance change can never silently widen access.

import pytest


@pytest.mark.parametrize("role,src,dst,expected", [
    (Role.INTAKE_CLERK, PermitState.SUBMITTED, PermitState.UNDER_REVIEW, True),
    (Role.APPLICANT, PermitState.UNDER_REVIEW, PermitState.ISSUED, False),
    (Role.PLAN_REVIEWER, PermitState.UNDER_REVIEW, PermitState.DENIED, True),
    (Role.FIELD_INSPECTOR, PermitState.INSPECTION, PermitState.ISSUED, True),
])
def test_transition_matrix(role, src, dst, expected):
    assert may_transition(role, src, dst) is expected


def test_ssn_is_masked_for_clerk():
    rec = PermitRecord(permit_id="P-1", parcel_id="0042", status=PermitState.SUBMITTED,
                       applicant_ssn="123-45-6789", contractor_license="EL-9981")
    view = project_for_role(rec, Role.INTAKE_CLERK)
    assert view["applicant_ssn"] == "***redacted***"
    assert view["contractor_license"] == "EL-9981"  # clerk IS cleared for license


def test_audit_chain_detects_tampering():
    h0 = audit("[email protected]", "read", "/permits/P-1", "allow", prev_hash="GENESIS")
    h1 = audit("[email protected]", "write", "/permits/P-1", "allow", prev_hash=h0)
    assert h1 != h0  # each entry chains to the previous

A passing matrix test plus a masking test gives you a regression net: change a transition table or a clearance tag and the suite tells you exactly which access boundary moved.

Permalink to this section Integration Notes

This access layer sits at the center of the architecture and touches almost every other subsystem. When zoning eligibility is evaluated, the role check must let the system consult spatial layers without exposing raw geometry to unauthorized actors — coordinate clearance tags with mapping municipal zoning overlays to GIS data. Permission rules that reference code provisions should read from the same versioned code sets used when cross-referencing state and local building codes, so a clerk’s authority tracks the correct year’s ordinance. Upstream, the records that this layer guards are produced by the companion automated permit ingestion and parsing workflows track; failures in that pipeline should route through the shared error handling and retry logic for ingestion pipelines rather than dropping records into an unaudited state.

Permalink to this section Frequently Asked Questions

Permalink to this section Should roles map to job titles or to permit lifecycle stages?

Map them to lifecycle stages. Job titles change with the org chart, but the transitions a record can undergo are defined by ordinance. Encoding (source_state, dest_state) → allowed_roles as a table keeps the rule defensible in a records dispute and makes an ordinance change a reviewable data change rather than a code rewrite.

Permalink to this section Is enforcing access in the API layer enough, or do I need UI checks too?

Enforce at the API/middleware boundary first — that is the authoritative gate, and hiding a button in the UI without a server check is the most common cause of silent over-exposure. UI checks are still worth adding for usability (don’t show actions a user can’t take), but they are never the security boundary.

Permalink to this section How do I prevent a denied permit from leaking sensitive fields in the response?

Drive field masking from the schema with a clearance tag on each sensitive attribute, and default untagged fields to the highest tier so new fields fail closed. The serializer projects each record per role before it is ever sent, so a 200 response carries only the attributes that role is cleared to read.

Permalink to this section What needs to be logged to satisfy a public-records audit?

Every permission evaluation and lifecycle transition: timestamp, actor, resource, resolved scopes, and the allow/deny decision. Hash-chain the entries so tampering is detectable, write them to an append-only sink, and redact raw tokens and certificate serials at ingestion so the trail itself is not a leak.

Permalink to this section How do I update permission rules without downtime?

Keep the policy as data (a model file plus a policy file) and reload it into a fresh evaluator, then atomically swap the reference. If the new policy fails to parse, keep the previous evaluator and raise a high-severity alert — active clerk sessions never observe a half-loaded matrix, and a bad deploy can never open the gates.