Files
Donkie e4563e58c7 auth: core primitives
Levels, principals, password hashing, cookies and login throttling. Pure
library code -- nothing imports it into a request path yet.

levels.py
  An ordered read < edit < manage scale. Ranks live in a separate mapping
  rather than as comparison operators on the enum: StrEnum inherits str's
  operators, so defining __lt__/__ge__ would shadow string ordering with a
  second alphabetical meaning that happens to agree today and would stop
  agreeing the moment a level is renamed. Callers use covers().

principal.py
  A frozen dataclass describing who is asking and what they may do. The
  absence of a credential is None rather than a Principal, so "nobody" can
  never be mistaken for a permission holder. Carries the tenant_id seam from
  the design doc: always None, present so auth functions already thread a
  tenant and adding tenancy later is a filter inside them rather than a
  change to forty routers. UNRESTRICTED is what gates return when auth is
  off, before any database access.

hashing.py
  scrypt at n=2**14, r=8, p=1 -- argon2-cffi and bcrypt ship compiled wheels
  that are painful on the armv7 image, and scrypt is memory-hard and in the
  standard library. OWASP's headline n=2**17 would be multi-second on the
  ARM boards Spoolman runs on; their memory-constrained variant raises p,
  which costs the same time without the memory hardness.

  Hashes embed their own parameters, so the cost can be raised later without
  a migration: verification re-derives with the stored parameters and
  needs_rehash() reports staleness for opportunistic upgrade at login.

  Deriving blocks for ~50 ms here and several times that on a Pi. Spoolman
  is a single process, so a synchronous call in a handler would stall every
  other request. Measured 4 hashes serially at 171 ms against 63 ms across
  four threads, confirming scrypt releases the GIL and the _async wrappers
  are a real offload. Handlers must use those.

  Session tokens are hashed with plain unsalted SHA-256, deliberately: they
  are 256 bits of cryptographic randomness, so there is no dictionary to
  attack and no work factor worth paying. The digest exists so a database
  leak does not hand over usable cookies.

cookies.py
  Session cookie is HttpOnly, so CSRF is handled by double submit against a
  second script-readable cookie. Secure is only ever concluded from positive
  evidence -- real TLS, or a proxy listed in SPOOLMAN_TRUSTED_PROXIES saying
  so -- because setting it on a plain-HTTP LAN deployment makes the browser
  discard the cookie and presents as a login that succeeds and does nothing.

ratelimit.py
  In-process sliding windows keyed by username and by client address. Sound
  because the deployment contract is one process, the same assumption the
  websocket manager and scheduler already make; counters reset on restart,
  which is why lockout is also persisted on auth_user. X-Forwarded-For and
  X-Forwarded-Proto are honoured only from a configured trusted proxy,
  otherwise any client could spoof them to evade the limiter.

Also adds AuthenticationRequiredError and PermissionDeniedError to
exceptions.py, matching the existing bare-Exception style.

Verified: level ordering and rejection of unknown names; principal frozen
and anonymous capped at read; hash round-trip, distinct salts, corrupt
hashes never validating, needs_rehash on stale parameters, and dummy_verify
costing 41 ms against a real verify's 42 ms so unknown usernames are
timing-indistinguishable; window limit/expiry/reset; untrusted
X-Forwarded-For ignored and untrusted proxies unable to force Secure; and
all nine CSRF cases including a lowercase-method bypass attempt.
2026-07-28 08:50:24 +02:00

67 lines
1.8 KiB
Python

"""The permission level scale.
Three ordered levels gate the API: ``read`` covers every GET and websocket
subscription, ``edit`` adds modification of existing records, and ``manage`` adds
creation and deletion.
``PUT /spool/{id}/use`` deliberately sits at ``edit`` rather than ``manage``: it is
Moonraker's and OctoPrint's hot path, so a printer can be given an ``edit`` credential
that tracks filament usage but cannot delete anything.
"""
from enum import StrEnum
class Level(StrEnum):
"""A permission level."""
READ = "read"
EDIT = "edit"
MANAGE = "manage"
# Ranks are kept in a separate mapping rather than expressed with comparison operators
# on Level itself. StrEnum inherits str's operators, so defining __lt__/__ge__ would
# silently shadow string ordering and give "edit" < "manage" a second, alphabetical
# meaning that happens to agree here and would stop agreeing the moment a level is
# renamed. Callers use covers() instead.
_RANKS: dict[Level, int] = {
Level.READ: 0,
Level.EDIT: 1,
Level.MANAGE: 2,
}
def covers(actual: Level, required: Level) -> bool:
"""Check whether a level satisfies a requirement.
Args:
actual: The level held.
required: The level demanded.
Returns:
bool: True if `actual` is at least as permissive as `required`.
"""
return _RANKS[actual] >= _RANKS[required]
def parse_level(value: str) -> Level:
"""Parse a level name.
Args:
value: The level name, case-insensitive.
Raises:
ValueError: If the name is not a known level.
Returns:
Level: The parsed level.
"""
try:
return Level(value.strip().lower())
except ValueError as exc:
known = ", ".join(level.value for level in Level)
raise ValueError(f"Unknown permission level '{value}'. Expected one of: {known}.") from exc