feat: enhance project and scenario management with role-based access control

- Implemented role-based access control for project and scenario routes.
- Added authorization checks to ensure users have appropriate roles for viewing and managing projects and scenarios.
- Introduced utility functions for ensuring project and scenario access based on user roles.
- Refactored project and scenario routes to utilize new authorization helpers.
- Created initial data seeding script to set up default roles and an admin user.
- Added tests for authorization helpers and initial data seeding functionality.
- Updated exception handling to include authorization errors.
This commit is contained in:
2025-11-09 23:14:54 +01:00
parent 27262bdfa3
commit 0f79864188
16 changed files with 997 additions and 132 deletions

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import logging
from scripts.initial_data import load_config, seed_initial_data
def main() -> int:
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
try:
config = load_config()
seed_initial_data(config)
except Exception as exc: # pragma: no cover - operational guard
logging.exception("Seeding failed: %s", exc)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

1
scripts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utility scripts for CalMiner maintenance tasks."""

183
scripts/initial_data.py Normal file
View File

@@ -0,0 +1,183 @@
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Callable, Iterable
from dotenv import load_dotenv
from models import Role, User
from services.repositories import DEFAULT_ROLE_DEFINITIONS, RoleRepository, UserRepository
from services.unit_of_work import UnitOfWork
@dataclass
class SeedConfig:
admin_email: str
admin_username: str
admin_password: str
admin_roles: tuple[str, ...]
force_reset: bool
@dataclass
class RoleSeedResult:
created: int
updated: int
total: int
@dataclass
class AdminSeedResult:
created_user: bool
updated_user: bool
password_rotated: bool
roles_granted: int
def parse_bool(value: str | None) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def normalise_role_list(raw_value: str | None) -> tuple[str, ...]:
if not raw_value:
return ("admin",)
parts = [segment.strip() for segment in raw_value.split(",") if segment.strip()]
if "admin" not in parts:
parts.insert(0, "admin")
seen: set[str] = set()
ordered: list[str] = []
for role_name in parts:
if role_name not in seen:
ordered.append(role_name)
seen.add(role_name)
return tuple(ordered)
def load_config() -> SeedConfig:
load_dotenv()
admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL", "admin@calminer.local")
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "ChangeMe123!")
admin_roles = normalise_role_list(os.getenv("CALMINER_SEED_ADMIN_ROLES"))
force_reset = parse_bool(os.getenv("CALMINER_SEED_FORCE"))
return SeedConfig(
admin_email=admin_email,
admin_username=admin_username,
admin_password=admin_password,
admin_roles=admin_roles,
force_reset=force_reset,
)
def ensure_default_roles(
role_repo: RoleRepository,
definitions: Iterable[dict[str, str]] = DEFAULT_ROLE_DEFINITIONS,
) -> RoleSeedResult:
created = 0
updated = 0
total = 0
for definition in definitions:
total += 1
existing = role_repo.get_by_name(definition["name"])
if existing is None:
role_repo.create(Role(**definition))
created += 1
continue
changed = False
if existing.display_name != definition["display_name"]:
existing.display_name = definition["display_name"]
changed = True
if existing.description != definition["description"]:
existing.description = definition["description"]
changed = True
if changed:
updated += 1
role_repo.session.flush()
return RoleSeedResult(created=created, updated=updated, total=total)
def ensure_admin_user(
user_repo: UserRepository,
role_repo: RoleRepository,
config: SeedConfig,
) -> AdminSeedResult:
created_user = False
updated_user = False
password_rotated = False
roles_granted = 0
user = user_repo.get_by_email(config.admin_email, with_roles=True)
if user is None:
user = User(
email=config.admin_email,
username=config.admin_username,
password_hash=User.hash_password(config.admin_password),
is_active=True,
is_superuser=True,
)
user_repo.create(user)
created_user = True
else:
if user.username != config.admin_username:
user.username = config.admin_username
updated_user = True
if not user.is_active:
user.is_active = True
updated_user = True
if not user.is_superuser:
user.is_superuser = True
updated_user = True
if config.force_reset:
user.password_hash = User.hash_password(config.admin_password)
password_rotated = True
updated_user = True
user_repo.session.flush()
for role_name in config.admin_roles:
role = role_repo.get_by_name(role_name)
if role is None:
logging.warning("Role '%s' is not defined and will be skipped", role_name)
continue
already_assigned = any(assignment.role_id == role.id for assignment in user.role_assignments)
if already_assigned:
continue
user_repo.assign_role(user_id=user.id, role_id=role.id, granted_by=user.id)
roles_granted += 1
return AdminSeedResult(
created_user=created_user,
updated_user=updated_user,
password_rotated=password_rotated,
roles_granted=roles_granted,
)
def seed_initial_data(
config: SeedConfig,
*,
unit_of_work_factory: Callable[[], UnitOfWork] | None = None,
) -> None:
logging.info("Starting initial data seeding")
factory = unit_of_work_factory or UnitOfWork
with factory() as uow:
assert uow.roles is not None and uow.users is not None
role_result = ensure_default_roles(uow.roles)
admin_result = ensure_admin_user(uow.users, uow.roles, config)
logging.info(
"Roles processed: %s total, %s created, %s updated",
role_result.total,
role_result.created,
role_result.updated,
)
logging.info(
"Admin user: created=%s updated=%s password_rotated=%s roles_granted=%s",
admin_result.created_user,
admin_result.updated_user,
admin_result.password_rotated,
admin_result.roles_granted,
)
logging.info("Initial data seeding completed successfully")