diff --git a/03-apiservice/app/admin.py b/03-apiservice/app/admin.py index 77128fc..4330a71 100644 --- a/03-apiservice/app/admin.py +++ b/03-apiservice/app/admin.py @@ -3,9 +3,9 @@ from __future__ import annotations from fastapi import HTTPException, Request, status from sqladmin import Admin, ModelView from sqladmin.authentication import AuthenticationBackend -from starlette.responses import RedirectResponse +from starlette.responses import RedirectResponse, JSONResponse from sqlalchemy.orm import sessionmaker -from wtforms import StringField +from wtforms import StringField, TextAreaField from wtforms.validators import Optional from app.core.config import settings @@ -39,27 +39,60 @@ class ApiClientAdmin(ModelView, model=ApiClient): class ApiKeyAdmin(ModelView, model=ApiKey): form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at] + column_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.is_active, ApiKey.created_at] + column_details_list = [ApiKey.id, ApiKey.client_id, ApiKey.name, ApiKey.key_prefix, ApiKey.permissions, ApiKey.is_active, ApiKey.created_at] + details_template = "apikey_details.html" form_extra_fields = { - "plain_key": StringField("Plain Key", validators=[Optional()]), - "permissions_csv": StringField("Permissions (comma)", validators=[Optional()]), + "permissions": TextAreaField( + "Permissions (JSON Array)", + validators=[Optional()], + description='Example: ["feed.waiting-time:write", "feed.opd-checkpoint:write"]', + render_kw={"placeholder": '["feed.waiting-time:write"]', "rows": 3} + ), } async def on_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None: - plain_key = data.get("plain_key") - if plain_key: + import json + + # Handle permissions from textarea (JSON format) + permissions_str = data.get("permissions") + if permissions_str: + try: + perms = json.loads(permissions_str) + if isinstance(perms, list): + model.permissions = perms + else: + raise ValueError("Permissions must be a JSON array") + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid permissions format: {str(e)}. Use JSON array like: [\"feed.waiting-time:write\"]" + ) + + # Auto-generate key for new records if not provided + if is_created and not model.key_hash: + plain_key = generate_api_key() model.key_prefix = get_prefix(plain_key) model.key_hash = hash_api_key(plain_key) - - permissions_csv = data.get("permissions_csv") - if permissions_csv is not None: - perms = [p.strip() for p in permissions_csv.split(",") if p.strip()] - model.permissions = perms + # Store in session for display after creation + request.session[f"new_api_key_{model.client_id}"] = plain_key def mount_admin(app): + import os + auth_backend = AdminAuth(secret_key=settings.ADMIN_SECRET_KEY) - admin = Admin(app=app, engine=engine, authentication_backend=auth_backend) + + # Configure templates directory + templates_dir = os.path.join(os.path.dirname(__file__), "templates") + + admin = Admin( + app=app, + engine=engine, + authentication_backend=auth_backend, + templates_dir=templates_dir + ) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) @@ -105,3 +138,67 @@ def mount_admin(app): return {"key_id": api_key.id, "api_key": plain_key, "permissions": perms} finally: db.close() + + @app.post("/admin/api-keys/{key_id}/regenerate") + async def _admin_regenerate_api_key(request: Request, key_id: int): + """Regenerate API key - creates new key while preserving permissions and other settings.""" + if not request.session.get("admin"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + db = SessionLocal() + try: + api_key = db.get(ApiKey, key_id) + if not api_key: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found") + + # Generate new key + plain_key = generate_api_key() + api_key.key_prefix = get_prefix(plain_key) + api_key.key_hash = hash_api_key(plain_key) + + db.commit() + db.refresh(api_key) + + return JSONResponse({ + "success": True, + "key_id": api_key.id, + "api_key": plain_key, + "key_prefix": api_key.key_prefix, + "permissions": api_key.permissions, + "message": "API key regenerated successfully. Please save this key - it won't be shown again!" + }) + finally: + db.close() + + @app.get("/admin/api-keys/{key_id}/view") + async def _admin_view_api_key(request: Request, key_id: int): + """View API key from session (only works for newly created/regenerated keys).""" + if not request.session.get("admin"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + db = SessionLocal() + try: + api_key = db.get(ApiKey, key_id) + if not api_key: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found") + + # Check if there's a stored key in session + session_key = f"new_api_key_{api_key.client_id}" + plain_key = request.session.get(session_key) + + if plain_key: + # Clear from session after viewing + request.session.pop(session_key, None) + return JSONResponse({ + "success": True, + "api_key": plain_key, + "key_prefix": api_key.key_prefix, + "message": "This is the only time you can view this key!" + }) + else: + return JSONResponse({ + "success": False, + "message": "API key cannot be retrieved. Keys can only be viewed once after creation or regeneration." + }, status_code=400) + finally: + db.close() diff --git a/03-apiservice/app/templates/apikey_details.html b/03-apiservice/app/templates/apikey_details.html new file mode 100644 index 0000000..54bcb53 --- /dev/null +++ b/03-apiservice/app/templates/apikey_details.html @@ -0,0 +1,96 @@ +{% extends "sqladmin/details.html" %} + +{% block content %} +{{ super() }} + +
+
+
API Key Actions
+
+
+ + + + +
+
+ + +{% endblock %}