From dd1f85d8d32820cf35a6b662c78d6b0ab56a1a3f Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 19 Feb 2026 11:18:50 +0100 Subject: [PATCH] feat: add admin router with admin group guard --- src/porchlight/admin/__init__.py | 0 src/porchlight/admin/routes.py | 34 ++++++++++++++++++ src/porchlight/app.py | 2 ++ tests/test_admin/__init__.py | 0 tests/test_admin/test_admin_guard.py | 53 ++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 src/porchlight/admin/__init__.py create mode 100644 src/porchlight/admin/routes.py create mode 100644 tests/test_admin/__init__.py create mode 100644 tests/test_admin/test_admin_guard.py diff --git a/src/porchlight/admin/__init__.py b/src/porchlight/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py new file mode 100644 index 0000000..78500c4 --- /dev/null +++ b/src/porchlight/admin/routes.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse + +from porchlight.dependencies import get_session_user +from porchlight.models import User + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +async def _get_admin_user(request: Request) -> User | None: + """Return the current user if they are an admin, else None.""" + session_user = get_session_user(request) + if session_user is None: + return None + userid, _username = session_user + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None or "admin" not in user.groups: + return None + return user + + +@router.get("/users", response_class=HTMLResponse) +async def users_list(request: Request) -> Response: + session_user = get_session_user(request) + if session_user is None: + return RedirectResponse("/login", status_code=303) + + admin = await _get_admin_user(request) + if admin is None: + return HTMLResponse("Forbidden", status_code=403) + + # Placeholder — will be implemented in Task 4 + return HTMLResponse("Admin users list") diff --git a/src/porchlight/app.py b/src/porchlight/app.py index f0bc34f..a27fc1c 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -10,6 +10,7 @@ from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware from porchlight.authn.password import PasswordService +from porchlight.admin.routes import router as admin_router from porchlight.authn.routes import router as authn_router from porchlight.authn.webauthn import WebAuthnService from porchlight.config import Settings, StorageBackend @@ -114,6 +115,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static") # Routers + app.include_router(admin_router) app.include_router(authn_router) app.include_router(manage_router) app.include_router(oidc_router) diff --git a/tests/test_admin/__init__.py b/tests/test_admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_admin/test_admin_guard.py b/tests/test_admin/test_admin_guard.py new file mode 100644 index 0000000..f58d187 --- /dev/null +++ b/tests/test_admin/test_admin_guard.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime + +import pytest +from argon2 import PasswordHasher +from httpx import AsyncClient + +from porchlight.authn.password import PasswordService +from porchlight.models import PasswordCredential, User + + +async def _login( + client: AsyncClient, username: str = "alice", password: str = "testpass", *, groups: list[str] | None = None +) -> None: + """Helper: create user + password credential and log in via POST /login/password.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = await user_repo.get_by_username(username) + if user is None: + user = User( + userid="test-user-01", + username=username, + groups=groups or [], + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + existing = await cred_repo.get_password_by_user(user.userid) + if existing is None: + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password))) + + await client.post( + "/login/password", + data={"username": username, "password": password}, + headers={"HX-Request": "true"}, + ) + + +@pytest.mark.asyncio +async def test_admin_users_redirects_unauthenticated(client: AsyncClient) -> None: + response = await client.get("/admin/users", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/login" + + +@pytest.mark.asyncio +async def test_admin_users_403_for_non_admin(client: AsyncClient) -> None: + await _login(client, username="regularuser", password="password123", groups=["users"]) + response = await client.get("/admin/users", follow_redirects=False) + assert response.status_code == 403