How to Implement Role-Based Access Control for Slotting Configs
An unscoped write to a velocity threshold or a fallback-routing rule does not fail loudly — it silently re-ranks thousands of SKUs and floods material handlers with relocation tasks on the next optimizer pass. This page solves one precise problem: how to gate every slotting-configuration write behind a role-and-scope check at the ingestion boundary, before the payload reaches the persistence layer or the constraint solver. It is a hands-on implementation companion to Enforcing Role- and Zone-Scoped Access Boundaries for Slotting, which frames the three policy axes; here we build the runnable Python that enforces them.
Prerequisites
Before wiring the decorator in, confirm you have:
- Python 3.11+ — the code uses
dataclasses,X | Noneunions, anddatetime.now(timezone.utc). - A resolved actor identity — an authenticated principal carrying
id,role, andzone_scopeclaims (from your API gateway, JWT, or service-mesh sidecar). RBAC assumes authentication already happened; it only authorizes. - A canonical role list agreed with operations — this guide uses
slotting_admin,velocity_analyst,floor_operator, andaudit_compliance. - A zone identifier on every config payload that resolves against the location graph in Location Hierarchy Mapping, so spatial scope can be checked, not assumed.
- A durable checkpoint/version store (Postgres row version, or a Redis
WATCH/MULTIkey) for the optimistic-locking guard. pytest>=8.0for the validation checks in the last section.
Configuration Block
Externalize the role-to-operation matrix and the guard limits so operations can re-scope a role without a code deploy. The YAML below is the source of truth; the equivalent Python dict is what the enforcement layer loads at startup.
# slotting_rbac.yaml — one policy file per facility, version-controlled
rbac:
matrix: # role -> set of permitted operations
slotting_admin: [create_zone, update_velocity, delete_slot, override_fallback]
velocity_analyst: [read_velocity, update_velocity, export_logs]
floor_operator: [read_slot, temp_override]
audit_compliance: [read_all, export_logs]
guards:
temp_override_max_ttl_s: 3600 # floor overrides self-expire within one shift-hour
require_zone_scope: true # deny cross-zone writes even if the role allows the action
enforce_version: true # reject stale-version payloads (optimistic lock)
fallback:
stream: "slotting:override:staging" # Redis stream for admin-approval routing
max_pending: 200 # backpressure ceiling on the approval queue
# Equivalent Python config dict loaded at service start
SLOT_RBAC = {
"matrix": {
"slotting_admin": {"create_zone", "update_velocity", "delete_slot", "override_fallback"},
"velocity_analyst": {"read_velocity", "update_velocity", "export_logs"},
"floor_operator": {"read_slot", "temp_override"},
"audit_compliance": {"read_all", "export_logs"},
},
"guards": {"temp_override_max_ttl_s": 3600, "require_zone_scope": True, "enforce_version": True},
"fallback": {"stream": "slotting:override:staging", "max_pending": 200},
}
Each role maps to the operational reality of the SKU Velocity Taxonomy Design layer: a velocity_analyst may rewrite tier assignments (update_velocity) but never restructure zones, while a floor_operator may only stamp short-lived overrides that expire inside a shift.
Implementation
The core is a declarative decorator that resolves the actor against the matrix, checks zone scope, enforces the TTL and version guards, and injects an audit record — rejecting the request before func (the real persistence call) ever runs.
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from functools import wraps
from typing import Any, Callable
logger = logging.getLogger("slotting.rbac")
@dataclass(frozen=True)
class Actor:
"""Authenticated principal; authentication happens upstream, this only authorizes."""
id: str
role: str
zone_scope: str | None = None
class ScopeError(PermissionError):
"""Raised when action-scope or spatial-scope resolution denies a request."""
def enforce_slotting_rbac(
required_ops: set[str], cfg: dict[str, Any], zone_locked: bool = True
) -> Callable:
"""Gate a slotting-config write behind role, zone, TTL, and version checks."""
def decorator(func: Callable) -> Callable:
@wraps(func) # preserve metadata so WMS API tracing keeps the real name
def wrapper(actor: Actor, payload: dict[str, Any], *args, **kwargs):
allowed = cfg["matrix"].get(actor.role, set())
missing = required_ops - allowed
if missing:
logger.warning("RBAC DENY actor=%s role=%s missing=%s", actor.id, actor.role, missing)
raise ScopeError(f"role '{actor.role}' lacks {missing}")
if zone_locked and cfg["guards"]["require_zone_scope"]:
if actor.zone_scope != payload.get("zone_id"):
logger.warning("SCOPE DENY actor=%s zone=%s", actor.id, payload.get("zone_id"))
raise ScopeError("zone-scope mismatch")
if "temp_override" in required_ops:
ttl = payload.get("ttl_seconds")
if not ttl or ttl > cfg["guards"]["temp_override_max_ttl_s"]:
raise ValueError(f"temp_override needs ttl <= {cfg['guards']['temp_override_max_ttl_s']}s")
payload["expires_at"] = datetime.now(timezone.utc).timestamp() + ttl
if cfg["guards"]["enforce_version"] and "version" in payload:
if payload["version"] < kwargs.get("db_version", payload["version"]):
raise ScopeError("stale payload version rejected (optimistic lock)")
payload["_audit"] = {
"actor_id": actor.id, "role": actor.role,
"ops": sorted(required_ops),
"ts": datetime.now(timezone.utc).isoformat(),
}
logger.info("RBAC GRANT actor=%s ops=%s zone=%s", actor.id, required_ops, payload.get("zone_id"))
return func(actor, payload, *args, **kwargs)
return wrapper
return decorator
Step-by-Step Walkthrough
- Resolve the action scope.
required_ops - allowedcomputes exactly which permissions the caller is missing against thematrixfrom config. An empty difference means every requested operation is permitted; anything left over raisesScopeErrorbefore any I/O. Declaringrequired_opsat the decorator site keeps the authorization contract next to the endpoint it protects. - Check the spatial scope. When
require_zone_scopeis on, the actor’szone_scopeclaim must equal the payload’szone_id. This is what stops afloor_operatorwho ownsZONE_Afrom rewriting parameters inCOLD_STORAGE, even though their role technically permitstemp_overrideelsewhere. - Bound temporary overrides in time. Any
temp_overridemust carry attl_secondsat or undertemp_override_max_ttl_s(3600s). The guard stampsexpires_atso a background reaper can revert congestion hacks automatically — overrides never silently become permanent. - Reject stale writes. With
enforce_versionon, a payload whoseversionis older than the currentdb_versionis refused. This optimistic lock prevents a stale cached config from clobbering a concurrent peak-shift edit and corrupting live pick paths. - Inject the audit trail. Every granted write carries a
_auditrecord (actor, role, operations, UTC timestamp) so theaudit_compliancerole can export a machine-readable change log.@wrapskeepsfunc.__name__intact so this record ties back to the real handler in traces.
For an override_fallback the caller lacks (a floor_operator needing an admin-only change during an equipment failure), do not return a hard denial. Enqueue the payload to the Redis fallback.stream and let an elevated worker evaluate it against real-time pick-density before applying — a graceful degradation path instead of a blocked shift.
Verification
Assert the three invariants that matter — permitted ops pass, unscoped ops are denied, and an over-long TTL is rejected — before shipping. These run in the config service’s CI gate.
import pytest
CFG = SLOT_RBAC # loaded from slotting_rbac.yaml
@enforce_slotting_rbac({"update_velocity"}, CFG)
def _write_velocity(actor: Actor, payload: dict) -> str:
return "written"
def test_permitted_write_passes() -> None:
analyst = Actor(id="u1", role="velocity_analyst", zone_scope="ZONE_A")
assert _write_velocity(analyst, {"zone_id": "ZONE_A", "tier": "A"}) == "written"
def test_role_without_op_is_denied() -> None:
operator = Actor(id="u2", role="floor_operator", zone_scope="ZONE_A")
with pytest.raises(PermissionError):
_write_velocity(operator, {"zone_id": "ZONE_A"})
def test_cross_zone_write_is_denied() -> None:
analyst = Actor(id="u3", role="velocity_analyst", zone_scope="ZONE_A")
with pytest.raises(PermissionError):
_write_velocity(analyst, {"zone_id": "COLD_STORAGE", "tier": "B"})
A healthy run: test_permitted_write_passes returns "written" and logs RBAC GRANT actor=u1 ops={'update_velocity'} zone=ZONE_A, while both denial tests log an RBAC DENY / SCOPE DENY line and raise PermissionError. If the cross-zone test passes silently, require_zone_scope is off or the actor’s zone_scope claim is not populated — fix that before deploying, because it is the guard that contains blast radius.
Common Pitfalls
- Enforcing at the presentation tier only. Hiding a button does not stop a direct API call. The decorator must sit at the config ingestion boundary (gateway or service entry), not in the UI, or malformed payloads still reach the solver.
- Unbounded overrides. Accepting a
temp_overridewithout a TTL — or with a multi-day TTL — turns a congestion hotfix into permanent drift. Always capttl_secondsand run a reaper onexpires_at. - Dropping the version guard under load. Peak-shift concurrency is exactly when two clients race the same zone config; without the optimistic-lock check a stale write wins and re-ranks a zone against yesterday’s velocity.
- Hard-denying fallback-eligible requests. Returning
403to afloor_operatorduring an equipment failure blocks the shift instead of routing to the staging stream. Reserve hard denials for genuine scope violations, not time-sensitive escalations.
FAQ
Where in the stack should the RBAC decorator live?
At the configuration ingestion boundary — the API gateway, service-mesh entry, or the first handler of the slotting config service — never in the presentation tier. Enforcing it upstream guarantees that an unauthorized or malformed payload is rejected before it can touch the persistence layer or the constraint solver, which is the only place the guarantee actually holds.
How is zone scope different from role scope?
Role scope answers what action a caller may perform (read, propose, override, write); zone scope answers where they may perform it. They are orthogonal: a velocity_analyst may hold update_velocity globally, but the spatial-scope check still refuses the write if the payload’s zone_id is outside their zone_scope. Both must pass, and both resolve against the location graph in Location Hierarchy Mapping.
Why route some denials to a fallback queue instead of returning 403?
Because warehouse operations cannot halt for an authorization gap during an equipment failure or congestion event. A floor_operator who needs an admin-only override gets their payload staged to a Redis stream where an elevated worker evaluates it against live pick-density metrics, rather than a blocked shift. Genuine scope violations still fail hard; only time-sensitive, admin-approvable actions degrade gracefully.
How do TTL-bound overrides prevent configuration drift?
Every temp_override must carry a ttl_seconds at or under 3600, and the guard stamps an absolute expires_at. A background reaper reverts the override once that timestamp passes, so a real-time congestion hack can never silently become the permanent slotting rule that quietly degrades pick paths.
Related
- Enforcing Role- and Zone-Scoped Access Boundaries for Slotting — the parent guide framing the three policy axes this decorator enforces.
- Location Hierarchy Mapping — the location graph that spatial-scope checks resolve against.
- SKU Velocity Taxonomy Design — the tier definitions that
update_velocitywrites modify. - Core Slotting Architecture & Velocity Taxonomies — the overarching architecture this control layer protects.