feat: add authentik admin user sync endpoint

This commit is contained in:
Chris
2026-03-29 23:08:52 +08:00
parent 5790878c87
commit dbd2ccf43f
13 changed files with 160 additions and 3 deletions

View File

@@ -29,6 +29,13 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- Optional: - Optional:
- `AUTHENTIK_AUDIENCE` (enables audience claim validation) - `AUTHENTIK_AUDIENCE` (enables audience claim validation)
## Authentik Admin API setup
- Required for `/internal/authentik/users/ensure`:
- `AUTHENTIK_BASE_URL`
- `AUTHENTIK_ADMIN_TOKEN`
- `AUTHENTIK_VERIFY_TLS`
## Main APIs ## Main APIs
- `GET /healthz` - `GET /healthz`
@@ -36,5 +43,6 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `GET /me/permissions/snapshot` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required)
- `POST /internal/users/upsert-by-sub` - `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{authentik_sub}/snapshot` - `GET /internal/permissions/{authentik_sub}/snapshot`
- `POST /internal/authentik/users/ensure`
- `POST /admin/permissions/grant` - `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke` - `POST /admin/permissions/revoke`

View File

@@ -5,8 +5,10 @@ from app.core.config import get_settings
from app.db.session import get_db from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository from app.repositories.users_repo import UsersRepository
from app.schemas.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse
from app.schemas.permissions import PermissionSnapshotResponse from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.users import UserUpsertBySubRequest from app.schemas.users import UserUpsertBySubRequest
from app.services.authentik_admin_service import AuthentikAdminService
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
router = APIRouter(prefix="/internal", tags=["internal"]) router = APIRouter(prefix="/internal", tags=["internal"])
@@ -36,6 +38,7 @@ def upsert_user_by_sub(
return { return {
"id": user.id, "id": user.id,
"sub": user.authentik_sub, "sub": user.authentik_sub,
"authentik_user_id": user.authentik_user_id,
"email": user.email, "email": user.email,
"display_name": user.display_name, "display_name": user.display_name,
"is_active": user.is_active, "is_active": user.is_active,
@@ -58,3 +61,29 @@ def get_permission_snapshot(
permissions = perms_repo.list_by_user_id(user.id) permissions = perms_repo.list_by_user_id(user.id)
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions] tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples) return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples)
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
def ensure_authentik_user(
payload: AuthentikEnsureUserRequest,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> AuthentikEnsureUserResponse:
settings = get_settings()
authentik_service = AuthentikAdminService(settings=settings)
sync_result = authentik_service.ensure_user(
sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
)
users_repo = UsersRepository(db)
users_repo.upsert_by_sub(
authentik_sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
authentik_user_id=sync_result.user_id,
)
return AuthentikEnsureUserResponse(authentik_user_id=sync_result.user_id, action=sync_result.action)

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Boolean, DateTime, String, func from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -13,6 +13,7 @@ class User(Base):
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
authentik_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) authentik_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
authentik_user_id: Mapped[int | None] = mapped_column(Integer)
email: Mapped[str | None] = mapped_column(String(320)) email: Mapped[str | None] = mapped_column(String(320))
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

View File

@@ -18,17 +18,21 @@ class UsersRepository:
email: str | None, email: str | None,
display_name: str | None, display_name: str | None,
is_active: bool, is_active: bool,
authentik_user_id: int | None = None,
) -> User: ) -> User:
user = self.get_by_sub(authentik_sub) user = self.get_by_sub(authentik_sub)
if user is None: if user is None:
user = User( user = User(
authentik_sub=authentik_sub, authentik_sub=authentik_sub,
authentik_user_id=authentik_user_id,
email=email, email=email,
display_name=display_name, display_name=display_name,
is_active=is_active, is_active=is_active,
) )
self.db.add(user) self.db.add(user)
else: else:
if authentik_user_id is not None:
user.authentik_user_id = authentik_user_id
user.email = email user.email = email
user.display_name = display_name user.display_name = display_name
user.is_active = is_active user.is_active = is_active

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel
class AuthentikEnsureUserRequest(BaseModel):
sub: str
email: str
display_name: str | None = None
is_active: bool = True
class AuthentikEnsureUserResponse(BaseModel):
authentik_user_id: int
action: str

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass
import httpx
from fastapi import HTTPException, status
from app.core.config import Settings
@dataclass
class AuthentikSyncResult:
user_id: int
action: str
class AuthentikAdminService:
def __init__(self, settings: Settings) -> None:
self.base_url = settings.authentik_base_url.rstrip("/")
self.admin_token = settings.authentik_admin_token
self.verify_tls = settings.authentik_verify_tls
if not self.base_url or not self.admin_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="authentik_admin_not_configured",
)
def _client(self) -> httpx.Client:
return httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Bearer {self.admin_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
verify=self.verify_tls,
)
@staticmethod
def _safe_username(sub: str, email: str) -> str:
if email and "@" in email:
return email.split("@", 1)[0]
return sub.replace("|", "_")[:150]
def ensure_user(self, sub: str, email: str, display_name: str | None, is_active: bool = True) -> AuthentikSyncResult:
payload = {
"username": self._safe_username(sub=sub, email=email),
"name": display_name or email,
"email": email,
"is_active": is_active,
}
with self._client() as client:
resp = client.get("/api/v3/core/users/", params={"email": email})
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
data = resp.json()
results = data.get("results") if isinstance(data, dict) else None
existing = results[0] if isinstance(results, list) and results else None
if existing and existing.get("pk") is not None:
user_pk = int(existing["pk"])
patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload)
if patch_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_update_failed")
return AuthentikSyncResult(user_id=user_pk, action="updated")
create_resp = client.post("/api/v3/core/users/", json=payload)
if create_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_create_failed")
created = create_resp.json()
return AuthentikSyncResult(user_id=int(created["pk"]), action="created")

View File

@@ -12,12 +12,12 @@ dependencies = [
"python-dotenv>=1.1.1", "python-dotenv>=1.1.1",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"pyjwt[crypto]>=2.10.1", "pyjwt[crypto]>=2.10.1",
"httpx>=0.28.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.4.2", "pytest>=8.4.2",
"httpx>=0.28.1",
"ruff>=0.13.0", "ruff>=0.13.0",
] ]

View File

@@ -5,6 +5,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_sub VARCHAR(255) NOT NULL UNIQUE, authentik_sub VARCHAR(255) NOT NULL UNIQUE,
authentik_user_id INTEGER,
email VARCHAR(320), email VARCHAR(320),
display_name VARCHAR(255), display_name VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,

View File

@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS authentik_user_id INTEGER;

View File

@@ -0,0 +1,19 @@
from fastapi.testclient import TestClient
from app.main import app
def test_internal_authentik_ensure_requires_config() -> None:
client = TestClient(app)
resp = client.post(
"/internal/authentik/users/ensure",
headers={"X-Internal-Secret": "CHANGE_ME"},
json={
"sub": "authentik-sub-1",
"email": "user@example.com",
"display_name": "User Example",
"is_active": True,
},
)
assert resp.status_code == 503
assert resp.json()["detail"] == "authentik_admin_not_configured"

View File

@@ -21,4 +21,4 @@
- 核心 API已建立health/internal/admin - 核心 API已建立health/internal/admin
- API key 驗證:已建立(`X-Client-Key` + `X-API-Key` - API key 驗證:已建立(`X-Client-Key` + `X-API-Key`
- Authentik JWT 驗證:已建立(`/me` 路由 + JWKS 驗簽) - Authentik JWT 驗證:已建立(`/me` 路由 + JWKS 驗簽)
- Authentik Admin API建立/停用使用者):待補 - Authentik Admin API建立/更新使用者):已建立(`/internal/authentik/users/ensure`

View File

@@ -56,6 +56,7 @@
- 內部路由(系統對系統) - 內部路由(系統對系統)
- `POST /internal/users/upsert-by-sub` - `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{authentik_sub}/snapshot` - `GET /internal/permissions/{authentik_sub}/snapshot`
- `POST /internal/authentik/users/ensure`
- header: `X-Internal-Secret` - header: `X-Internal-Secret`
- 管理路由(後台/API client - 管理路由(後台/API client
- `POST /admin/permissions/grant` - `POST /admin/permissions/grant`
@@ -72,6 +73,9 @@
- `me` 路由使用 Authentik Access Token 驗證: - `me` 路由使用 Authentik Access Token 驗證:
- 使用 `AUTHENTIK_JWKS_URL``AUTHENTIK_ISSUER` 推導 JWKS - 使用 `AUTHENTIK_JWKS_URL``AUTHENTIK_ISSUER` 推導 JWKS
- 可選 `AUTHENTIK_AUDIENCE` 驗證 aud claim - 可選 `AUTHENTIK_AUDIENCE` 驗證 aud claim
- Authentik Admin 整合:
- 使用 `AUTHENTIK_BASE_URL + AUTHENTIK_ADMIN_TOKEN`
- 可透過 `/internal/authentik/users/ensure` 建立或更新 Authentik user
- 建議上線前: - 建議上線前:
-`.env` 範本中的明文密碼改為部署平台 secret -`.env` 範本中的明文密碼改為部署平台 secret
- API key 全部改為 argon2/bcrypt hash - API key 全部改為 argon2/bcrypt hash

View File

@@ -12,6 +12,7 @@ cp .env.example .env
## 2. 建立資料表 ## 2. 建立資料表
1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql` 1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql`
2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql` 2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql`
3. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
## 3. 啟動服務 ## 3. 啟動服務
```bash ```bash