Implementing Role-Based Access for Farm Ops

Agricultural automation needs permission checks that survive the field, not just the datacentre. The specific failure this page solves is the token-refresh collision: an edge controller renews an operator’s access token at the same instant it is streaming a variable-rate application script to a Programmable Logic Controller (PLC). If the middleware blocks the command queue while it waits for cryptographic re-verification over a flaky cellular link, mechanical actuators can halt mid-row — leaving a sprayer boom open, a planter section disengaged, or a chemical valve latched at its last setpoint. The corrupt outcome is not an exception in a log; it is an over-applied strip of crop and an application record that no longer matches the ground truth.

This reference details how to scope role-based access control (RBAC) so that a refresh race degrades to a safe read-only state instead of a hard halt, how CAN-bus command priority is arbitrated between an autonomous navigation lock and a human override, and how every privilege transition is logged with a cryptographic nonce so an audit can reconstruct exactly who acted. It applies the zero-trust field posture from the parent Security & Access Boundaries guide, and its typed payloads validate against the same Field Schema Design contracts the rest of the platform enforces at ingestion.

Operator session states under a token-refresh collision A vertical state machine with four states. ACTIVE (live in-scope assertion, commands authorized) transitions down to REFRESHING when exp minus now is less than refresh_threshold. REFRESHING transitions down to READ_ONLY_FALLBACK when refresh latency exceeds refresh_timeout_ms. READ_ONLY_FALLBACK uses the cached identity, holds the last actuator setpoint, and starts no new privileged action; it transitions down to the terminal UNAUTHORIZED state, which blocks the command, when cache_ttl has expired. Two return arrows on the right, both labelled refresh succeeds, run from REFRESHING and from READ_ONLY_FALLBACK back up to ACTIVE. Operator session under a token-refresh collision ACTIVE live in-scope assertion · commands authorized REFRESHING background token renewal in flight READ_ONLY_FALLBACK cached identity · maintain last setpoint UNAUTHORIZED no valid basis · command blocked Actuators hold last setpoint no new privileged action starts exp − now < refresh_threshold refresh latency > refresh_timeout_ms cache_ttl expired refresh succeeds refresh succeeds authenticated session

Parameter Reference

Every tunable below governs how aggressively the edge controller trusts a cached identity versus how quickly it falls back. Values are the recommended starting points for a cellular-backhauled implement operating in 60-minute session windows.

Parameter Type Recommended value Effect on behaviour
max_ttl int (seconds) 3600 Hard ceiling on assertion lifetime; a token whose exp - iat exceeds this is rejected at validation as exceeding the field-ops regulatory maximum.
CACHE_TTL int (seconds) 120 How long a validated assertion remains usable as a fallback after the live token goes missing. Longer values ride out worse dead zones but widen the stale-identity window.
REFRESH_THRESHOLD int (seconds) 300 Seconds before exp at which a background refresh is triggered. Raising it leaves more slack for a slow cellular handoff before the token actually expires.
PLC_QUEUE_MAX_DEPTH int 256 Command backlog at which the enforcer stops accepting new work and drains the queue before allowing a refresh — the guard that prevents a refresh landing mid-batch.
refresh_timeout_ms int (ms) 2000 A refresh call is abandoned past this. Beyond the timeout the session degrades to read-only rather than blocking actuators awaiting verification.
MAINTENANCE_WINDOW int (seconds) 300 Auto-revoke horizon for a temporary maintenance escalation (see the override protocol); the elevated role expires at this bound or on buffer flush, whichever comes first.

Application rates and geofence identifiers referenced in a scope are strings, never floats — the same exact-typing discipline that keeps regulated-rate comparisons from drifting elsewhere in the platform.

Runnable Implementation

The enforcer runs on the edge controller and answers one question per command: given the CAN message ID and the scope it needs, is this session allowed to execute right now? A missing live token does not mean “deny” — it means “fall back to the last valid identity, but only in read-only mode, and only until the cache ages out.” The match block makes the decision ladder explicit and ordered from most to least restrictive.

python
from __future__ import annotations

import time
from typing import Literal

from pydantic import BaseModel, Field, field_validator

Role = Literal["operator", "agronomist", "auditor", "maintenance"]


class RoleAssertion(BaseModel):
    """A scoped, signed claim bound to one operator session."""

    sub: str                        # operator UUID
    role: Role
    scope: list[str]                # e.g. ["tractor:nav", "sprayer:valve_3", "field:zone_B"]
    iat: int                        # issued-at, epoch seconds
    exp: int                        # expiry, epoch seconds
    nonce: str                      # replay guard, unique per issuance

    @field_validator("exp")
    @classmethod
    def enforce_ttl(cls, v: int, info) -> int:
        max_ttl = 3600              # 60-minute regulatory ceiling for field ops
        iat = info.data.get("iat", 0)
        if v - iat > max_ttl:
            raise ValueError(f"token TTL exceeds {max_ttl}s regulatory maximum")
        return v


# Hardware interrupt priority per role; a lower number preempts a higher one.
PRIORITY: dict[str, int] = {"nav": 0, "safety": 1, "agronomist": 2, "telemetry": 3}


def _blocks_nav(can_id: int, role: str) -> bool:
    # Navigation-range IDs (0x18000000-0x18000FFF) may only issue from priority 0.
    in_nav_range = 0x18000000 <= can_id <= 0x18000FFF
    return in_nav_range and PRIORITY.get(role, 3) > 0


class EdgeEnforcer:
    """Validate scopes locally so an actuator never runs an unauthorized command,
    and retain the last valid assertion so a refresh race degrades to read-only
    instead of halting an implement mid-row."""

    def __init__(self, cache_ttl: int = 120, refresh_threshold: int = 300) -> None:
        self.cache_ttl = cache_ttl
        self.refresh_threshold = refresh_threshold
        self._last_valid: RoleAssertion | None = None
        self._cached_at: float = 0.0

    def _cache_fresh(self) -> bool:
        return time.monotonic() - self._cached_at <= self.cache_ttl

    def accept(self, assertion: RoleAssertion) -> None:
        """Record a freshly validated assertion as the fallback of record."""
        self._last_valid = assertion
        self._cached_at = time.monotonic()

    def authorize(
        self, can_id: int, requested_scope: str, current: RoleAssertion | None
    ) -> str:
        # If the live token is missing (a refresh collision) but the cached
        # identity has not aged out, act on the cached assertion in read-only mode.
        assertion = current
        if assertion is None and self._last_valid is not None and self._cache_fresh():
            assertion = self._last_valid

        match assertion:
            case None:
                return "UNAUTHORIZED"            # no basis to act: block the command
            case RoleAssertion() if requested_scope not in assertion.scope:
                return "SCOPE_DENIED"            # not in this session's grant
            case RoleAssertion() if _blocks_nav(can_id, assertion.role):
                return "PENDING_ARBITRATION"     # navigation lock outranks the request
            case RoleAssertion() if assertion is self._last_valid:
                return "READ_ONLY_FALLBACK"      # cached identity: maintain last setpoint
            case _:
                return "AUTHORIZED"

Two design choices carry the safety guarantee. First, the cached assertion is returned only as READ_ONLY_FALLBACK, never as AUTHORIZED — a stale identity can hold the current setpoint but can never initiate a new privileged action such as opening a valve. Second, _blocks_nav is checked before the fallback branch, so an agronomist tablet cannot inject a pesticide override into the navigation CAN range while the autonomous module holds the higher-priority execution lock. The priority map mirrors the hardware mutex the Equipment Telemetry Parsing layer uses when it routes ISO 11783 messages onto the bus:

Interrupt priority Role scope CAN message ID range Override behaviour
0 (highest) Autonomous navigation 0x18000000–0x18000FFF Blocks all lower commands
1 Emergency stop / safety 0x18001000–0x18001FFF Preempts priority 0
2 Agronomist override 0x18002000–0x18002FFF Queues if priority 0 active
3 Routine telemetry / logging 0x18003000–0x18003FFF Dropped during high load

Log Patterns and Observable Signals

Every decision the enforcer returns must be traceable to the operator and the session that produced it. The success path emits a compact dispatch record; the warning path captures the refresh collision as it degrades; the error path records an outright block. All three carry the nonce so the entry can be cross-referenced against the identity provider.

Success — a command authorized against a live assertion:

json
{
  "timestamp": "2026-06-14T14:31:58Z",
  "event_type": "command_authorized",
  "correlation_id": "op-7f8a9b2c",
  "sub": "b7e2...c1",
  "role": "operator",
  "scope": "sprayer:valve_3",
  "decision": "AUTHORIZED",
  "nonce": "sha256:2a9f...b0"
}

Warning — a refresh collision handled by the read-only fallback:

json
{
  "timestamp": "2026-06-14T14:32:01Z",
  "event_type": "token_refresh_collision",
  "correlation_id": "op-7f8a9b2c",
  "role_transition": "active -> read_only_fallback",
  "actuator_state": "maintain_last_setpoint",
  "queue_depth": 214,
  "latency_ms": 2150,
  "override_applied": true
}

Error — a command with no valid basis to act, blocked before it reached the bus:

json
{
  "timestamp": "2026-06-14T14:32:03Z",
  "event_type": "command_blocked",
  "correlation_id": "op-7f8a9b2c",
  "decision": "UNAUTHORIZED",
  "reason": "cache_expired_no_live_token",
  "can_id": "0x18002010"
}

The signals worth alerting on are deliberately few: a rising token_refresh_collision rate (cellular backhaul degrading), any command_blocked during an active spray window (an operator locked out mid-task), and PENDING_ARBITRATION events that never clear (a navigation lock that failed to release). These map to the reconnection and de-duplication mechanics in the Fallback Routing Logic subsystem, which owns what happens to the buffered records once connectivity returns.

Safe Override Protocol

There is one legitimate reason to escalate privilege in the field: a cross-module flush. When a soil-moisture sensor gateway times out, buffered telemetry must be drained before it is lost, and the ingestion service needs a temporary maintenance role to write it. This bypass is safe only under explicit guard conditions — without them, a “flush” is indistinguishable from a lateral privilege escalation to an auditor.

Grant the temporary role only when all of the following hold, and log the grant with a fresh nonce derived per the NIST SP 800-63B Digital Identity Guidelines:

  1. The escalation reason is a recognized gateway/queue drain, not an actuator command.
  2. The elevated scope is limited to the ingest endpoint — it may never include a *:valve* or *:nav* scope.
  3. The grant carries an expiry_epoch no further out than MAINTENANCE_WINDOW (300 s) and auto-revokes on successful buffer flush, whichever is first.
  4. The nonce is verifiable against the central identity provider, preserving the cryptographic chain that EPA/USDA reporting audits follow.
json
{
  "escalation_reason": "gateway_timeout_flush",
  "temporary_role": "maintenance_ingest",
  "nonce": "sha256:8f3a9b...c1d2",
  "buffer_size_bytes": 14520,
  "regulatory_flag": "EPA_40CFR_170_compliant",
  "expiry_epoch": 1723654321
}

Mapping these escalation events to the correct statutory reference is the job of the EPA/USDA Rule Mapping engine, which stamps each flush with the rule revision it was evaluated against so a later inspection reproduces the exact grant that was in force.

Troubleshooting

  • Silent actuator halt during a spray pass → the refresh blocked the PLC queue mid-batch. Confirm PLC_QUEUE_MAX_DEPTH is draining before refresh and that refresh_timeout_ms is set; the fix is that a collision emits token_refresh_collision and falls to READ_ONLY_FALLBACK rather than UNAUTHORIZED.
  • Operator locked out with command_blocked / reason: cache_expired_no_live_token → the cellular gap outlasted CACHE_TTL. Raise CACHE_TTL for known dead zones, but only within the stale-identity tolerance the compliance team accepts.
  • SCOPE_DENIED on a command the operator should own → the assertion’s scope list is missing the endpoint. Reissue with the correct tractor:* / sprayer:* grant; do not widen the scope wildcard as a workaround.
  • PENDING_ARBITRATION never clears → the autonomous navigation module never released its priority-0 lock. Check the CAN mutex, not the RBAC layer; the enforcer is correctly refusing to inject a lower-priority command into the nav range.
  • Audit flags a maintenance grant as unauthorized → the escalation was logged without a verifiable nonce or with a scope outside the ingest endpoint. Enforce all four guard conditions above before issuing the temporary role.

Frequently Asked Questions

Why fall back to read-only instead of just re-blocking until the token refreshes? Blocking mid-row is itself a hazard: a halted implement can leave a valve latched or a section disengaged, damaging crop and corrupting the application record. Holding the last setpoint under a cached identity keeps the machine safe while the refresh completes, and the READ_ONLY_FALLBACK state guarantees no new privileged action starts on a stale token.

Can the cached assertion ever open a valve or start a new application? No. The enforcer returns the cached identity only as READ_ONLY_FALLBACK, which authorizes maintaining the current setpoint but never a new privileged command. Any new valve or navigation action requires a live, in-scope assertion.

How does an audit prove who acted during a refresh collision? Every decision — including the fallback — is logged with the session nonce and correlation_id. Because the nonce is verifiable against the identity provider and stamped with the rule-set version, an inspector can reconstruct exactly which operator’s assertion was in force when the actuator held its setpoint.

Up: Security & Access Boundaries