feat: add authentik admin user sync endpoint
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
backend/app/schemas/authentik_admin.py
Normal file
13
backend/app/schemas/authentik_admin.py
Normal 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
|
||||||
75
backend/app/services/authentik_admin_service.py
Normal file
75
backend/app/services/authentik_admin_service.py
Normal 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")
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
2
backend/scripts/migrate_add_authentik_user_id.sql
Normal file
2
backend/scripts/migrate_add_authentik_user_id.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS authentik_user_id INTEGER;
|
||||||
19
backend/tests/test_internal_authentik_sync.py
Normal file
19
backend/tests/test_internal_authentik_sync.py
Normal 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"
|
||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user