Files
paskia/paskia/db/structs.py
T
LeoVasanko 52f3b26630 Wildcard origins follow the shell-glob convention: **. for apex+any depth, *. for one level
'**.example.com' covers the apex and subdomains at any depth;
'*.example.com' covers exactly one subdomain level (neither apex nor
deeper) — analogous to permission scope wildcards, and sidestepping the
DNS/TLS/nginx ambiguity around '*.'. This also allows excluding the apex
where wanted. The seeded/default entry becomes '**.{rp-id}' (init,
add-domain, legacy empty-origins conversion, branch-era '*' sanitize
rewrite).
2026-09-07 15:15:30 +00:00

763 lines
24 KiB
Python

from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
import msgspec
import uuid7
from paskia import db
from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret
# Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0)
class Permission(msgspec.Struct, dict=True, omit_defaults=True):
"""Permission data structure.
Mutable fields: scope, display_name, domain, orgs
Immutable fields: None (all fields can be updated via update_permission)
uuid is generated at creation.
"""
scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write")
display_name: str
domain: str | None = None # If set, scopes permission to this domain
orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def org_set(self) -> set[UUID]:
"""Get orgs that can grant this permission as a set."""
return set(self.orgs.keys())
@property
def orgs_list(self) -> list[Org]:
"""Get list of Org objects that can grant this permission."""
return [
db.data().orgs[org_uuid]
for org_uuid in self.orgs.keys()
if org_uuid in db.data().orgs
]
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
Must be called inside a transaction.
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
del _data.permissions[self.uuid]
@classmethod
def create(
cls,
scope: str,
display_name: str,
domain: str | None = None,
created_at: datetime | None = None,
) -> Permission:
"""Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls(
scope=scope,
display_name=display_name,
domain=domain,
)
perm.uuid = uuid7.create(now)
return perm
class Org(msgspec.Struct, dict=True):
"""Organization data structure."""
display_name: str
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def roles(self) -> list[Role]:
"""Get all roles that belong to this organization."""
return [r for r in db.data().roles.values() if r.org_uuid == self.uuid]
@property
def permissions(self) -> list[Permission]:
"""Get all permissions that this organization can grant."""
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
def store(self) -> None:
"""Store this organization in the database. Must be called inside a transaction."""
db.data().orgs[self.uuid] = self
def delete(self) -> None:
"""Delete this org and cascade to roles, users. Remove from permissions.
Must be called inside a transaction.
"""
_data = db.data()
for p in _data.permissions.values():
p.orgs.pop(self.uuid, None)
for role in self.roles:
for user in role.users:
del _data.users[user.uuid]
del _data.roles[role.uuid]
del _data.orgs[self.uuid]
@classmethod
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
"""Create a new Org with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name)
org.uuid = uuid7.create(now)
return org
class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Role data structure.
Mutable fields: display_name, permissions
Immutable fields: org_uuid (set at creation, never modified)
uuid is generated at creation.
"""
org_uuid: UUID = msgspec.field(name="org")
display_name: str
permissions: dict[UUID, bool] = {} # permission_uuid -> True
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def permission_set(self) -> set[UUID]:
"""Get permissions as a set of UUIDs."""
return set(self.permissions.keys())
@property
def permissions_list(self) -> list[Permission]:
"""Get list of Permission objects for this role."""
return [
db.data().permissions[perm_uuid]
for perm_uuid in self.permissions.keys()
if perm_uuid in db.data().permissions
]
@property
def org(self) -> Org:
"""Get the organization object this role belongs to."""
return db.data().orgs[self.org_uuid]
@property
def users(self) -> list[User]:
"""Get all users that have this role."""
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
def store(self) -> None:
"""Store this role in the database. Must be called inside a transaction."""
db.data().roles[self.uuid] = self
def delete(self) -> None:
"""Delete this role from the database. Must be called inside a transaction."""
del db.data().roles[self.uuid]
@classmethod
def create(
cls,
org: UUID | Org,
display_name: str,
permissions: set[UUID] | None = None,
created_at: datetime | None = None,
) -> Role:
"""Create a new Role with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org_uuid = org if isinstance(org, UUID) else org.uuid
role = cls(
org_uuid=org_uuid,
display_name=display_name,
permissions={p: True for p in (permissions or set())},
)
role.uuid = uuid7.create(now)
return role
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
"""User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7.
"""
display_name: str
role_uuid: UUID = msgspec.field(name="role")
created_at: datetime
visits: int
last_seen: datetime | None = None
theme: str = ""
email: str | None = None # OIDC email claim
preferred_username: str | None = None # OIDC preferred_username claim
telephone: str | None = None # Telephone number
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def role(self) -> Role:
"""Get the role object this user has."""
return db.data().roles[self.role_uuid]
@property
def org(self) -> Org:
"""Get the organization this user belongs to (via role)."""
return self.role.org
@property
def credentials(self) -> list[Credential]:
"""Get all credentials for this user."""
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
@property
def credential_ids(self) -> list[bytes]:
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
return [c.credential_id for c in self.credentials]
def credential_ids_for(self, rp_id: str) -> list[bytes]:
"""Get credential IDs registered under a specific domain's rp-id."""
return [c.credential_id for c in self.credentials if c.rp_id == rp_id]
@property
def sessions(self) -> list[Session]:
"""Get all sessions for this user."""
return [s for s in db.data().sessions.values() if s.user_uuid == self.uuid]
@property
def reset_tokens(self) -> list[ResetToken]:
"""Get all reset tokens for this user."""
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
def store(self) -> None:
"""Store this user in the database. Must be called inside a transaction."""
db.data().users[self.uuid] = self
def delete(self) -> None:
"""Delete this user and cascade to credentials, sessions, reset tokens.
Must be called inside a transaction.
"""
_data = db.data()
for cred in self.credentials:
del _data.credentials[cred.uuid]
for sess in self.sessions:
del _data.sessions[sess.key]
for token in self.reset_tokens:
del _data.reset_tokens[token.key]
del _data.users[self.uuid]
@classmethod
def create(
cls,
display_name: str,
role: UUID | Role,
created_at: datetime | None = None,
) -> User:
"""Create a new User with auto-generated uuid7."""
role_uuid = role if isinstance(role, UUID) else role.uuid
user = cls(
display_name=display_name,
role_uuid=role_uuid,
created_at=created_at or datetime.now(UTC),
last_seen=None,
visits=0,
theme="",
)
user.uuid = uuid7.create(user.created_at)
return user
class Credential(msgspec.Struct, dict=True):
"""Credential (passkey) data structure.
Mutable fields: sign_count, last_used, last_verified
Immutable fields: credential_id, user, aaguid, public_key, created_at, rp_id
uuid is derived from created_at using uuid7.
rp_id is the domain the passkey was registered under. With Related Origin
Requests it is always the domain's canonical rp-id, regardless of which
origin the registration ceremony ran on.
"""
credential_id: bytes # Long binary ID from the authenticator
user_uuid: UUID = msgspec.field(name="user")
aaguid: UUID
public_key: bytes
sign_count: int
created_at: datetime
rp_id: str
last_used: datetime | None = None
last_verified: datetime | None = None
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@property
def user(self) -> User:
"""Get the User object for this credential."""
return db.data().users[self.user_uuid]
@property
def sessions(self) -> list[Session]:
"""Get all sessions using this credential."""
return [
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
]
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
def delete(self) -> None:
"""Delete this credential and all its sessions.
Must be called inside a transaction.
"""
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
del _data.credentials[self.uuid]
@classmethod
def create(
cls,
credential_id: bytes,
user: UUID | User,
aaguid: UUID,
public_key: bytes,
sign_count: int,
rp_id: str,
created_at: datetime | None = None,
) -> Credential:
"""Create a new Credential with auto-generated uuid7."""
user_uuid = user if isinstance(user, UUID) else user.uuid
now = created_at or datetime.now(UTC)
cred = cls(
credential_id=credential_id,
user_uuid=user_uuid,
aaguid=aaguid,
public_key=public_key,
sign_count=sign_count,
created_at=now,
rp_id=rp_id,
last_used=now,
last_verified=now,
)
cred.uuid = uuid7.create(now)
return cred
class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure.
Mutable fields: host, ip, user_agent, validated, issuer (update_session)
Immutable fields: user_uuid, credential_uuid, client_uuid, rp_id
key is the hashed db_key, stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session.
Security: The database stores only derived keys, never the raw secret.
A database leak does not expose working session credentials.
"""
user_uuid: UUID = msgspec.field(name="user")
credential_uuid: UUID = msgspec.field(name="credential")
host: str
ip: str
user_agent: str
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
rp_id: str | None = None # Owning domain (needed when no request context)
issuer: str | None = None # OIDC issuer URL this session was created under
def __post_init__(self):
if not hasattr(self, "key"):
self.key: str = ""
@property
def user(self) -> User:
"""Get the User object for this session."""
return db.data().users[self.user_uuid]
@property
def credential(self) -> Credential:
"""Get the Credential object for this session."""
return db.data().credentials[self.credential_uuid]
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
@classmethod
def create(
cls,
user: UUID | User,
credential: UUID | Credential,
key: str,
host: str,
ip: str,
user_agent: str,
validated: datetime,
client: UUID | None = None,
rp_id: str | None = None,
issuer: str | None = None,
) -> Session:
"""Create a new Session with the provided key.
Args:
key: The hashed session key (derived from secret via hash_secret)
rp_id: Owning domain's rp-id (used when no request context exists)
issuer: OIDC issuer URL (scheme + host) for OIDC sessions
Returns:
Session object with key set
"""
user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid
)
session = cls(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
validated=validated,
client_uuid=client,
rp_id=rp_id,
issuer=issuer,
)
session.key = key
return session
class ResetToken(msgspec.Struct, dict=True):
"""Reset/device-addition token data structure.
Immutable fields: All fields (tokens are created and deleted, never modified)
key is stored in the dict key, not in the struct.
"""
user_uuid: UUID = msgspec.field(name="user")
expiry: datetime
token_type: str
def __post_init__(self):
if not hasattr(self, "key"):
self.key: str = ""
@property
def user(self) -> User:
"""Get the User object for this reset token."""
return db.data().users[self.user_uuid]
def store(self) -> None:
"""Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self
def delete(self) -> None:
"""Delete this reset token from the database. Must be called inside a transaction."""
del db.data().reset_tokens[self.key]
@staticmethod
def hash(passphrase: str) -> str:
"""Hash a passphrase to string for reset token storage."""
if not passphrase_util.is_well_formed(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hash_secret("reset", passphrase)
@classmethod
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
"""Get a reset token by passphrase."""
key = cls.hash(passphrase)
return db.data().reset_tokens.get(key)
@classmethod
def create(
cls,
user: UUID | User,
expiry: datetime,
token_type: str,
passphrase: str | None = None,
) -> tuple[ResetToken, str]:
"""Create a new ResetToken with auto-generated or provided passphrase.
Args:
user: User UUID or User object
expiry: Token expiration datetime
token_type: Type of token (e.g., "device addition", "account recovery")
passphrase: Optional passphrase to use (auto-generated if not provided)
Returns:
Tuple of (token, passphrase) where passphrase is the human-readable
code to give to the user.
"""
if passphrase is None:
passphrase = passphrase_util.generate()
key = cls.hash(passphrase)
user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
)
token.key = key
return token, passphrase
# -------------------------------------------------------------------------
# OIDC Provider structures
# -------------------------------------------------------------------------
class Client(msgspec.Struct, dict=True, omit_defaults=True):
"""OIDC client (relying party) registration.
client_id is the dict key (UUID).
"""
client_secret_hash: bytes
name: str
redirect_uris: list[str]
backchannel_logout_uri: str | None = None
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@classmethod
def create(
cls,
name: str,
redirect_uris: list[str],
client_secret: str,
created_at: datetime | None = None,
backchannel_logout_uri: str | None = None,
) -> tuple[Client, str]:
"""Create a new OIDClient with hashed secret.
Returns (client, client_secret) tuple.
"""
now = created_at or datetime.now(UTC)
secret_hash = hashlib.sha256(client_secret.encode()).digest()
client = cls(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = uuid7.create(now)
return client, client_secret
def verify_secret(self, client_secret: str) -> bool:
"""Verify a client secret against stored hash."""
return secrets.compare_digest(
self.client_secret_hash,
hashlib.sha256(client_secret.encode()).digest(),
)
class SessionContext(msgspec.Struct):
session: Session
user: User
org: Org
role: Role
credential: Credential
permissions: list[Permission] = []
class OIDC(msgspec.Struct, dict=True):
clients: dict[UUID, Client] = {}
key: bytes | None = None
class OriginEntry(msgspec.Struct, omit_defaults=True):
"""Extra properties of one allowed origin within a domain.
Stored as the dict value for an origin key; plain ``True`` instead of an
object means presence only, nothing more to store.
"""
auth_host: bool = False # This site hosts the account/admin interface
class DomainConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one domain (one WebAuthn rp-id).
``origins`` is a single table of sites that may sign in with this
domain's passkeys, classified by the rp-id: entries within the rp-id
domain are in-domain sign-in sites, entries outside it are related
origins (WebAuthn Related Origin Requests — individual hosts only,
no wildcards). Keys are hosts without the https:// scheme
("app.example.com"), wildcard patterns under the rp-id following the
shell-glob convention ("**.example.com" — the base domain and its
subdomains at any depth; "*.example.com" — exactly one subdomain
level; https only, any scheme and port under localhost), or full
origins ("http://localhost:8080", "https://app2.com"). An empty dict
means nothing is allowed — list sites explicitly. Ordering carries no
meaning — display order is decided by the UI.
"""
rp_name: str | None = None
origins: dict[str, bool | OriginEntry] = {}
class Config(msgspec.Struct, omit_defaults=True):
"""Stored configuration for the instance.
Domains are keyed by rp-id and shared by the whole administrative
instance: organizations and users are global across rp-ids.
"""
domains: dict[str, DomainConfig] = msgspec.field(
default_factory=lambda: {
"localhost": DomainConfig(origins={"**.localhost": True})
}
)
listen: list[str] | None = None # Process-global listen endpoints
# -------------------------------------------------------------------------
# Database storage structure
# -------------------------------------------------------------------------
class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads."""
config: Config = msgspec.field(default_factory=Config)
permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {}
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[str, ResetToken] = {}
# OIDC provider data: one instance-global provider (single signing key
# and client set); each request Host acts as an issuer alias.
oidc: OIDC = msgspec.field(default_factory=OIDC)
def __post_init__(self):
# Optional store reference for non-global DB instances (e.g. tests).
self._store: Any | None = None
# Set the key fields on all stored objects
for uuid, perm in self.permissions.items():
perm.uuid = uuid
for uuid, org in self.orgs.items():
org.uuid = uuid
for uuid, role in self.roles.items():
role.uuid = uuid
for uuid, user in self.users.items():
user.uuid = uuid
for uuid, cred in self.credentials.items():
cred.uuid = uuid
for key, session in self.sessions.items():
session.key = key
for key, token in self.reset_tokens.items():
token.key = key
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
def session_ctx(
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
"""Get full session context with effective permissions.
Args:
session_secret: The session secret (cookie value) - will be hashed for lookup
host: The request host; sessions are host-bound and domain-scoped
permissions are filtered by it
Returns:
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = hash_secret("cookie", session_secret)
try:
s = self.sessions[key]
except KeyError:
return None
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
# Sessions are host-bound
if s.host != host:
return None
try:
user = s.user
role = user.role
org = role.org
credential = s.credential
except KeyError:
return None
# Effective permissions: role's permissions that the org can grant,
# filtered by domain restriction
org_perm_uuids = {p.uuid for p in org.permissions}
effective_perms = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
try:
p = self.permissions[perm_uuid]
except KeyError:
continue
if p.domain is not None and p.domain != host:
continue
effective_perms.append(p)
return SessionContext(
session=s,
user=user,
org=org,
role=role,
credential=credential,
permissions=effective_perms,
)