Debugging RBAC 403 Denials in CMMS Preventive Maintenance Routing

Automated preventive maintenance (PM) routing pipelines fail at dispatch when role-based access control (RBAC) boundaries do not line up with the asset tree the work order targets. The classic symptom: a Python integration service account that creates work orders all day suddenly receives a 403 Forbidden the moment it tries to route a PM across a site boundary. The root cause almost always lives in the security and access boundaries stage of the CMMS Architecture & Maintenance Taxonomy layer, where a role grant fails to inherit down to the exact node the routing action executes on. This guide isolates that failure, fixes it, and gives you a runnable simulator to prove the fix before it touches production.

Incident Profile

When the routing engine evaluates a PM schedule, it checks the caller’s role against the target asset’s security group at the precise node named in the payload — not at the PM schedule’s origin node. If the service account lacks the routing privilege at that node, the API rejects the dispatch. The following trace captures the exact failure during a scheduled PM run:

2026-05-14T08:12:03Z [CMMS-API] POST /api/v1/workorders/route
2026-05-14T08:12:03Z [AUTH] Token validated: svc_pm_router@prod
2026-05-14T08:12:03Z [RBAC] Evaluating role: integration_pm_dispatcher
2026-05-14T08:12:03Z [RBAC] Checking scope: pm:route:execute on asset_id: HVAC-CH-04
2026-05-14T08:12:03Z [RBAC] Scope DENIED. Missing inheritance from parent site: SITE-BLDG-01
2026-05-14T08:12:03Z [RESPONSE] HTTP 403 {"error": "insufficient_permissions", "required_scope": "pm:route:execute", "context": "cross_hierarchy_routing"}

The give-away is the pairing of required_scope: pm:route:execute with context: cross_hierarchy_routing. The token authenticated cleanly and the role resolved — the denial is authorization, not authentication. The integration_pm_dispatcher role holds workorder:create but not the routing privilege needed to assign labor and push a PM down to a child asset, and the scope it does need is not inherited from the parent site SITE-BLDG-01.

Root Cause Analysis

CMMS platforms enforce RBAC at three layers: the user or service account, the role definition, and the asset security group. A routing action only succeeds when the required scope is present on the role and that scope resolves on the target node’s security boundary. Three independent gaps produce the same 403:

  1. Token claim gap. The service account token never carried the pm:route:execute claim — usually a stale OAuth2 scope set or an identity-provider mapping that grants workorder:create but not routing.
  2. Role-to-security-group decoupling. The role exists but is not bound to the parent site’s Maintenance_Routing security group, so the inheritance chain that the canonical nodes from asset hierarchy design rely on never reaches the child asset.
  3. Inheritance disabled on the action. Asset-hierarchy inheritance is explicitly turned off for pm:route in the policy engine to block privilege escalation across tenant or site boundaries — a safe default that silently denies legitimate cross-site PM routing.

Integration pipelines routinely assume top-down inheritance is automatic. Enterprise CMMS platforms evaluate the exact asset_id in the payload, so a PM authored at the system level but dispatched to a site-specific asset is checked at the child node, where the grant was never propagated. Shared assets, dynamically provisioned equipment, and child nodes with overridden local policies all amplify this.

RBAC scope resolution across the asset hierarchy: denied versus permitted A service account token and the integration_pm_dispatcher role both feed a policy engine that resolves the required scope at the exact target asset_id. Two paths walk the same asset hierarchy SITE-BLDG-01 then BLDG-A then HVAC-System then HVAC-CH-04. On the left, pm:route:execute is granted at SITE-BLDG-01 but the action is not inheritable, so the grant is stranded at the site root, never reaches the child chiller, and the routing call returns HTTP 403 Forbidden with context cross_hierarchy_routing. On the right, the same grant is flagged inheritable, propagates down every node, resolves at HVAC-CH-04, and the route is permitted. SERVICE ACCOUNT TOKEN svc_pm_router@prod workorder:create · pm:route:execute ROLE integration_pm_dispatcher bound to a security group POLICY ENGINE resolve scope at exact asset_id required: pm:route:execute INHERITANCE OFF → DENIED INHERITANCE ON → PERMITTED grant stranded at site root inherits inherits inherits SITE-BLDG-01 ✓ grant pm:route:execute BLDG-A no resolved scope HVAC-System no resolved scope HVAC-CH-04 ✗ scope not resolved SITE-BLDG-01 ✓ grant pm:route:execute BLDG-A ✓ inherited HVAC-System ✓ inherited HVAC-CH-04 ✓ pm:route:execute resolved HTTP 403 Forbidden context=cross_hierarchy_routing Routed · permit WO-PM-8842 → HVAC-CH-04

Resolution

The fix has two halves: grant the missing scope and let it inherit to the target node. The before/after below is the routing pre-flight check that the pipeline runs before calling the API. The “before” version trusts that creation rights imply routing rights; the “after” version checks the actual routing scope at the resolved node and refuses to dispatch when it is absent — failing closed rather than firing a request guaranteed to 403.

# BEFORE — assumes workorder:create implies routing, never inspects the target node
def can_route(role_scopes: set[str]) -> bool:
    # Bug: routing is gated on creation, and the asset node is never consulted,
    # so a cross-site PM passes this check and then 403s at the API.
    return "workorder:create" in role_scopes
# AFTER — checks the routing scope AND that it resolves on the target node
def can_route(
    role_scopes: set[str],
    resolved_scopes_at_node: set[str],   # scopes that actually inherit to asset_id
) -> bool:
    # 1. The role itself must carry the routing privilege, not just creation rights.
    if "pm:route:execute" not in role_scopes:
        return False
    # 2. That privilege must inherit all the way down to the node being routed to;
    #    a grant stranded at the parent site does not authorize the child asset.
    return "pm:route:execute" in resolved_scopes_at_node

To make resolved_scopes_at_node actually contain the scope, apply three configuration changes on the CMMS side:

  1. Issue the scope. Update the identity provider so the client-credentials flow assigns pm:route:execute (alongside pm:dispatch) to the service account.
  2. Bind the role to the parent security group. Attach integration_pm_dispatcher to the SITE-BLDG-01 Maintenance_Routing group and enable “propagate to child assets.”
  3. Enable inheritance for the action. Turn on hierarchy inheritance for pm:route and pm:dispatch so the grant resolves at HVAC-CH-04, not just at the site root.

Minimal Reproducible Pipeline

The script below reproduces the 403 and then verifies the fix without touching a live CMMS. It carries the canonical WorkOrderPayload — including the SLA fields priority, requested_completion, and escalation_tier — so it drops straight into an existing pipeline without redefining the contract, and models the policy engine’s node-level scope resolution exactly. Run it as-is: the first evaluation denies, the second permits.

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"


@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


class RoutingDenied(Exception):
    """Raised when the routing scope does not resolve on the target node."""


# Asset path from site root down to the equipment being routed to.
ASSET_PATH = ["SITE-BLDG-01", "BLDG-A", "HVAC-System", "HVAC-CH-04"]


def resolve_scopes_at_node(
    asset_id: str,
    grants: dict[str, set[str]],
    inheritable_actions: set[str],
) -> set[str]:
    """Mirror the CMMS policy engine: walk the path to asset_id and accumulate
    only the grants whose action is flagged inheritable along the way."""
    resolved: set[str] = set()
    for node in ASSET_PATH:
        for scope in grants.get(node, set()):
            action = scope.rsplit(":", 1)[0]          # 'pm:route' from 'pm:route:execute'
            if node == asset_id or action in inheritable_actions:
                resolved.add(scope)
        if node == asset_id:
            break
    return resolved


def route_pm(
    payload: WorkOrderPayload,
    role_scopes: set[str],
    grants: dict[str, set[str]],
    inheritable_actions: set[str],
) -> str:
    resolved = resolve_scopes_at_node(payload.asset_id, grants, inheritable_actions)
    if "pm:route:execute" not in role_scopes:
        raise RoutingDenied("role missing pm:route:execute scope")
    if "pm:route:execute" not in resolved:
        raise RoutingDenied(
            f"pm:route:execute not inherited at {payload.asset_id} "
            f"(context=cross_hierarchy_routing)"
        )
    return f"routed {payload.work_order_id} -> {payload.asset_id}"


pm = WorkOrderPayload(
    work_order_id="WO-PM-8842",
    asset_id="HVAC-CH-04",
    requested_role="integration_pm_dispatcher",
    zone="ZONE-A",
    scope=MaintenanceScope.PREVENTIVE,
    priority=3,
    requested_completion=datetime(2026, 6, 30, tzinfo=timezone.utc),
    escalation_tier=0,
    pm_trigger_source="PM-2026-05A",
)

# --- Reproduce the 403: role has the scope, granted at the site, but pm:route
#     is not inheritable, so it never resolves at the child node ---
broken_role = {"workorder:create", "pm:route:execute"}
broken_grants = {"SITE-BLDG-01": {"pm:route:execute"}}
try:
    route_pm(pm, broken_role, broken_grants, inheritable_actions=set())
except RoutingDenied as exc:
    print("DENIED (expected):", exc)

# --- Apply the fix: role carries the scope AND pm:route is inheritable ---
fixed_role = {"workorder:create", "pm:route:execute", "pm:dispatch"}
fixed_grants = {"SITE-BLDG-01": {"pm:route:execute", "pm:dispatch"}}
print(route_pm(pm, fixed_role, fixed_grants, inheritable_actions={"pm:route", "pm:dispatch"}))

Expected output:

DENIED (expected): pm:route:execute not inherited at HVAC-CH-04 (context=cross_hierarchy_routing)
routed WO-PM-8842 -> HVAC-CH-04

The first call fails exactly as production does — the grant is stranded at the site root because pm:route is not inheritable. The second call succeeds once the role carries the scope and the action is allowed to propagate to the child node.

Prevention Checklist

Work through these before the next scheduling window to keep cross-site PM routing from regressing.

Frequently Asked Questions

Why does work order creation succeed but routing return 403?

Creation and routing are separate scopes. workorder:create lets the account write a work order record; pm:route:execute lets it assign labor and push the job to a specific asset. A role can hold the first without the second, which is exactly the gap this incident exposes. Treat routing as its own privilege and check it independently.

Why is the scope present at the site but still denied at the asset?

The policy engine evaluates the exact asset_id in the payload, not the PM schedule’s origin. A grant attached to SITE-BLDG-01 only reaches HVAC-CH-04 if pm:route is flagged inheritable and the role is bound to a security group that propagates to child assets. Without inheritance the grant is stranded at the parent and the child node denies.

Should I just enable inheritance for every action to avoid 403s?

No. Inheritance is disabled on pm:route by default specifically to stop privilege escalation across site or tenant boundaries. Enable it only for the routing and dispatch actions on the specific groups that need it, and keep destructive or administrative scopes non-inheritable. Pair the change with the audit trail described in security and access boundaries so every grant is reviewable.

How is this different from a schema or asset-resolution failure?

A 403 with insufficient_permissions is authorization — the payload was well-formed and the asset resolved, but the caller lacked the right. A malformed payload surfaces earlier as a 400 from work order schema standards, and a mis-parented node surfaces as the gate pointing at the wrong boundary. Read the error code and context field first to route your debugging.

Set the runtime authorization model this debugging targets in security and access boundaries, key inheritance on the canonical nodes from asset hierarchy design, validate the payload upstream with JSON schema validation for work order payloads, and align the schedules being routed with PM interval calculation.

Part of: Security & Access Boundaries for CMMS Routing.