From 3ca207d24ab160542b4379f8c7f05d1de98a36bd Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 23:01:34 +0800 Subject: [PATCH] feat: bootstrap backend MVP and architecture docs --- .env.example | 16 ++++++ .env.production.example | 16 ++++++ README.md | 25 ++++++++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/admin.py | 60 ++++++++++++++++++++++ app/api/internal.py | 60 ++++++++++++++++++++++ app/core/__init__.py | 1 + app/core/config.py | 46 +++++++++++++++++ app/db/__init__.py | 1 + app/db/base.py | 5 ++ app/db/session.py | 18 +++++++ app/main.py | 15 ++++++ app/models/__init__.py | 5 ++ app/models/api_client.py | 31 ++++++++++++ app/models/permission.py | 31 ++++++++++++ app/models/user.py | 23 +++++++++ app/repositories/__init__.py | 1 + app/repositories/permissions_repo.py | 63 +++++++++++++++++++++++ app/repositories/users_repo.py | 38 ++++++++++++++ app/schemas/__init__.py | 1 + app/schemas/permissions.py | 31 ++++++++++++ app/schemas/users.py | 8 +++ app/security/__init__.py | 1 + app/security/api_client_auth.py | 75 ++++++++++++++++++++++++++++ app/services/__init__.py | 1 + app/services/permission_service.py | 13 +++++ pyproject.toml | 29 +++++++++++ scripts/init_schema.sql | 30 +++++++++++ tests/test_healthz.py | 10 ++++ 30 files changed, 656 insertions(+) create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/admin.py create mode 100644 app/api/internal.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/api_client.py create mode 100644 app/models/permission.py create mode 100644 app/models/user.py create mode 100644 app/repositories/__init__.py create mode 100644 app/repositories/permissions_repo.py create mode 100644 app/repositories/users_repo.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/permissions.py create mode 100644 app/schemas/users.py create mode 100644 app/security/__init__.py create mode 100644 app/security/api_client_auth.py create mode 100644 app/services/__init__.py create mode 100644 app/services/permission_service.py create mode 100644 pyproject.toml create mode 100644 scripts/init_schema.sql create mode 100644 tests/test_healthz.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e33275 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..e031c75 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbb8e7d --- /dev/null +++ b/README.md @@ -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` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f00e47d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""memberapi backend package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..f7ec5ce --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API routers.""" diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..947ed36 --- /dev/null +++ b/app/api/admin.py @@ -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"} diff --git a/app/api/internal.py b/app/api/internal.py new file mode 100644 index 0000000..970c04d --- /dev/null +++ b/app/api/internal.py @@ -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) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..d1d134a --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core settings and constants.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..d546fa2 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..9a2ecb3 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +"""Database wiring.""" diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..83bc48a --- /dev/null +++ b/app/db/session.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..141e51f --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..f4457bd --- /dev/null +++ b/app/models/__init__.py @@ -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"] diff --git a/app/models/api_client.py b/app/models/api_client.py new file mode 100644 index 0000000..e865cd1 --- /dev/null +++ b/app/models/api_client.py @@ -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 + ) diff --git a/app/models/permission.py b/app/models/permission.py new file mode 100644 index 0000000..d19af29 --- /dev/null +++ b/app/models/permission.py @@ -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) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..a4487e5 --- /dev/null +++ b/app/models/user.py @@ -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 + ) diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..4e2fa5e --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1 @@ +"""Repository layer.""" diff --git a/app/repositories/permissions_repo.py b/app/repositories/permissions_repo.py new file mode 100644 index 0000000..82ab800 --- /dev/null +++ b/app/repositories/permissions_repo.py @@ -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) diff --git a/app/repositories/users_repo.py b/app/repositories/users_repo.py new file mode 100644 index 0000000..cedb8df --- /dev/null +++ b/app/repositories/users_repo.py @@ -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 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..f391682 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas.""" diff --git a/app/schemas/permissions.py b/app/schemas/permissions.py new file mode 100644 index 0000000..a868aa3 --- /dev/null +++ b/app/schemas/permissions.py @@ -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] diff --git a/app/schemas/users.py b/app/schemas/users.py new file mode 100644 index 0000000..b63f6b2 --- /dev/null +++ b/app/schemas/users.py @@ -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 diff --git a/app/security/__init__.py b/app/security/__init__.py new file mode 100644 index 0000000..218069a --- /dev/null +++ b/app/security/__init__.py @@ -0,0 +1 @@ +"""Security dependencies and guards.""" diff --git a/app/security/api_client_auth.py b/app/security/api_client_auth.py new file mode 100644 index 0000000..4854ae4 --- /dev/null +++ b/app/security/api_client_auth.py @@ -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: 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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..02dea84 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer.""" diff --git a/app/services/permission_service.py b/app/services/permission_service.py new file mode 100644 index 0000000..b2e6788 --- /dev/null +++ b/app/services/permission_service.py @@ -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 + ], + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..820a7b5 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/scripts/init_schema.sql b/scripts/init_schema.sql new file mode 100644 index 0000000..0fb3ff4 --- /dev/null +++ b/scripts/init_schema.sql @@ -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; diff --git a/tests/test_healthz.py b/tests/test_healthz.py new file mode 100644 index 0000000..b8d26dd --- /dev/null +++ b/tests/test_healthz.py @@ -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"