Security & Access Boundaries for CMMS Routing
Security and access boundaries are the authorization stage of the CMMS Architecture & Maintenance Taxonomy domain — the gate that decides whether a validated work order is allowed to reach a technician at all before the routing engine dispatches it.
When that gate is missing, the failure is not abstract. A high-voltage corrective job lands with an unlicensed contractor, a confined-space preventive task is dispatched without the lockout/tagout authorization the site requires, and an auditor later asks for an evidence trail that the pipeline never recorded. Treating authorization as a typed, deterministic check — one that maps every work order’s role, zone, and certification claims against a versioned access matrix and either permits the job or routes it to human triage — replaces that exposure with a decision that is reproducible and provable. This guide implements that stage end to end: prerequisites, the input/output data contract, a step-by-step Python build, a configuration reference, validation checks, and the failure modes you will actually hit in production.
Prerequisites
The boundary check runs as a synchronous gate invoked by the routing engine the moment a work order passes schema validation and asset resolution, immediately before dispatch. Before you deploy it, confirm the following are in place.
- Python 3.11+ with
pydantic>=2.6for schema enforcement on the access matrix,requests>=2.31for reading credentials over REST, and the standard-librarydataclasses,enum, anddatetimemodules. No background scheduler is required; the validator is driven inline by the routing stage. - CMMS REST API v1 with read access to the credential resource (
GET /api/v1/credentials) and the access-rule resource (GET /api/v1/access/boundaries). The API must return each technician’s active certifications and zone grants with their expiry timestamps so the matrix can be rebuilt deterministically on every sync. - A resolved asset registry. Authorization is only meaningful once external equipment tags map to canonical asset nodes, which is the job of asset hierarchy design upstream; the boundary matrix is keyed on those same canonical asset IDs.
- A versioned work order schema. The payload entering this gate must already satisfy work order schema standards, so the validator never has to defend against malformed input — only against unauthorized input.
- Environment variables:
CMMS_BASE_URL,CMMS_API_TOKEN,ACCESS_MATRIX_VERSION, andBOUNDARY_FAIL_MODE(defaultclosed). The token must carry thecredentials:readandaccess:readscopes; a token missing either scope fails closed at matrix load rather than silently authorizing every job.
Architecture and Data Contract
The boundary check sits between work order validation and dispatch. It never ingests raw maintenance signals directly — it consumes an already-validated WorkOrderPayload that references a canonical asset, evaluates the payload against a precomputed access matrix, and emits a typed AccessDecision that the routing engine either acts on or escalates. The gate is stateless: identical inputs against the same matrix version always yield the same decision, regardless of load or transient state.
Four boundaries keep the authorization honest and stop dispatch concerns from leaking into the check:
- Ingestion boundary: a payload reaches the validator only after it has passed schema validation and asset resolution; the gate assumes a well-formed payload and defends only against authorization failures.
- Determinism boundary: the check reads from an in-memory matrix snapshot pinned to a version string; it performs no live network call mid-evaluation, so a credential that expires between two triggers cannot make the same payload pass once and fail once within a single matrix generation.
- Fail-closed boundary: any condition the gate cannot evaluate — an undefined asset boundary, an unreachable credential service, a missing matrix version — denies the job and sends it to triage rather than defaulting to permissive routing.
- Audit boundary: every decision, permit or deny, is emitted as an immutable record carrying the payload identity, the matrix version, and the exact violations, so a regulatory review can reconstruct why any job was or was not dispatched.
The data contract is narrow by design. The input is the canonical WorkOrderPayload; the output is an AccessDecision:
| Direction | Type | Key fields |
|---|---|---|
| Input | WorkOrderPayload |
work_order_id, asset_id, requested_role, zone, certifications, priority, requested_completion, escalation_tier |
| Output | AccessDecision |
work_order_id, permitted, violations, matrix_version, evaluated_at |
Step-by-Step Implementation
1. Define the canonical work order payload
Every stage in the pipeline shares one immutable payload so the gate never re-defines the contract. The frozen=True dataclass prevents accidental mutation in transit, and the SLA fields — priority, requested_completion, and escalation_tier — travel with the job so the boundary can enforce an escalation ceiling, not just role and zone.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import FrozenSet, Optional
class MaintenanceScope(str, Enum):
CORRECTIVE = "corrective"
PREVENTIVE = "preventive"
class AccessLevel(Enum):
RESTRICTED = "restricted"
CERTIFIED = "certified"
UNRESTRICTED = "unrestricted"
@dataclass(frozen=True)
class WorkOrderPayload:
work_order_id: str
asset_id: str
requested_role: str
zone: str
scope: MaintenanceScope
priority: int # 1 (highest) .. 5 (lowest)
requested_completion: datetime # SLA due timestamp, timezone-aware UTC
escalation_tier: int = 0 # 0 = none .. 5 = executive
certifications: FrozenSet[str] = field(default_factory=frozenset)
pm_trigger_source: Optional[str] = None
2. Model the access boundary and the decision it produces
A boundary is the set of conditions an asset demands of whoever works it. The decision is the immutable verdict the gate returns. Keeping violations as an ordered tuple makes the record reproducible and directly serializable into the audit log.
class RoutingError(Exception):
"""Raised when a work order cannot be authorized and must fail closed."""
@dataclass(frozen=True)
class AccessBoundary:
allowed_roles: FrozenSet[str]
required_certifications: FrozenSet[str]
permitted_zones: FrozenSet[str]
min_access_level: AccessLevel
max_escalation_tier: int = 5
@dataclass(frozen=True)
class AccessDecision:
work_order_id: str
asset_id: str
permitted: bool
violations: tuple[str, ...]
matrix_version: str
evaluated_at: datetime
3. Build the boundary validator
The validator evaluates the payload against the boundary for its target asset and accumulates every violation rather than short-circuiting on the first, so an operator sees the full reason a job was denied. An asset with no defined boundary is not a silent pass — it is a fail-closed RoutingError.
import logging
logger = logging.getLogger(__name__)
class RoutingAccessValidator:
def __init__(
self,
boundary_matrix: dict[str, AccessBoundary],
matrix_version: str,
) -> None:
self._matrix = boundary_matrix
self._version = matrix_version
def evaluate(self, payload: WorkOrderPayload) -> AccessDecision:
now = datetime.now(timezone.utc)
boundary = self._matrix.get(payload.asset_id)
if boundary is None:
# Undefined boundary is treated as deny, never as allow.
raise RoutingError(
f"No access boundary defined for asset {payload.asset_id}"
)
violations: list[str] = []
if payload.requested_role not in boundary.allowed_roles:
violations.append(
f"role '{payload.requested_role}' not permitted for {payload.asset_id}"
)
if payload.zone not in boundary.permitted_zones:
violations.append(
f"zone '{payload.zone}' outside permitted zones"
)
missing = boundary.required_certifications - payload.certifications
if missing:
violations.append(
"missing certifications: " + ", ".join(sorted(missing))
)
if payload.escalation_tier > boundary.max_escalation_tier:
violations.append(
f"escalation tier {payload.escalation_tier} exceeds "
f"ceiling {boundary.max_escalation_tier}"
)
decision = AccessDecision(
work_order_id=payload.work_order_id,
asset_id=payload.asset_id,
permitted=not violations,
violations=tuple(violations),
matrix_version=self._version,
evaluated_at=now,
)
if decision.permitted:
logger.info(
"access granted | WO %s asset:%s role:%s zone:%s",
payload.work_order_id, payload.asset_id,
payload.requested_role, payload.zone,
)
else:
logger.warning(
"access denied | WO %s asset:%s reasons:%s",
payload.work_order_id, payload.asset_id,
"; ".join(decision.violations),
)
return decision
4. Wire the gate into routing with a fail-closed circuit breaker
The validator is only as safe as the matrix behind it. If the credential synchronization service is unreachable, the gate must refuse to dispatch rather than fall back to a stale or empty matrix. The dispatch wrapper below records every decision to an append-only audit sink and converts a denial into an escalation to human triage — never a silent drop. This is where the role-based access control for maintenance teams rules become live policy: the synced matrix is the runtime projection of those role grants.
from typing import Callable, Protocol
class AuditSink(Protocol):
def write(self, decision: AccessDecision) -> None: ...
def authorize_and_route(
payload: WorkOrderPayload,
validator: RoutingAccessValidator,
audit: AuditSink,
dispatch: Callable[[WorkOrderPayload], None],
escalate: Callable[[WorkOrderPayload, AccessDecision], None],
fail_mode: str = "closed",
) -> AccessDecision:
try:
decision = validator.evaluate(payload)
except RoutingError as exc:
if fail_mode != "closed":
raise
# Synthesize a deny so the failure is still audited, then escalate.
decision = AccessDecision(
work_order_id=payload.work_order_id,
asset_id=payload.asset_id,
permitted=False,
violations=(f"fail-closed: {exc}",),
matrix_version="unavailable",
evaluated_at=datetime.now(timezone.utc),
)
audit.write(decision)
if decision.permitted:
dispatch(payload)
else:
escalate(payload, decision)
return decision
Configuration Reference
Keep every authorization tunable in a version-controlled configuration registry, not in the validator source. The defaults below are conservative starting points for a multi-trade facility.
| Parameter | Accepted values | Default | CMMS-specific notes |
|---|---|---|---|
boundary_fail_mode |
closed, open |
closed |
closed denies and escalates any job the gate cannot evaluate; open is for sandbox only and must never reach production. |
matrix_version |
semver string | required | Pins the in-memory matrix snapshot; every AccessDecision records it so audits can replay the exact ruleset in force. |
credential_sync |
webhook, poll |
webhook |
Webhook-driven sync invalidates a revoked badge immediately; polling leaves a window where a pulled credential still authorizes work. |
sync_cadence_seconds |
30–3600 |
300 |
Poll interval when credential_sync=poll; ignored under webhook sync. |
max_escalation_tier |
1–5 |
5 |
Upper bound enforced per asset; a job above an asset’s ceiling is denied even with a valid role. |
cert_grace_seconds |
0–86400 |
0 |
Grace window after a certification’s expiry; keep 0 for safety-critical trades so an expired license fails immediately. |
audit_sink |
append_log, event_stream |
append_log |
Both must be write-once; a mutable audit store fails regulatory review regardless of content. |
Validation and Testing
Authorization must be reproducible, so the highest-value test asserts that the same payload against the same matrix version always yields the same decision, and that each boundary dimension denies independently. A single deterministic assertion catches accidental nondeterminism — an unstable violation order, a clock-dependent default — before it reaches production.
def _matrix() -> RoutingAccessValidator:
boundary = AccessBoundary(
allowed_roles=frozenset({"HV_ELECTRICIAN"}),
required_certifications=frozenset({"HV_LICENSE", "LOTO"}),
permitted_zones=frozenset({"ZONE-E"}),
min_access_level=AccessLevel.CERTIFIED,
max_escalation_tier=3,
)
return RoutingAccessValidator({"SWGR-204": boundary}, matrix_version="2026.06.1")
def test_decision_is_deterministic_and_denies_per_dimension():
validator = _matrix()
payload = WorkOrderPayload(
work_order_id="WO-100042",
asset_id="SWGR-204",
requested_role="HV_ELECTRICIAN",
zone="ZONE-E",
scope=MaintenanceScope.CORRECTIVE,
priority=1,
requested_completion=datetime(2026, 6, 29, tzinfo=timezone.utc),
escalation_tier=1,
certifications=frozenset({"HV_LICENSE", "LOTO"}),
)
a = validator.evaluate(payload)
b = validator.evaluate(payload)
assert a == b
assert a.permitted is True
assert a.matrix_version == "2026.06.1"
# Strip one certification: the job must be denied with a precise reason.
unqualified = WorkOrderPayload(**{**payload.__dict__,
"certifications": frozenset({"HV_LICENSE"})})
denied = validator.evaluate(unqualified)
assert denied.permitted is False
assert denied.violations == ("missing certifications: LOTO",)
On a granted job the gate emits one structured line — access granted | WO WO-100042 asset:SWGR-204 role:HV_ELECTRICIAN zone:ZONE-E — which is the canonical signal a payload cleared authorization. A denial produces access denied | WO ... reasons:...; seeing that line spike after a credential sync points at expired or revoked grants, not a code bug. Assert against both log lines in integration tests to verify the full validate-to-decision path.
Failure Modes and Troubleshooting
Expand each scenario for the root cause, the diagnostic log excerpt, and the fix. The checklist items render as interactive checkboxes — work through them in order.
Every job is denied and escalated to triage
A revoked badge still authorizes work
A qualified technician is denied a job they should hold
Audit review cannot reconstruct why a job was dispatched
Frequently Asked Questions
Should the boundary check call the credential service live on every job?
No. Read credentials on a sync cadence into an in-memory matrix pinned to a version string, and evaluate against that snapshot. A live call mid-evaluation makes the same payload pass once and fail once depending on timing, which breaks determinism and makes audits impossible to replay. Invalidate the snapshot on a revocation webhook so changes take effect on the next trigger.
What is the difference between fail-closed and fail-open here?
Fail-closed means any condition the gate cannot evaluate — an undefined boundary, an unreachable credential service — denies the job and routes it to human triage. Fail-open would dispatch it anyway. For maintenance routing, fail-open can put unqualified hands on energized or confined-space equipment, so boundary_fail_mode must stay closed in production and open is reserved for sandboxes.
How does this gate relate to the asset hierarchy and the work order schema?
The schema guarantees the payload is well-formed before it arrives, so the gate only defends against unauthorized input; the hierarchy resolves the canonical asset_id the boundary matrix is keyed on. If either upstream stage drifts, the gate denies correctly formed but mis-targeted jobs — which is the safe failure, but it surfaces as a spike in triage volume.
Why audit granted jobs and not just denials?
A dispatched job that injures someone or violates a permit is the event an auditor cares about most, and you cannot explain it after the fact unless the grant was recorded with its matrix version and evaluated role. Auditing only denials leaves every job that was actually performed unexplained, which is the compliance gap regulators flag first.
Related
Authorize against the schema enforced by work order schema standards, key the boundary matrix on the canonical nodes from asset hierarchy design, synchronize role grants and certification expiry with role-based access control for maintenance teams, and gate time-based jobs on the cadence set by PM interval calculation.
Part of: CMMS Architecture & Maintenance Taxonomy.