Files
kiddo/backend/app.py
stephan 63ee0b40b5 feat: login time window rules (EPIC_000014)
- Implemented RuleManager and JSON storage
- Added background enforcement scheduler
- Added Web UI for rule management at /ui/rules
- Bumped version to 0.3.0
2026-01-16 11:25:56 +01:00

377 lines
12 KiB
Python

import asyncio
import logging
from typing import List
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from backend import actions, enforcer, rules
from backend.actions import ActionError
from backend.auth import (
authenticate_admin_user,
get_current_admin,
is_authorized_admin,
is_account_locked,
issue_token,
list_manageable_users,
)
from backend.models import (
AccessRule,
ActionRequest,
ActionResponse,
EnrollRequest,
EnrollResponse,
LoginRequest,
LoginResponse,
RuleSet,
UpdateActionResponse,
UpdateCheckResponse,
UpdateLogEntry,
UpdateServiceStatus,
UpdateStatus,
SystemMetrics,
UserStatus,
)
from backend.oidc import OIDCClient, OIDCError
from backend.settings import Settings, get_settings
from backend import update
from backend import system_metrics
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("skd")
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
templates = Jinja2Templates(directory="backend/templates")
@app.on_event("startup")
async def startup_event():
asyncio.create_task(enforcer.enforcement_loop())
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
if not settings.oidc_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OIDC not configured",
)
try:
return OIDCClient(settings)
except OIDCError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
allowed = set(list_manageable_users(settings))
if username not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not allowed")
return username
@app.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict:
return {"status": "ok", "dry_run": settings.dry_run}
@app.get("/me")
def whoami(
current_user: str = Depends(get_current_admin),
settings: Settings = Depends(get_settings),
) -> dict:
auth_mode = "pam+oidc" if settings.oidc_enabled else "pam"
return {"user": current_user, "auth_mode": auth_mode}
@app.post("/login", response_model=LoginResponse)
def login(
payload: LoginRequest,
response: Response,
settings: Settings = Depends(get_settings),
) -> LoginResponse:
authenticate_admin_user(payload.username, payload.password, settings)
token = issue_token(payload.username, settings)
response.set_cookie(
settings.session_cookie_name,
token,
max_age=settings.token_ttl_seconds,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
)
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
@app.get("/login/oidc/start")
def oidc_start(
settings: Settings = Depends(get_settings),
oidc: OIDCClient = Depends(get_oidc_client),
):
state = oidc.build_state_token()
redirect = RedirectResponse(url=oidc.authorization_url(state))
redirect.set_cookie(
settings.oidc_state_cookie_name,
state,
max_age=300,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
)
return redirect
@app.get("/login/oidc/callback")
def oidc_callback(
request: Request,
code: str,
state: str,
settings: Settings = Depends(get_settings),
oidc: OIDCClient = Depends(get_oidc_client),
):
stored_state = request.cookies.get(settings.oidc_state_cookie_name, "")
if not oidc.is_state_valid(state, stored_state):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OIDC state")
claims = oidc.exchange_code_for_claims(code)
username = oidc.extract_username(claims)
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim")
if not is_authorized_admin(username, settings, mode="oidc"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
token = issue_token(username, settings)
redirect = RedirectResponse(url="/")
redirect.set_cookie(
settings.session_cookie_name,
token,
max_age=settings.token_ttl_seconds,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
)
redirect.delete_cookie(settings.oidc_state_cookie_name)
return redirect
@app.get("/login/oidc/status")
def oidc_status(settings: Settings = Depends(get_settings)) -> dict:
return {"enabled": settings.oidc_enabled}
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
logged_in = set(actions.list_logged_in_users())
targets = list_manageable_users(settings)
return [
UserStatus(
user=user,
logged_in=user in logged_in,
account_locked=is_account_locked(user),
)
for user in targets
]
@app.post(
"/users/{username}/disable",
response_model=ActionResponse,
dependencies=[Depends(get_current_admin)],
)
def disable_user(
username: str = Depends(validate_user),
payload: ActionRequest | None = Body(default=None),
settings: Settings = Depends(get_settings),
current_user: str = Depends(get_current_admin),
) -> ActionResponse:
if username == current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot disable current user",
)
try:
steps = actions.disable_user(
username,
countdown=payload.countdown if payload else None,
sound=payload.sound if payload else None,
message=payload.message if payload else None,
)
except ActionError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except Exception as exc: # pragma: no cover - safeguard
logger.exception("Failed to disable %s", username)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed") from exc
logged_in = username in actions.list_logged_in_users()
return ActionResponse(
user=username,
action="disable",
dry_run=settings.dry_run,
steps=steps,
logged_in=logged_in,
)
@app.post(
"/users/{username}/enable",
response_model=ActionResponse,
dependencies=[Depends(get_current_admin)],
)
def enable_user(
username: str = Depends(validate_user),
settings: Settings = Depends(get_settings),
) -> ActionResponse:
try:
steps = actions.enable_user(username)
except ActionError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except Exception as exc: # pragma: no cover - safeguard
logger.exception("Failed to enable %s", username)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed") from exc
logged_in = username in actions.list_logged_in_users()
return ActionResponse(
user=username,
action="enable",
dry_run=settings.dry_run,
steps=steps,
logged_in=logged_in,
)
@app.get("/rules", response_model=RuleSet, dependencies=[Depends(get_current_admin)])
def get_rules(settings: Settings = Depends(get_settings)) -> RuleSet:
manager = rules.RuleManager(settings)
return manager.load_rules()
@app.post("/rules", dependencies=[Depends(get_current_admin)])
def set_rule(
rule: AccessRule,
settings: Settings = Depends(get_settings),
) -> dict:
manager = rules.RuleManager(settings)
manager.set_rule(rule)
return {"status": "ok"}
@app.delete("/rules/{username}", dependencies=[Depends(get_current_admin)])
def delete_rule(
username: str,
settings: Settings = Depends(get_settings),
) -> dict:
manager = rules.RuleManager(settings)
manager.delete_rule(username)
return {"status": "ok"}
@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)])
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
status_data = update.get_status(settings)
return UpdateStatus(**status_data)
@app.post("/update/enroll", response_model=EnrollResponse, dependencies=[Depends(get_current_admin)])
def update_enroll(
payload: EnrollRequest | None = Body(default=None),
settings: Settings = Depends(get_settings),
) -> EnrollResponse:
if payload and payload.enroll_token:
settings.update_enroll_token = payload.enroll_token
try:
update.enroll(settings)
return EnrollResponse(enrolled=True, message="Enrollment successful")
except Exception as exc:
logger.exception("Enrollment failed")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Enrollment failed: {str(exc)}",
) from exc
@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)])
def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse:
try:
check_data = update.check_update(settings)
except Exception as exc:
logger.exception("Update check failed")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Update check failed: {str(exc)}",
) from exc
return UpdateCheckResponse(**check_data)
@app.post("/update/apply", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)])
def update_apply(
settings: Settings = Depends(get_settings),
payload: dict | None = Body(default=None),
) -> UpdateActionResponse:
version = payload.get("version") if payload else None
try:
update.start_update(settings, version)
except Exception as exc:
logger.exception("Failed to start update")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start update: {str(exc)}",
) from exc
return UpdateActionResponse(started=True, message="Update started")
@app.post("/update/rollback", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)])
def update_rollback(settings: Settings = Depends(get_settings)) -> UpdateActionResponse:
try:
update.start_rollback(settings)
except Exception as exc:
logger.exception("Failed to start rollback")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start rollback: {str(exc)}",
) from exc
return UpdateActionResponse(started=True, message="Rollback started")
@app.get("/update/logs", dependencies=[Depends(get_current_admin)])
def update_logs(settings: Settings = Depends(get_settings), limit: int = 200) -> list[dict]:
try:
return update.get_logs(settings, limit)
except Exception as exc:
logger.exception("Failed to retrieve update logs")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve logs: {str(exc)}",
) from exc
@app.get("/update/service-status", response_model=UpdateServiceStatus, dependencies=[Depends(get_current_admin)])
def update_service_status(settings: Settings = Depends(get_settings)) -> UpdateServiceStatus:
data = update.get_service_status(settings)
return UpdateServiceStatus(**data)
@app.get("/system/metrics", response_model=SystemMetrics, dependencies=[Depends(get_current_admin)])
def system_metrics_status() -> SystemMetrics:
data = system_metrics.get_system_metrics()
return SystemMetrics(**data)
@app.get("/", response_class=HTMLResponse)
@app.get("/login", response_class=HTMLResponse)
@app.get("/dashboard", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/ui/rules", response_class=HTMLResponse)
def rules_ui(request: Request) -> HTMLResponse:
return templates.TemplateResponse("rules.html", {"request": request})