feat: bootstrap backend MVP and architecture docs

This commit is contained in:
Chris
2026-03-29 23:01:34 +08:00
commit 3ca207d24a
30 changed files with 656 additions and 0 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# memberapi.ose.tw backend env (development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
AUTHENTIK_BASE_URL=
AUTHENTIK_ADMIN_TOKEN=
AUTHENTIK_VERIFY_TLS=false
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

16
.env.production.example Normal file
View File

@@ -0,0 +1,16 @@
# memberapi.ose.tw backend env (production)
APP_ENV=production
PORT=8000
DB_HOST=postgresql
DB_PORT=5432
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
AUTHENTIK_BASE_URL=
AUTHENTIK_ADMIN_TOKEN=
AUTHENTIK_VERIFY_TLS=false
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# memberapi.ose.tw backend
## Quick start
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e .
cp .env.example .env
uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```
## Required DB setup
1. Initialize API client whitelist table with `docs/API_CLIENTS_SQL.sql`.
2. Initialize core tables with `backend/scripts/init_schema.sql`.
## Main APIs
- `GET /healthz`
- `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{authentik_sub}/snapshot`
- `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke`

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""memberapi backend package."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API routers."""

60
app/api/admin.py Normal file
View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/permissions/grant")
def grant_permission(
payload: PermissionGrantRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=payload.authentik_sub,
email=payload.email,
display_name=payload.display_name,
is_active=True,
)
permission = perms_repo.create_if_not_exists(
user_id=user.id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
module=payload.module,
action=payload.action,
)
return {"permission_id": permission.id, "result": "granted"}
@router.post("/permissions/revoke")
def revoke_permission(
payload: PermissionRevokeRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.get_by_sub(payload.authentik_sub)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
deleted = perms_repo.revoke(
user_id=user.id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
module=payload.module,
action=payload.action,
)
return {"deleted": deleted, "result": "revoked"}

60
app/api/internal.py Normal file
View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.users import UserUpsertBySubRequest
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/internal", tags=["internal"])
def verify_internal_secret(x_internal_secret: str = Header(alias="X-Internal-Secret")) -> None:
settings = get_settings()
if not settings.internal_shared_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="internal_secret_not_configured")
if x_internal_secret != settings.internal_shared_secret:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_internal_secret")
@router.post("/users/upsert-by-sub")
def upsert_user_by_sub(
payload: UserUpsertBySubRequest,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> dict[str, str | bool | None]:
repo = UsersRepository(db)
user = repo.upsert_by_sub(
authentik_sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
)
return {
"id": user.id,
"sub": user.authentik_sub,
"email": user.email,
"display_name": user.display_name,
"is_active": user.is_active,
}
@router.get("/permissions/{authentik_sub}/snapshot", response_model=PermissionSnapshotResponse)
def get_permission_snapshot(
authentik_sub: str,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.get_by_sub(authentik_sub)
if user is None:
return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[])
permissions = perms_repo.list_by_user_id(user.id)
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)

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core settings and constants."""

46
app/core/config.py Normal file
View File

@@ -0,0 +1,46 @@
from functools import lru_cache
from typing import Annotated
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "development"
port: int = 8000
db_host: str = "127.0.0.1"
db_port: int = 54321
db_name: str = "member_center"
db_user: str = "member_ose"
db_password: str = ""
authentik_base_url: str = ""
authentik_admin_token: str = ""
authentik_verify_tls: bool = False
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
internal_shared_secret: str = ""
@field_validator("public_frontend_origins", mode="before")
@classmethod
def parse_origins(cls, value: str | list[str]) -> list[str]:
if isinstance(value, list):
return value
if not value:
return []
return [origin.strip() for origin in value.split(",") if origin.strip()]
@property
def database_url(self) -> str:
return (
"postgresql+psycopg://"
f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
)
@lru_cache
def get_settings() -> Settings:
return Settings()

1
app/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Database wiring."""

5
app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

18
app/db/session.py Normal file
View File

@@ -0,0 +1,18 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

15
app/main.py Normal file
View File

@@ -0,0 +1,15 @@
from fastapi import FastAPI
from app.api.admin import router as admin_router
from app.api.internal import router as internal_router
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
@app.get("/healthz", tags=["health"])
def healthz() -> dict[str, str]:
return {"status": "ok"}
app.include_router(internal_router)
app.include_router(admin_router)

5
app/models/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from app.models.api_client import ApiClient
from app.models.permission import Permission
from app.models.user import User
__all__ = ["ApiClient", "Permission", "User"]

31
app/models/api_client.py Normal file
View File

@@ -0,0 +1,31 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ApiClient(Base):
__tablename__ = "api_clients"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
client_key: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
api_key_hash: Mapped[str] = mapped_column(Text, nullable=False)
allowed_origins: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
allowed_ips: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
allowed_paths: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
rate_limit_per_min: Mapped[int | None] = mapped_column(Integer)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

31
app/models/permission.py Normal file
View File

@@ -0,0 +1,31 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Permission(Base):
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"user_id",
"scope_type",
"scope_id",
"module",
"action",
name="uq_permissions_user_scope_module_action",
),
)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
scope_type: Mapped[str] = mapped_column(String(32), nullable=False)
scope_id: Mapped[str] = mapped_column(String(128), nullable=False)
module: Mapped[str] = mapped_column(String(128), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

23
app/models/user.py Normal file
View File

@@ -0,0 +1,23 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class User(Base):
__tablename__ = "users"
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)
email: Mapped[str | None] = mapped_column(String(320))
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -0,0 +1 @@
"""Repository layer."""

View File

@@ -0,0 +1,63 @@
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.permission import Permission
class PermissionsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_by_user_id(self, user_id: str) -> list[Permission]:
stmt = select(Permission).where(Permission.user_id == user_id)
return list(self.db.scalars(stmt).all())
def create_if_not_exists(
self,
user_id: str,
scope_type: str,
scope_id: str,
module: str,
action: str,
) -> Permission:
stmt = select(Permission).where(
Permission.user_id == user_id,
Permission.scope_type == scope_type,
Permission.scope_id == scope_id,
Permission.module == module,
Permission.action == action,
)
existing = self.db.scalar(stmt)
if existing:
return existing
item = Permission(
user_id=user_id,
scope_type=scope_type,
scope_id=scope_id,
module=module,
action=action,
)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item
def revoke(
self,
user_id: str,
scope_type: str,
scope_id: str,
module: str,
action: str,
) -> int:
stmt = delete(Permission).where(
Permission.user_id == user_id,
Permission.scope_type == scope_type,
Permission.scope_id == scope_id,
Permission.module == module,
Permission.action == action,
)
result = self.db.execute(stmt)
self.db.commit()
return int(result.rowcount or 0)

View File

@@ -0,0 +1,38 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.user import User
class UsersRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_sub(self, authentik_sub: str) -> User | None:
stmt = select(User).where(User.authentik_sub == authentik_sub)
return self.db.scalar(stmt)
def upsert_by_sub(
self,
authentik_sub: str,
email: str | None,
display_name: str | None,
is_active: bool,
) -> User:
user = self.get_by_sub(authentik_sub)
if user is None:
user = User(
authentik_sub=authentik_sub,
email=email,
display_name=display_name,
is_active=is_active,
)
self.db.add(user)
else:
user.email = email
user.display_name = display_name
user.is_active = is_active
self.db.commit()
self.db.refresh(user)
return user

1
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Pydantic schemas."""

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
class PermissionGrantRequest(BaseModel):
authentik_sub: str
email: str | None = None
display_name: str | None = None
scope_type: str
scope_id: str
module: str
action: str
class PermissionRevokeRequest(BaseModel):
authentik_sub: str
scope_type: str
scope_id: str
module: str
action: str
class PermissionItem(BaseModel):
scope_type: str
scope_id: str
module: str
action: str
class PermissionSnapshotResponse(BaseModel):
authentik_sub: str
permissions: list[PermissionItem]

8
app/schemas/users.py Normal file
View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel
class UserUpsertBySubRequest(BaseModel):
sub: str
email: str | None = None
display_name: str | None = None
is_active: bool = True

1
app/security/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Security dependencies and guards."""

View File

@@ -0,0 +1,75 @@
import hashlib
import hmac
from datetime import datetime, timezone
from fastapi import Depends, Header, HTTPException, Request, status
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
def _verify_api_key(plain_key: str, stored_hash: str) -> bool:
# Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production.
if stored_hash.startswith("sha256:"):
hex_hash = hashlib.sha256(plain_key.encode("utf-8")).hexdigest()
return hmac.compare_digest(stored_hash.removeprefix("sha256:"), hex_hash)
try:
return pwd_context.verify(plain_key, stored_hash)
except Exception:
return False
def _is_expired(expires_at: datetime | None) -> bool:
if expires_at is None:
return False
now = datetime.now(timezone.utc)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return expires_at <= now
def _check_request_whitelist(client: ApiClient, request: Request) -> None:
origin = request.headers.get("origin")
client_ip = request.client.host if request.client else None
path = request.url.path
if client.allowed_origins and origin and origin not in client.allowed_origins:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="origin_not_allowed")
if client.allowed_ips and client_ip and client_ip not in client.allowed_ips:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ip_not_allowed")
if client.allowed_paths and not any(path.startswith(prefix) for prefix in client.allowed_paths):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="path_not_allowed")
def require_api_client(
request: Request,
x_client_key: str = Header(alias="X-Client-Key"),
x_api_key: str = Header(alias="X-API-Key"),
db: Session = Depends(get_db),
) -> ApiClient:
stmt = select(ApiClient).where(ApiClient.client_key == x_client_key)
client = db.scalar(stmt)
if client is None or client.status != "active":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_client")
if _is_expired(client.expires_at):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="client_expired")
if not _verify_api_key(x_api_key, client.api_key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_api_key")
_check_request_whitelist(client, request)
client.last_used_at = datetime.now(timezone.utc)
db.commit()
return client

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Service layer."""

View File

@@ -0,0 +1,13 @@
from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse
class PermissionService:
@staticmethod
def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str, str]]) -> PermissionSnapshotResponse:
return PermissionSnapshotResponse(
authentik_sub=authentik_sub,
permissions=[
PermissionItem(scope_type=s_type, scope_id=s_id, module=module, action=action)
for s_type, s_id, module, action in permissions
],
)

29
pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[project]
name = "memberapi-backend"
version = "0.1.0"
description = "memberapi.ose.tw backend"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116.0",
"uvicorn[standard]>=0.35.0",
"sqlalchemy>=2.0.44",
"psycopg[binary]>=3.2.9",
"pydantic-settings>=2.11.0",
"python-dotenv>=1.1.1",
"passlib[bcrypt]>=1.7.4",
]
[project.optional-dependencies]
dev = [
"pytest>=8.4.2",
"httpx>=0.28.1",
"ruff>=0.13.0",
]
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-q"
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

30
scripts/init_schema.sql Normal file
View File

@@ -0,0 +1,30 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_sub VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(320),
display_name VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_id VARCHAR(128) NOT NULL,
module VARCHAR(128) NOT NULL,
action VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_permissions_user_scope_module_action
UNIQUE (user_id, scope_type, scope_id, module, action)
);
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
COMMIT;

10
tests/test_healthz.py Normal file
View File

@@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
def test_healthz() -> None:
client = TestClient(app)
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"