§7e.5 Authorization & session awareness
The daemon's policy stance is "zero implicit trust" on the data plane. WS upgrades are JWT-gated; drive commands are gated against the Redis-published roster (§7e.3).
dcc-bus has no concept of a lease or a takeover. It never models
who owns a vehicle, who leased it, or whether a takeover is
active. The only thing it knows is, per DCC address, a flat set of
controllerUserIds — "these user ids may drive this address right
now". Resolving the domain semantics (owner + active lessees +
the 5-minute takeover self-lease holder, §4.3) into that flat set is
done exclusively by loco-server, which has SQLite and the full
pkgs/bigfred/server/security policy layer. When a lease starts or
ends, or a takeover is granted/released, loco-server recomputes
controllerUserIds and republishes the allowed_vehicles snapshot;
the daemon simply swaps its in-memory map and the next command sees
the new set. The daemon therefore stays stateless about driving rights
and cannot drift from the server's authoritative decision.
Authenticating the WS upgrade
- The HTTP server inside
dcc-busreads thetokenquery parameter from the upgrade request. (Cookie-based auth is rejected — the daemon expects the explicit?token=flow already used byloco-server's WS upgrade, §6.3.) - The token is verified against
--jwt-secretusing the same HMAC-SHA256 algorithm asloco-server. Expired / malformed tokens close the upgrade with HTTP 401. - The JWT carries
{ userId, layoutId }. The daemon checkslayoutId == --layout-id; mismatch closes the upgrade with HTTP 403 andWWW-Authenticate: dcc-bus realm="layout-mismatch". - The JWT
userIdis stored on the WS session. The daemon does not loaddomain.Userrows from SQLite. The user is required to have a non-zero permanent / effective role reachable fromdomain.EffectiveRoles(any ofdriver,signalman,admin). The daemon does not re-resolve sudo elevations (noSudoServiceindcc-bus). Instead it consumes the user's effectiveRoleset via Redis pub/sub (bigfred:user:<id>:elevation) — when elevation changes,loco-serverpublishes the new role set and the daemon updates its in-memoryUser.EffectiveRolescache. This keeps the daemon stateless w.r.t. PIN / 2-minute timers. - The daemon allocates a fresh
DriveSession.SessionID(uuid) for the WS connection. The session is logged in Redis atdcc-bus:<L>:<C>:sessionsso operators andloco-servercan inventory it.
The WS Hub inside the daemon stores (*Client, sessionId, userId,
effectiveRoles, driveTargets, emergencyPlan) and uses these to
authorize subsequent actions.
Per-action authorization
| Action | Domain objects loaded | Policy method | Notes |
|---|---|---|---|
loco.subscribe { addr } |
allowed_vehicles snapshot |
addr present in snapshot |
Read-only telemetry; any authenticated layout user may subscribe. Rejects with vehicle_not_on_layout. |
loco.setSpeed / loco.toggleFn |
allowed_vehicles snapshot |
userId ∈ controllerUserIds for addr |
Rejects with not_authorized or vehicle_not_on_layout. controllerUserIds is the server-computed set (owner + active lessees + active takeover self-lease holder); the daemon does not re-derive lease/takeover semantics — it only checks membership. |
loco.toggleFn { fn } |
additionally []DccFunction (resolved list) |
FunctionSecurityContext.CanInvokeFunction |
Refuses unregistered functions with function_not_registered. |
system.estop |
the user's session-local DriveTargets |
LocoSecurityContext.CanDriveLoco evaluated per target with the user as actor |
Targets where the policy now denies are silently dropped (e.g. lease expired moments before estop). Audited via the event channel. |
ping |
none | none | – |
The daemon never short-circuits authorization based on the JWT
alone. A driver whose lease expired between login and setSpeed is
correctly rejected with not_authorized_to_drive. A signalman whose
takeover got revoked sees taken_over flip back to the driver and
controlledBy: "driver" on the next loco.state.
Takeover & lease propagation
loco-server is the sole writer of takeover and lease state
(§3a.5 audit log, §4.2 takeover state machine, §7a.3). When state
changes, loco-server does two things:
- Updates SQLite (existing path).
- Publishes the new state on
bigfred:vehicle:<id>:takeover/bigfred:vehicle:<id>:lease.
dcc-bus subscribes to these channels for vehicles in its
interesting set. On a payload:
- Invalidate the relevant memory cache.
- For each connected WS client subscribed to the affected
addr, push an updatedloco.statewith the newcontrolledByvalue (or with aloco.error { code: "lease_revoked" }if the driver lost authority).
In the worst case the pub/sub round trip takes a handful of
milliseconds. When loco-server publishes an updated
allowed_vehicles snapshot (including revised controllerUserIds),
the daemon replaces its cache before the next command is processed.
The push event is a UX nicety; the snapshot is authoritative for the
daemon process.
Session lifecycle & emergency plan
The daemon mirrors §4.5's drive session model in miniature:
type daemonSession struct {
SessionID uuid.UUID
UserID uint
EffectiveRoles domain.EffectiveRoles
Client *wsClient
OpenedAt time.Time
LastHeartbeat time.Time
DriveTargets map[uint16]struct{} // addrs the user has touched on THIS daemon
EmergencyPlan domain.EmergencyPlan // snapshotted at connect
}
The dead-man's switch follows §4.5.5:
- WS-level ping every 30 s (handled by
coder/websocket). - App-level
{type:"ping"}from the client every 3 s while in throttle mode (§4.5.2). - Missing heartbeat for
gracePeriod / 2→session.warning. - Missing heartbeat for
gracePeriodOR WS close → session enters theloststate. - When the user's last daemonSession on this daemon transitions to
lost, the daemon runs the user's emergency plan againstDriveTargets. This is the per-daemon rule — a separate daemon (different cs) does not fire just because this one did.
Speed gate. The dead-man's switch (idle timeout, last-session
close) acts on a locomotive only when its cached speed is above 1
— standing or creeping locos are left alone. Manual system.estop
from the throttle is not gated this way (it still skips speed 0 only).
Per-vehicle function plan (implemented). After SetSpeed(0) /
EMG-stop on a locomotive, dcc-bus reads the vehicle's
deadManSwitchOption from the allowed_vehicles snapshot and may
issue additional DCC function commands:
| Option | Brake | Rp1 (rp1Function, default F2) |
Emergency lights (emergencyLightsFunction, default F0) |
|---|---|---|---|
stop |
yes | — | — |
stop_horn |
yes | ON for 1 s, then OFF | — |
stop_horn_emergency_lights |
yes | ON for 1 s, then OFF | ON (left on) |
The owner configures the three fields on the vehicle add/edit dialog;
loco-server copies them into every allowed_vehicles publish so
daemons act without SQLite.
The cross-process aggregate "last session of the user anywhere" rule
from §4.5.3 lives in loco-server. When the daemon executes its
local plan, it publishes
bigfred:layout:<L>:emergency:<userId> { source:"dcc-bus", commandStationId, affectedVehicles }.
loco-server consumes that and:
- Mirrors
session.emergencyExecutedto the user's other control-plane sessions (so the dashboard updates). - Asks
ScriptService.StopAllForUser(userId)to interrupt any running script the user owns (existing behaviour, §4.5.3 ¶3a). - Writes the
session.emergency_executedaudit row (§3a.5).
If the user is also connected to loco-server's /api/v1/ws and
that is what dies (not the daemon WS), the existing §4.5 path
fires on loco-server, which also publishes
bigfred:layout:<L>:emergency:<userId> so every dcc-bus-* the
user has open connections to drops the user's drive targets to 0
on their respective command stations.
In short: whichever process notices a lost handle first triggers the fan-out; the other side debounces (an emergency that fired within the last 5 s for this user is ignored to prevent feedback loops).
Audit fan-in
The daemon does not write the audit log. Every event it produces
(takeover-relevant loco.state, emergency plan execution, function
invocation refused by policy, system.estop) lands on
dcc-bus:evt:<L>:<C>. loco-server consumes that channel and:
- Writes the audit row (
session.emergency_executed, etc.). - Updates derived state (e.g.
interlocking.occupantChangedif the driver loses authority due to the daemon executing the emergency plan — the existing logic already handles this). - Mirrors the event onto the server WS when it is relevant for non-throttle clients (e.g. an admin watching a dashboard).
If loco-server is down, audit events are lost — same as today (the
hand-rolled supervisor in §7 #12 had no audit fan-in either). When
loco-server comes back up, it picks up new events but does not
retroactively backfill missed ones; an operator can read the daemon
logs (/data/log/dcc-bus-…stdout.log) for the
gap.
Why this is acceptable security-wise
The policy layer (§7a.3) is pure and independent of the
process it runs in. Re-evaluating CanDriveLoco inside dcc-bus
against domain.User{ID:42} produces the same Decision as
re-evaluating it inside loco-server. The only thing the daemon
must get right is the input — and the input is exclusively domain
roster data published by loco-server (built from the same SQLite
rows the server would use for CanDriveLoco) plus the JWT-pinned
(userId, layoutId).
Sudo elevation deserves a closer look. A sudo admin minted on
loco-server should flip to admin everywhere within the 2-minute
window. The daemon's User.EffectiveRoles cache is keyed off the
bigfred:user:<id>:elevation pub/sub channel (§4.5/§7a.7 fan-out),
which loco-server already publishes for auth.elevationChanged.
The daemon listens and updates its in-memory copy; the next
authorization check sees the new role set. If pub/sub is delayed,
the worst case is a 2-minute admin briefly losing admin authority
inside the daemon — CanDriveLoco does not depend on admin, so
the impact is bounded to "may not perform admin-gated operations
the daemon currently exposes", which is zero (the daemon does not
expose admin operations).
What the daemon does NOT do
- It does not open or write to SQLite. Ever.
- It does not bump audit rows directly.
- It does not run
SudoServicerate-limiters. - It does not own the WS for control-plane traffic.
- It does not host the Hub for
loco-server's other sessions. - It does not spawn or supervise children.
This minimal surface is what lets dcc-bus crash and restart
without compromising the integrity of the rest of the system. The
"single source of truth" for catalogue data stays loco-server +
SQLite; the daemon's operational view is Redis snapshots + CLI station
config.