feat(security): enforce admin allowlist guard on admin APIs and attach bearer for admin client
This commit is contained in:
@@ -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
|
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
ADMIN_ALLOWLIST_EMAILS=chris@ose.tw
|
||||||
|
ADMIN_ALLOWLIST_SUBS=17a35b0a03a752d60617cf2de2bef2aaf0f0f0f53f24e5bf33c3e7abb6c06e87
|
||||||
|
ADMIN_REQUIRED_GROUPS=
|
||||||
|
|||||||
@@ -21,3 +21,6 @@ AUTHENTIK_USERINFO_ENDPOINT=
|
|||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
ADMIN_ALLOWLIST_EMAILS=
|
||||||
|
ADMIN_ALLOWLIST_SUBS=
|
||||||
|
ADMIN_REQUIRED_GROUPS=
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ from app.schemas.permissions import (
|
|||||||
PermissionRevokeRequest,
|
PermissionRevokeRequest,
|
||||||
)
|
)
|
||||||
from app.security.api_client_auth import require_api_client
|
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:
|
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
|
||||||
|
|||||||
@@ -40,9 +40,14 @@ from app.schemas.catalog import (
|
|||||||
)
|
)
|
||||||
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
|
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
|
||||||
from app.security.api_client_auth import require_api_client
|
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
|
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:
|
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
||||||
internal_shared_secret: str = ""
|
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")
|
@field_validator("public_frontend_origins", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -40,6 +43,15 @@ class Settings(BaseSettings):
|
|||||||
return []
|
return []
|
||||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
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
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class AuthentikPrincipal(BaseModel):
|
class AuthentikPrincipal(BaseModel):
|
||||||
@@ -6,6 +6,7 @@ class AuthentikPrincipal(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
preferred_username: str | None = None
|
preferred_username: str | None = None
|
||||||
|
groups: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MeSummaryResponse(BaseModel):
|
class MeSummaryResponse(BaseModel):
|
||||||
|
|||||||
26
backend/app/security/admin_guard.py
Normal file
26
backend/app/security/admin_guard.py
Normal file
@@ -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
|
||||||
@@ -63,7 +63,7 @@ class AuthentikTokenVerifier:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
|
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
|
return principal
|
||||||
if not self.userinfo_endpoint:
|
if not self.userinfo_endpoint:
|
||||||
return principal
|
return principal
|
||||||
@@ -91,11 +91,16 @@ class AuthentikTokenVerifier:
|
|||||||
preferred_username = principal.preferred_username or (
|
preferred_username = principal.preferred_username or (
|
||||||
data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None
|
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(
|
return AuthentikPrincipal(
|
||||||
sub=principal.sub,
|
sub=principal.sub,
|
||||||
email=email,
|
email=email,
|
||||||
name=name,
|
name=name,
|
||||||
preferred_username=preferred_username,
|
preferred_username=preferred_username,
|
||||||
|
groups=groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
||||||
@@ -142,6 +147,7 @@ class AuthentikTokenVerifier:
|
|||||||
email=claims.get("email"),
|
email=claims.get("email"),
|
||||||
name=claims.get("name"),
|
name=claims.get("name"),
|
||||||
preferred_username=claims.get("preferred_username"),
|
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)
|
return self._enrich_from_userinfo(principal, token)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ userHttp.interceptors.response.use(
|
|||||||
export const adminHttp = axios.create({ baseURL: BASE_URL })
|
export const adminHttp = axios.create({ baseURL: BASE_URL })
|
||||||
|
|
||||||
adminHttp.interceptors.request.use(config => {
|
adminHttp.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
const clientKey = sessionStorage.getItem('admin_client_key') || ENV_ADMIN_CLIENT_KEY
|
const clientKey = sessionStorage.getItem('admin_client_key') || ENV_ADMIN_CLIENT_KEY
|
||||||
const apiKey = sessionStorage.getItem('admin_api_key') || ENV_ADMIN_API_KEY
|
const apiKey = sessionStorage.getItem('admin_api_key') || ENV_ADMIN_API_KEY
|
||||||
if (clientKey && !sessionStorage.getItem('admin_client_key')) {
|
if (clientKey && !sessionStorage.getItem('admin_client_key')) {
|
||||||
@@ -43,3 +47,14 @@ adminHttp.interceptors.request.use(config => {
|
|||||||
if (apiKey) config.headers['X-API-Key'] = apiKey
|
if (apiKey) config.headers['X-API-Key'] = apiKey
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
adminHttp.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user