mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-23 02:22:20 -05:00
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.
67 lines
1.8 KiB
Python
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
|