added backends, improved templating, rbac

This commit is contained in:
2026-06-10 14:40:22 +02:00
parent d9ca48addc
commit ce43f2658f
28 changed files with 1183 additions and 78 deletions

View File

@@ -4,11 +4,12 @@ import hashlib
import hmac
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy.orm import Session
from app.db.models import ApiKey, User
from app.security.time import ensure_aware_utc, utc_now
API_KEY_PREFIX_LENGTH = 12
API_KEY_RANDOM_BYTES = 32
@@ -64,9 +65,10 @@ def create_api_key(
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
now = datetime.now(timezone.utc)
now = utc_now()
for candidate in candidates:
if candidate.expires_at and candidate.expires_at < now:
expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now:
continue
if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "$".join([
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
])
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"))
expected = base64.b64decode(digest_b64.encode("ascii"))
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy.orm import Session
from app.db.models import AuthSession, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from app.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32
DEFAULT_SESSION_HOURS = 12
@dataclass(slots=True)
class CreatedSession:
model: AuthSession
token: str
def generate_session_token() -> str:
return "ms_" + secrets.token_urlsafe(SESSION_RANDOM_BYTES)
def hash_session_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_session_token(token: str, expected_hash: str) -> bool:
return hmac.compare_digest(hash_session_token(token), expected_hash)
def create_auth_session(
session: Session,
*,
user: User,
hours: int = DEFAULT_SESSION_HOURS,
user_agent: str | None = None,
ip_address: str | None = None,
) -> CreatedSession:
token = generate_session_token()
now = utc_now()
model = AuthSession(
tenant_id=user.tenant_id,
user_id=user.id,
token_hash=hash_session_token(token),
expires_at=now + timedelta(hours=hours),
last_seen_at=now,
user_agent=user_agent,
ip_address=ip_address,
)
user.last_login_at = now
session.add(model)
session.add(user)
session.flush()
return CreatedSession(model=model, token=token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
if not model:
return None
now = utc_now()
expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now:
return None
model.last_seen_at = now
session.add(model)
return model
def revoke_auth_session(session: Session, token: str) -> bool:
model = authenticate_session_token(session, token)
if not model:
return False
model.revoked_at = utc_now()
session.add(model)
return True
def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id: dict[str, Role] = {}
direct = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(UserRoleAssignment.user_id == user.id, UserRoleAssignment.tenant_id == user.tenant_id)
.all()
)
for role in direct:
roles_by_id[role.id] = role
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.filter(UserGroupMembership.user_id == user.id, UserGroupMembership.tenant_id == user.tenant_id)
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
return list(roles_by_id.values())
def collect_user_groups(session: Session, user: User) -> list[Group]:
return (
session.query(Group)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(UserGroupMembership.user_id == user.id, UserGroupMembership.tenant_id == user.tenant_id)
.all()
)
def collect_user_scopes(session: Session, user: User) -> list[str]:
if user.is_tenant_admin:
return ["*"]
scopes: set[str] = set()
for role in collect_user_roles(session, user):
scopes.update(role.permissions or [])
return sorted(scopes)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)