Enforcing Role- and Zone-Scoped Access Boundaries for Slotting
Slotting optimization runs as a continuous control loop that reconciles inventory velocity, physical storage constraints, and pick-path efficiency, and every stage of that loop is a place where an unauthorized or malformed configuration change can corrupt live fulfillment. This guide is part of the Core Slotting Architecture & Velocity Taxonomies system, and it treats access boundaries as first-class, deterministic constraints on the optimizer rather than administrative overhead bolted on afterward. An uncontrolled write to a velocity-decay threshold or an affinity weight does not just log an audit line — it re-ranks thousands of SKUs, floods material handlers with relocation tasks, and can strand this week’s fast movers in reserve. The job of an access boundary is to make sure that only a scoped, validated, and reversible change ever reaches the constraint solver.
What a Slotting Access Boundary Enforces
An access boundary for slotting is a policy-driven middleware layer that intercepts every configuration request before it reaches the optimization solver and resolves it against three orthogonal axes:
- Action scope (role-based). What class of operation the caller may perform — read a rule set, propose a change, approve a zone, override a velocity threshold, or write directly to the live configuration. This is classic RBAC, expressed as a permission matrix mapping roles such as
slotting_analyst,warehouse_ops_lead, andslotting_engineerto sets of allowed actions. - Spatial scope (zone-based). Which physical zones the caller may touch. A floor supervisor who owns
ZONE_Amust not be able to rewrite bulk-storage parameters inCOLD_STORAGEor override hazardous-material segregation rules, even if their role technically permits the action elsewhere. Spatial scope is checked against the location graph defined in Location Hierarchy Mapping. - Temporal / approval scope. Whether a high-impact change may execute immediately or must route through a staged approval chain. A single affinity-weight tweak on one aisle is low blast-radius; a facility-wide velocity reclassification during a peak throughput window is not.
The industry-standard variant of this pattern is attribute-based access control (ABAC), where zone ownership and change magnitude become just more attributes in a policy expression. For most slotting stacks a hybrid is cleaner: coarse RBAC for the action verb, an explicit zone-ownership check for the spatial axis, and a magnitude threshold for the approval axis. The edge case that catches teams out is the system_admin wildcard — a role that bypasses action scope must still be logged and, ideally, still bounded by the approval axis so that no single credential can silently re-slot an entire building.
Input Data Requirements
The boundary layer consumes two records on every request: the authenticated caller context (derived from a verified token) and the slotting configuration payload. Both must be fully typed and validated before any policy check runs — an unvalidated payload lets a type-coerced zone_scope slip past a string equality check. The canonical fields, types, and preconditions are below.
| Field | Type | Source | Precondition |
|---|---|---|---|
user_id |
str |
JWT sub claim |
Non-empty, matches an active identity |
roles |
set[str] |
JWT roles claim |
Subset of the permission matrix keys |
zone_assignments |
set[str] |
Identity service | Every zone exists in the location graph |
token_exp |
float |
JWT exp claim |
Strictly greater than time.time() |
rule_set_id |
str |
Request body | References an existing rule set |
zone_scope |
str |
Request body | Matches the ^[A-Z_]{2,20}$ zone pattern |
velocity_override |
float | None |
Request body | 0.0 ≤ v ≤ 1.0 when present |
affinity_weights |
dict[str, float] | None |
Request body | Weights in [0, 10], keys are known SKU families |
The quality precondition that matters most is that zone_assignments is resolved from an authoritative identity service, not copied from the token claims verbatim — tokens outlive role changes, so a zone the caller lost yesterday must not still be honored today.
Step-by-Step Implementation
The enforcement pipeline is a FastAPI dependency chain: resolve identity, check action scope, check zone scope, then either dispatch to the solver or fan out to an approval chain. Each stage emits a structured audit line so a denied request is diagnosable from logs alone.
1. Model the Permission Matrix and Request Context
Start with strongly typed models and a permission matrix that maps each role to the actions it may perform. Keep the matrix data-driven (loadable from config) so a new role never requires a code deploy. In production the matrix is Redis-backed and refreshed on a short TTL; here it is inlined for clarity.
from __future__ import annotations
import logging
import time
from typing import Dict, Optional, Set
from fastapi import Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
audit_logger = logging.getLogger("slotting.access_audit")
audit_logger.setLevel(logging.INFO)
class SlottingConfigPayload(BaseModel):
rule_set_id: str
zone_scope: str = Field(..., pattern=r"^[A-Z_]{2,20}$")
velocity_override: Optional[float] = Field(None, ge=0.0, le=1.0)
affinity_weights: Optional[Dict[str, float]] = None
class UserContext(BaseModel):
user_id: str
roles: Set[str]
zone_assignments: Set[str]
token_exp: float
# Data-driven RBAC matrix (production: Redis-backed, short TTL).
SLOTTING_PERMISSION_MATRIX: Dict[str, Set[str]] = {
"slotting_analyst": {"read", "propose"},
"warehouse_ops_lead": {"read", "propose", "approve_zone"},
"slotting_engineer": {"read", "propose", "approve_zone", "override_velocity", "write"},
"system_admin": {"*"},
}
2. Resolve and Verify the Caller’s Identity
Every request carries a bearer token. Reject anything without a well-formed Authorization header before touching the payload, and verify the token has not expired against the server clock. In production this function decodes the JWT, verifies the signature against your JWKS, and hydrates zone_assignments from the identity service rather than trusting the claim blindly.
async def get_current_user(request: Request) -> UserContext:
"""Extract and validate JWT claims, then hydrate zone assignments."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
audit_logger.warning("Rejected request with missing bearer token")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing or invalid token")
# In production: decode + verify signature, then resolve zones from the
# identity service so revoked assignments are not honored via a stale token.
user = UserContext(
user_id="eng_user_01",
roles={"slotting_engineer"},
zone_assignments={"ZONE_A", "ZONE_B", "COLD_STORAGE"},
token_exp=time.time() + 3600,
)
if user.token_exp <= time.time():
audit_logger.warning("Rejected expired token for user=%s", user.user_id)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token expired")
return user
3. Enforce Action Scope with a Dependency Guard
The action guard is a parameterized FastAPI dependency: pass it the action a route requires, and it verifies at least one of the caller’s roles grants that action. The system_admin wildcard short-circuits the check but is still logged, so no privileged write is ever silent.
def require_slotting_permission(required_action: str):
"""FastAPI dependency enforcing RBAC action scope."""
def dependency(user: UserContext = Depends(get_current_user)) -> UserContext:
if "system_admin" in user.roles:
audit_logger.info(
"Admin override: user=%s action=%s", user.user_id, required_action
)
return user
granted = any(
required_action in SLOTTING_PERMISSION_MATRIX.get(role, set())
for role in user.roles
)
if not granted:
audit_logger.warning(
"Permission denied: user=%s action=%s roles=%s",
user.user_id, required_action, user.roles,
)
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Insufficient privileges for action: {required_action}",
)
return user
return dependency
4. Enforce Zone Scope Against Physical Assignments
Action scope alone is not enough: a slotting_engineer may write, but only within the zones they own. The zone check compares the payload’s zone_scope against the caller’s authoritative assignments and denies cross-zone writes with an audit line that records both the requested and the allowed set.
def validate_zone_scope(payload: SlottingConfigPayload, user: UserContext) -> None:
"""Ensure the requested zone is within the caller's physical authority."""
if payload.zone_scope not in user.zone_assignments:
audit_logger.warning(
"Zone boundary violation: user=%s requested=%s allowed=%s",
user.user_id, payload.zone_scope, user.zone_assignments,
)
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"User lacks authority to modify zone: {payload.zone_scope}",
)
5. Route High-Impact Changes Through Staged Approval
Not every authorized change should execute inline. A facility-wide velocity reclassification or a cross-aisle swap must route through an asynchronous approval chain so it cannot detonate during peak throughput. The magnitude gate classifies a payload by blast radius, and only low-magnitude changes reach the solver directly; the rest are staged for cross-functional sign-off.
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
class ChangeMagnitude(str, Enum):
LOW = "low" # single-zone weight tweak — immediate
HIGH = "high" # velocity override or multi-zone — staged approval
@dataclass
class StagedChange:
rule_set_id: str
zone_scope: str
requested_by: str
magnitude: ChangeMagnitude
approvals: Set[str] = field(default_factory=set)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def is_approved(self, quorum: int) -> bool:
return len(self.approvals) >= quorum
def classify_magnitude(payload: SlottingConfigPayload) -> ChangeMagnitude:
"""A velocity override is always high blast-radius; weight tweaks are low."""
if payload.velocity_override is not None:
return ChangeMagnitude.HIGH
if payload.affinity_weights and len(payload.affinity_weights) > 5:
return ChangeMagnitude.HIGH
return ChangeMagnitude.LOW
6. Wire the Route and Emit an Immutable Audit Trail
The route ties the stages together. Action scope is enforced as a dependency, zone scope and magnitude are checked in the handler, and every accepted change is written to an append-only audit log keyed by a cryptographic nonce so change tracking is deterministic and tamper-evident. The immutable log is what satisfies compliance audits and lets you replay exactly which credential authorized which re-slot.
import secrets
from fastapi import FastAPI
app = FastAPI()
@app.post("/api/v1/slotting/config")
async def update_slotting_rules(
payload: SlottingConfigPayload,
user: UserContext = Depends(require_slotting_permission("write")),
):
validate_zone_scope(payload, user)
magnitude = classify_magnitude(payload)
nonce = secrets.token_hex(16)
if magnitude is ChangeMagnitude.HIGH:
staged = StagedChange(
rule_set_id=payload.rule_set_id,
zone_scope=payload.zone_scope,
requested_by=user.user_id,
magnitude=magnitude,
)
audit_logger.info(
"Change staged for approval: nonce=%s rule_set=%s zone=%s user=%s",
nonce, payload.rule_set_id, payload.zone_scope, user.user_id,
)
return {"status": "pending_approval", "nonce": nonce, "requested_by": staged.requested_by}
audit_logger.info(
"Slotting config accepted: nonce=%s rule_set=%s zone=%s user=%s",
nonce, payload.rule_set_id, payload.zone_scope, user.user_id,
)
return {"status": "queued_for_solver", "nonce": nonce, "payload_id": payload.rule_set_id}
The full production hardening of this enforcement layer — JWKS signature verification, Redis-backed policy caching, context-variable propagation, and schema-driven payload validation — is covered end to end in Implementing role-based access for slotting configs.
Tuning & Calibration
The boundary’s behavior is entirely externalized so token lifetimes, cache freshness, approval quorum, and the magnitude threshold can change without a redeploy. Two knobs dominate: the policy-cache TTL (too long and revoked roles linger; too short and every request hammers Redis) and the approval quorum for high-magnitude changes (too high and legitimate re-slots stall through peak season; too low and the staging gate is theater). Both the YAML and the equivalent Python dict are shown so the config can be loaded from a file or embedded in a settings module.
access_boundary:
token_ttl_seconds: 3600 # JWT lifetime; keep short to bound stale roles
policy_cache_ttl_seconds: 60 # Redis matrix refresh window
zone_pattern: "^[A-Z_]{2,20}$" # accepted zone_scope shape
approval:
high_magnitude_quorum: 2 # distinct approvers required
approval_window_hours: 4 # stage expires if unapproved in window
affinity_weight_fanout: 5 # >N weighted families => HIGH magnitude
audit:
nonce_bytes: 16 # entropy per change record
immutable_sink: "append_only" # WORM store; never in-place update
CONFIG = {
"access_boundary": {
"token_ttl_seconds": 3600,
"policy_cache_ttl_seconds": 60,
"zone_pattern": r"^[A-Z_]{2,20}$",
},
"approval": {
"high_magnitude_quorum": 2,
"approval_window_hours": 4,
"affinity_weight_fanout": 5,
},
"audit": {"nonce_bytes": 16, "immutable_sink": "append_only"},
}
Facility-specific adjustment: single-shift operations with a small slotting team can drop high_magnitude_quorum to 1 and lengthen approval_window_hours, while a 24/7 multi-zone building should raise the quorum and shorten the policy-cache TTL so role changes propagate before the next shift inherits them.
Validation & Testing
Access-control code is exactly where silent regressions hide, so assert the deny paths explicitly, not just the happy path. The checks below exercise action scope, zone scope, and magnitude classification with plain assert statements you can run under pytest.
import pytest
def _ctx(roles, zones):
return UserContext(user_id="t", roles=set(roles), zone_assignments=set(zones),
token_exp=time.time() + 60)
def test_analyst_cannot_write():
guard = require_slotting_permission("write")
with pytest.raises(HTTPException) as exc:
guard(_ctx({"slotting_analyst"}, {"ZONE_A"}))
assert exc.value.status_code == status.HTTP_403_FORBIDDEN
def test_engineer_write_in_owned_zone_passes():
guard = require_slotting_permission("write")
user = guard(_ctx({"slotting_engineer"}, {"ZONE_A"}))
payload = SlottingConfigPayload(rule_set_id="rs1", zone_scope="ZONE_A")
validate_zone_scope(payload, user) # must not raise
def test_engineer_blocked_outside_owned_zone():
user = _ctx({"slotting_engineer"}, {"ZONE_A"})
payload = SlottingConfigPayload(rule_set_id="rs1", zone_scope="COLD_STORAGE")
with pytest.raises(HTTPException) as exc:
validate_zone_scope(payload, user)
assert exc.value.status_code == status.HTTP_403_FORBIDDEN
def test_velocity_override_is_high_magnitude():
payload = SlottingConfigPayload(rule_set_id="rs1", zone_scope="ZONE_A",
velocity_override=0.4)
assert classify_magnitude(payload) is ChangeMagnitude.HIGH
def test_staged_change_needs_quorum():
change = StagedChange("rs1", "ZONE_A", "eng_1", ChangeMagnitude.HIGH)
change.approvals.update({"lead_1"})
assert not change.is_approved(quorum=2)
change.approvals.update({"lead_2"})
assert change.is_approved(quorum=2)
Run these in CI on every change to the matrix or the guards; a green suite proves that adding a role or renaming an action did not accidentally widen a deny path into an allow.
Integration Points
The access boundary sits directly in front of the constraint solver, so its output is the gate every other component of the architecture depends on. Approved payloads flow into the scoring and assignment engine described in SKU Velocity Taxonomy Design — the velocity_override field a caller is or is not permitted to set is the same decay threshold that taxonomy computes. Zone scope is validated against the graph produced by Location Hierarchy Mapping, which is why zone_assignments must reference real, existing zones. And because a slotting change alters pick density, the magnitude gate should consult Pick Path Modeling Frameworks: if a proposed rule invalidates an active pick-path model or pushes travel time past a safe threshold, the boundary must halt execution and force it into the approval chain regardless of the caller’s role.
Failure Modes & Edge Cases
- Zone assignments trusted from stale token claims. A revoked zone remains writable until the token expires. Remediation: hydrate
zone_assignmentsfrom the identity service at request time and keeptoken_ttl_secondsshort. - Wildcard admin bypasses the approval axis. A single
system_admincredential re-slots a building with no second signer. Remediation: keep admin subject to the magnitude gate, and log every admin action to the immutable sink. - Policy-cache TTL too long after a role change. A demoted engineer keeps write access for the length of the cache window. Remediation: bound
policy_cache_ttl_secondsto seconds and invalidate on role-change events. - Magnitude misclassification. A high-fanout affinity change slips through as
LOWand executes inline during peak. Remediation: tuneaffinity_weight_fanoutconservatively and treat anyvelocity_overrideasHIGHunconditionally. - Audit log written in place rather than append-only. An attacker or a buggy job overwrites the record of who authorized a change. Remediation: use a write-once (WORM) sink keyed by nonce and never update a record after creation.
FAQ
Why enforce zone scope separately from roles instead of just creating per-zone roles?
Per-zone roles explode combinatorially — five actions across twenty zones is a hundred role names to maintain. Keeping action scope (RBAC) orthogonal to spatial scope (zone ownership) means a slotting_engineer is defined once, and their reach is bounded by an assignment set resolved at request time. It also lets you move an engineer between zones by editing one assignment list rather than reissuing roles.
Should the system_admin wildcard skip the approval chain?
No. Action scope and approval scope are different axes: system_admin legitimately bypasses the RBAC verb check, but a facility-wide reclassification is still high blast-radius no matter who requests it. Keep admin subject to the magnitude gate so no single credential can silently re-slot an entire building, and log every admin action to the immutable sink.
How do I stop the staging gate from blocking legitimate re-slots during peak season?
Tune the quorum and window, not the gate itself. Drop high_magnitude_quorum for smaller teams, lengthen approval_window_hours so a stage does not expire before a signer is available, and pre-authorize a standing on-call approver. Removing the gate entirely trades a few minutes of latency for the risk of a peak-window slotting storm.
Where does token signature verification belong in this flow?
At the very front, inside get_current_user, before any payload parsing or policy check. Verify the JWT signature against your JWKS and reject expired tokens first; only then hydrate the authoritative zone assignments. Every downstream guard assumes the identity is already trustworthy, so a gap here undermines the whole boundary.
Related
- Implementing role-based access for slotting configs — JWKS verification, Redis policy caching, and context-variable propagation for the enforcement layer.
- SKU Velocity Taxonomy Design — the scoring engine whose decay thresholds and affinity weights these boundaries protect.
- Location Hierarchy Mapping — the zone graph that
zone_scopeandzone_assignmentsare validated against. - Pick Path Modeling Frameworks — the travel-time models the magnitude gate consults before allowing a change.
- Core Slotting Architecture & Velocity Taxonomies — the parent architecture this access layer secures.