Home

05 authorization

§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

  1. The HTTP server inside dcc-bus reads the token query parameter from the upgrade request. (Cookie-based auth is rejected — the daemon expects the explicit ?token= flow already used by loco-server's WS upgrade, §6.3.)
  2. The token is verified against --jwt-secret using the same HMAC-SHA256 algorithm as loco-server. Expired / malformed tokens close the upgrade with HTTP 401.
  3. The JWT carries { userId, layoutId }. The daemon checks layoutId == --layout-id; mismatch closes the upgrade with HTTP 403 and WWW-Authenticate: dcc-bus realm="layout-mismatch".
  4. The JWT userId is stored on the WS session. The daemon does not load domain.User rows from SQLite. The user is required to have a non-zero permanent / effective role reachable from domain.EffectiveRoles (any of driver, signalman, admin). The daemon does not re-resolve sudo elevations (no SudoService in dcc-bus). Instead it consumes the user's effective Role set via Redis pub/sub (bigfred:user:<id>:elevation) — when elevation changes, loco-server publishes the new role set and the daemon updates its in-memory User.EffectiveRoles cache. This keeps the daemon stateless w.r.t. PIN / 2-minute timers.
  5. The daemon allocates a fresh DriveSession.SessionID (uuid) for the WS connection. The session is logged in Redis at dcc-bus:<L>:<C>:sessions so operators and loco-server can 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:

  1. Updates SQLite (existing path).
  2. 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:

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:

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:

  1. Mirrors session.emergencyExecuted to the user's other control-plane sessions (so the dashboard updates).
  2. Asks ScriptService.StopAllForUser(userId) to interrupt any running script the user owns (existing behaviour, §4.5.3 ¶3a).
  3. Writes the session.emergency_executed audit 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:

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

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.