diff --git a/.env.development b/.env.development index cbd3a99..0e2afa4 100644 --- a/.env.development +++ b/.env.development @@ -21,3 +21,6 @@ AUTHENTIK_USERINFO_ENDPOINT=https://auth.ose.tw/application/o/userinfo/ PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 INTERNAL_SHARED_SECRET=CHANGE_ME +ADMIN_ALLOWLIST_EMAILS=chris@ose.tw +ADMIN_ALLOWLIST_SUBS=17a35b0a03a752d60617cf2de2bef2aaf0f0f0f53f24e5bf33c3e7abb6c06e87 +ADMIN_REQUIRED_GROUPS= diff --git a/.env.example b/.env.example index fe5a9f5..e28c7a4 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,6 @@ AUTHENTIK_USERINFO_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME +ADMIN_ALLOWLIST_EMAILS= +ADMIN_ALLOWLIST_SUBS= +ADMIN_REQUIRED_GROUPS= diff --git a/app/api/admin.py b/app/api/admin.py index 21b38f6..fa9115f 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -18,8 +18,13 @@ from app.schemas.permissions import ( PermissionRevokeRequest, ) from app.security.api_client_auth import require_api_client +from app.security.admin_guard import require_admin_principal -router = APIRouter(prefix="/admin", tags=["admin"]) +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(require_admin_principal)], +) def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str: diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index d597426..881ac79 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -40,9 +40,14 @@ from app.schemas.catalog import ( ) from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.security.api_client_auth import require_api_client +from app.security.admin_guard import require_admin_principal from app.services.authentik_admin_service import AuthentikAdminService -router = APIRouter(prefix="/admin", tags=["admin"]) +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(require_admin_principal)], +) def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str: diff --git a/app/core/config.py b/app/core/config.py index a36ce6f..cc8964e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -30,6 +30,9 @@ class Settings(BaseSettings): public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] internal_shared_secret: str = "" + admin_allowlist_emails: Annotated[list[str], NoDecode] = [] + admin_allowlist_subs: Annotated[list[str], NoDecode] = [] + admin_required_groups: Annotated[list[str], NoDecode] = [] @field_validator("public_frontend_origins", mode="before") @classmethod @@ -40,6 +43,15 @@ class Settings(BaseSettings): return [] return [origin.strip() for origin in value.split(",") if origin.strip()] + @field_validator("admin_allowlist_emails", "admin_allowlist_subs", "admin_required_groups", mode="before") + @classmethod + def parse_csv(cls, value: str | list[str]) -> list[str]: + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + if not value: + return [] + return [part.strip() for part in value.split(",") if part.strip()] + @property def database_url(self) -> str: return ( diff --git a/app/schemas/auth.py b/app/schemas/auth.py index fc3c301..b89dbdd 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class AuthentikPrincipal(BaseModel): @@ -6,6 +6,7 @@ class AuthentikPrincipal(BaseModel): email: str | None = None name: str | None = None preferred_username: str | None = None + groups: list[str] = Field(default_factory=list) class MeSummaryResponse(BaseModel): diff --git a/app/security/admin_guard.py b/app/security/admin_guard.py new file mode 100644 index 0000000..117b936 --- /dev/null +++ b/app/security/admin_guard.py @@ -0,0 +1,26 @@ +from fastapi import Depends, HTTPException, status + +from app.core.config import get_settings +from app.schemas.auth import AuthentikPrincipal +from app.security.authentik_jwt import require_authenticated_principal + + +def require_admin_principal( + principal: AuthentikPrincipal = Depends(require_authenticated_principal), +) -> AuthentikPrincipal: + settings = get_settings() + allowed_emails = {email.lower() for email in settings.admin_allowlist_emails} + allowed_subs = set(settings.admin_allowlist_subs) + required_groups = {group.lower() for group in settings.admin_required_groups} + + if not allowed_emails and not allowed_subs and not required_groups: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="admin_policy_not_configured") + + email_ok = bool(principal.email and principal.email.lower() in allowed_emails) + sub_ok = principal.sub in allowed_subs + principal_groups = {group.lower() for group in principal.groups} + group_ok = bool(required_groups.intersection(principal_groups)) + + if not (email_ok or sub_ok or group_ok): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_forbidden") + return principal diff --git a/app/security/authentik_jwt.py b/app/security/authentik_jwt.py index af96375..2d9aeb0 100644 --- a/app/security/authentik_jwt.py +++ b/app/security/authentik_jwt.py @@ -63,7 +63,7 @@ class AuthentikTokenVerifier: return None def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal: - if principal.email and (principal.name or principal.preferred_username): + if principal.email and (principal.name or principal.preferred_username) and principal.groups: return principal if not self.userinfo_endpoint: return principal @@ -91,11 +91,16 @@ class AuthentikTokenVerifier: preferred_username = principal.preferred_username or ( data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None ) + groups = principal.groups + payload_groups = data.get("groups") + if isinstance(payload_groups, list): + groups = [str(g) for g in payload_groups if str(g)] return AuthentikPrincipal( sub=principal.sub, email=email, name=name, preferred_username=preferred_username, + groups=groups, ) def verify_access_token(self, token: str) -> AuthentikPrincipal: @@ -142,6 +147,7 @@ class AuthentikTokenVerifier: email=claims.get("email"), name=claims.get("name"), preferred_username=claims.get("preferred_username"), + groups=[str(g) for g in claims.get("groups", []) if str(g)] if isinstance(claims.get("groups"), list) else [], ) return self._enrich_from_userinfo(principal, token)