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.
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 arole(or group) claim. Do not trust a role claim that the client could mint; bind roles server-side keyed onsubwhere 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"])raisesValueError; 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
leewayinjwt.decodeand monitor host time sync. - Policy file fails to load. A malformed
rbac_policy.csvdeploy 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.
Permalink to this section Related
- Securing municipal API endpoints for third-party integrations — extend these scopes to external consumers.
- Designing JSON schemas for building permits — the typed records this layer masks and guards.
- Mapping municipal zoning overlays to GIS data — clearance-aware access to spatial layers.
- Building fallback routing for legacy system downtime — graceful degradation when the datastore is unavailable.
- Core Architecture & Code Taxonomy for Municipal Permits — the parent track tying these subsystems together.