From e9717d5214883e2f65eeff2f54f3a1f783fa21c4 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 --- .gitignore | 23 ++++ backend/.env.example | 16 +++ backend/.env.production.example | 16 +++ backend/README.md | 25 +++++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/admin.py | 60 +++++++++++ backend/app/api/internal.py | 60 +++++++++++ backend/app/core/__init__.py | 1 + backend/app/core/config.py | 46 ++++++++ backend/app/db/__init__.py | 1 + backend/app/db/base.py | 5 + backend/app/db/session.py | 18 ++++ backend/app/main.py | 15 +++ backend/app/models/__init__.py | 5 + backend/app/models/api_client.py | 31 ++++++ backend/app/models/permission.py | 31 ++++++ backend/app/models/user.py | 23 ++++ backend/app/repositories/__init__.py | 1 + backend/app/repositories/permissions_repo.py | 63 +++++++++++ backend/app/repositories/users_repo.py | 38 +++++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/permissions.py | 31 ++++++ backend/app/schemas/users.py | 8 ++ backend/app/security/__init__.py | 1 + backend/app/security/api_client_auth.py | 75 +++++++++++++ backend/app/services/__init__.py | 1 + backend/app/services/permission_service.py | 13 +++ backend/pyproject.toml | 29 +++++ backend/scripts/init_schema.sql | 30 ++++++ backend/tests/test_healthz.py | 10 ++ docs/API_CLIENTS_SQL.sql | 106 +++++++++++++++++++ docs/ARCHITECTURE_AND_CONFIG.md | 23 ++++ docs/BACKEND_ARCHITECTURE.md | 82 ++++++++++++++ docs/BACKEND_BOOTSTRAP.md | 26 +++++ docs/FRONTEND_ARCHITECTURE.md | 55 ++++++++++ 36 files changed, 971 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.env.example create mode 100644 backend/.env.production.example create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/internal.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/api_client.py create mode 100644 backend/app/models/permission.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/permissions_repo.py create mode 100644 backend/app/repositories/users_repo.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/permissions.py create mode 100644 backend/app/schemas/users.py create mode 100644 backend/app/security/__init__.py create mode 100644 backend/app/security/api_client_auth.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/permission_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/scripts/init_schema.sql create mode 100644 backend/tests/test_healthz.py create mode 100644 docs/API_CLIENTS_SQL.sql create mode 100644 docs/ARCHITECTURE_AND_CONFIG.md create mode 100644 docs/BACKEND_ARCHITECTURE.md create mode 100644 docs/BACKEND_BOOTSTRAP.md create mode 100644 docs/FRONTEND_ARCHITECTURE.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..247163f --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.ruff_cache/ + +# Virtual environments +.venv/ +venv/ + +# Env files +.env +.env.* +!.env.example +!.env.production.example + +# macOS +.DS_Store + +# Build metadata +*.egg-info/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4e33275 --- /dev/null +++ b/backend/.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/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 0000000..e031c75 --- /dev/null +++ b/backend/.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/backend/README.md b/backend/README.md new file mode 100644 index 0000000..fbb8e7d --- /dev/null +++ b/backend/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/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..f00e47d --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""memberapi backend package.""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..f7ec5ce --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API routers.""" diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..947ed36 --- /dev/null +++ b/backend/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/backend/app/api/internal.py b/backend/app/api/internal.py new file mode 100644 index 0000000..970c04d --- /dev/null +++ b/backend/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/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..d1d134a --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Core settings and constants.""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..d546fa2 --- /dev/null +++ b/backend/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/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..9a2ecb3 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""Database wiring.""" diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..83bc48a --- /dev/null +++ b/backend/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/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..141e51f --- /dev/null +++ b/backend/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/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..f4457bd --- /dev/null +++ b/backend/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/backend/app/models/api_client.py b/backend/app/models/api_client.py new file mode 100644 index 0000000..e865cd1 --- /dev/null +++ b/backend/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/backend/app/models/permission.py b/backend/app/models/permission.py new file mode 100644 index 0000000..d19af29 --- /dev/null +++ b/backend/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/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..a4487e5 --- /dev/null +++ b/backend/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/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..4e2fa5e --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1 @@ +"""Repository layer.""" diff --git a/backend/app/repositories/permissions_repo.py b/backend/app/repositories/permissions_repo.py new file mode 100644 index 0000000..82ab800 --- /dev/null +++ b/backend/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/backend/app/repositories/users_repo.py b/backend/app/repositories/users_repo.py new file mode 100644 index 0000000..cedb8df --- /dev/null +++ b/backend/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/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..f391682 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas.""" diff --git a/backend/app/schemas/permissions.py b/backend/app/schemas/permissions.py new file mode 100644 index 0000000..a868aa3 --- /dev/null +++ b/backend/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/backend/app/schemas/users.py b/backend/app/schemas/users.py new file mode 100644 index 0000000..b63f6b2 --- /dev/null +++ b/backend/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/backend/app/security/__init__.py b/backend/app/security/__init__.py new file mode 100644 index 0000000..218069a --- /dev/null +++ b/backend/app/security/__init__.py @@ -0,0 +1 @@ +"""Security dependencies and guards.""" diff --git a/backend/app/security/api_client_auth.py b/backend/app/security/api_client_auth.py new file mode 100644 index 0000000..4854ae4 --- /dev/null +++ b/backend/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/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..02dea84 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer.""" diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py new file mode 100644 index 0000000..b2e6788 --- /dev/null +++ b/backend/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/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..820a7b5 --- /dev/null +++ b/backend/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/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql new file mode 100644 index 0000000..0fb3ff4 --- /dev/null +++ b/backend/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/backend/tests/test_healthz.py b/backend/tests/test_healthz.py new file mode 100644 index 0000000..b8d26dd --- /dev/null +++ b/backend/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" diff --git a/docs/API_CLIENTS_SQL.sql b/docs/API_CLIENTS_SQL.sql new file mode 100644 index 0000000..e91281e --- /dev/null +++ b/docs/API_CLIENTS_SQL.sql @@ -0,0 +1,106 @@ +-- member_center: API 呼叫方白名單表 +-- 位置: public schema + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'client_status') THEN + CREATE TYPE client_status AS ENUM ('active', 'inactive'); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS api_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status client_status NOT NULL DEFAULT 'active', + + -- 只存 hash,不存明文 key + api_key_hash TEXT NOT NULL, + + -- 可先留空,之後再嚴格化 + allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb, + allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb, + allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb, + + rate_limit_per_min INTEGER, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status); +CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at); + +CREATE OR REPLACE FUNCTION set_updated_at_api_clients() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_api_clients_set_updated_at' + ) THEN + CREATE TRIGGER trg_api_clients_set_updated_at + BEFORE UPDATE ON api_clients + FOR EACH ROW EXECUTE FUNCTION set_updated_at_api_clients(); + END IF; +END $$; + +-- 建議初始化 2~3 個 client(api_key_hash 先放占位,後續再更新) +INSERT INTO api_clients ( + client_key, + name, + status, + api_key_hash, + allowed_origins, + allowed_ips, + allowed_paths, + rate_limit_per_min +) +VALUES + ( + 'mkt-backend', + 'MKT Backend Service', + 'active', + 'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH', + '[]'::jsonb, + '[]'::jsonb, + '["/internal/users/upsert-by-sub", "/internal/permissions"]'::jsonb, + 600 + ), + ( + 'admin-frontend', + 'Admin Frontend', + 'active', + 'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH', + '["https://admin.ose.tw", "https://member.ose.tw"]'::jsonb, + '[]'::jsonb, + '["/admin"]'::jsonb, + 300 + ), + ( + 'ops-local', + 'Ops Local Tooling', + 'inactive', + 'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH', + '[]'::jsonb, + '["127.0.0.1"]'::jsonb, + '["/internal", "/admin"]'::jsonb, + 120 + ) +ON CONFLICT (client_key) DO NOTHING; + +COMMIT; + +-- 快速檢查 +-- SELECT client_key, status, expires_at, created_at FROM api_clients ORDER BY client_key; diff --git a/docs/ARCHITECTURE_AND_CONFIG.md b/docs/ARCHITECTURE_AND_CONFIG.md new file mode 100644 index 0000000..ced1e39 --- /dev/null +++ b/docs/ARCHITECTURE_AND_CONFIG.md @@ -0,0 +1,23 @@ +# member 系統文件入口(Architecture & Config) + +## 入口說明 +這份文件是入口索引。若你只要快速開始,先看: +1. `docs/BACKEND_BOOTSTRAP.md` +2. `docs/BACKEND_ARCHITECTURE.md` +3. `docs/FRONTEND_ARCHITECTURE.md` + +## 文件地圖 +- `docs/BACKEND_BOOTSTRAP.md` + - 後端啟動步驟(環境、安裝、建表、啟動) +- `docs/BACKEND_ARCHITECTURE.md` + - memberapi 後端模組、資料流、API、安全策略 +- `docs/FRONTEND_ARCHITECTURE.md` + - member 前端架構(由前端 AI 接手) +- `docs/API_CLIENTS_SQL.sql` + - `api_clients` 白名單表與初始資料 SQL + +## 目前狀態(2026-03-29) +- 後端骨架:已建立(FastAPI + SQLAlchemy) +- 核心 API:已建立(health/internal/admin) +- API key 驗證:已建立(`X-Client-Key` + `X-API-Key`) +- Authentik 深度整合:待補(目前先保留介接欄位與流程位置) diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md new file mode 100644 index 0000000..276bc46 --- /dev/null +++ b/docs/BACKEND_ARCHITECTURE.md @@ -0,0 +1,82 @@ +# memberapi.ose.tw 後端架構(FastAPI) + +## 1. 目標與邊界 +- 網域:`memberapi.ose.tw` +- 角色:會員中心後端真相來源(User + Permission) +- 範圍: + - user upsert(以 `authentik_sub` 為跨系統主鍵) + - permission grant/revoke + - permission snapshot 提供給其他系統 +- 不在本服務處理: + - Authentik OIDC 流程頁與 UI + - 前端互動邏輯 + +## 2. 技術棧 +- Python 3.12 +- FastAPI +- SQLAlchemy 2.0 +- PostgreSQL(psycopg) +- Pydantic Settings + +## 3. 後端目錄(已建立) +- `backend/app/main.py` +- `backend/app/api/` + - `internal.py` + - `admin.py` +- `backend/app/core/config.py` +- `backend/app/db/session.py` +- `backend/app/models/` + - `user.py` + - `permission.py` + - `api_client.py` +- `backend/app/repositories/` + - `users_repo.py` + - `permissions_repo.py` +- `backend/app/security/api_client_auth.py` +- `backend/scripts/init_schema.sql` +- `backend/.env.example` +- `backend/.env.production.example` + +## 4. 資料模型 +- `users` + - `id`, `authentik_sub`(unique), `email`, `display_name`, `is_active`, timestamps +- `permissions` + - `id`, `user_id`, `scope_type`, `scope_id`, `module`, `action`, `created_at` + - unique constraint: `(user_id, scope_type, scope_id, module, action)` +- `api_clients`(由 `docs/API_CLIENTS_SQL.sql` 建立) + - `client_key`, `api_key_hash`, `status`, allowlist, expires/rate-limit 欄位 + +## 5. API 設計(MVP) +- 健康檢查 + - `GET /healthz` +- 內部路由(系統對系統) + - `POST /internal/users/upsert-by-sub` + - `GET /internal/permissions/{authentik_sub}/snapshot` + - header: `X-Internal-Secret` +- 管理路由(後台/API client) + - `POST /admin/permissions/grant` + - `POST /admin/permissions/revoke` + - headers: `X-Client-Key`, `X-API-Key` + +## 6. 安全策略 +- `admin` 路由強制 API client 驗證: + - client 必須存在且 `status=active` + - `expires_at` 未過期 + - `api_key_hash` 驗證(支援 `sha256:` 與 bcrypt/argon2) + - allowlist 驗證(origin/ip/path) +- `internal` 路由使用 `X-Internal-Secret` 做服務間驗證 +- 建議上線前: + - 將 `.env` 範本中的明文密碼改為部署平台 secret + - API key 全部改為 argon2/bcrypt hash + +## 7. 與其他系統資料流 +1. mkt/admin 後端登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub` +2. 權限調整走 `/admin/permissions/grant|revoke` +3. 需要授權判斷時,呼叫 `/internal/permissions/{sub}/snapshot` +4. mkt 系統可本地快取 snapshot,並做定時補償 + +## 8. 下一階段(建議) +- 加入 Alembic migration +- 為 permission/action 加 enum 與驗證規則 +- 增加 audit log(誰在何時授權/撤銷) +- 加入 rate-limit 與可觀測性(metrics + request id) diff --git a/docs/BACKEND_BOOTSTRAP.md b/docs/BACKEND_BOOTSTRAP.md new file mode 100644 index 0000000..483259d --- /dev/null +++ b/docs/BACKEND_BOOTSTRAP.md @@ -0,0 +1,26 @@ +# Backend Bootstrap(memberapi) + +## 1. 環境準備 +```bash +cd member.ose.tw/backend +python -m venv .venv +source .venv/bin/activate +pip install -e . +cp .env.example .env +``` + +## 2. 建立資料表 +1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql` +2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql` + +## 3. 啟動服務 +```bash +cd member.ose.tw/backend +source .venv/bin/activate +uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload +``` + +## 4. 快速驗證 +```bash +curl -sS http://127.0.0.1:8000/healthz +``` diff --git a/docs/FRONTEND_ARCHITECTURE.md b/docs/FRONTEND_ARCHITECTURE.md new file mode 100644 index 0000000..408e0d7 --- /dev/null +++ b/docs/FRONTEND_ARCHITECTURE.md @@ -0,0 +1,55 @@ +# member.ose.tw 前端架構(Vue) + +## 1. 角色定位 +- 網域:`member.ose.tw` +- 功能:會員中心前台(管理 users/公司/站台/權限) +- 注意:密碼流程不在這裡做,自動導 Authentik flow + +## 2. 技術棧 +- `Vue 3 + Vite` +- `Element Plus` +- `Tailwind CSS` +- `Vue Router` +- `Pinia`(建議) + +## 3. 建議目錄 +- `frontend/src/main.ts` +- `frontend/src/router/` +- `frontend/src/stores/` +- `frontend/src/api/`(axios client + modules) +- `frontend/src/pages/` + - `users/` + - `companies/` + - `sites/` + - `permissions/` +- `frontend/src/components/` + +## 4. 第一版頁面 +- 使用者列表/搜尋 +- 公司列表 +- 站台列表 +- 權限指派(user x scope x module x action) + +## 5. 權限與登入 +- 透過 Authentik OIDC 登入 +- 前端持有 backend session/token,不直接操作 Authentik admin API +- 「修改密碼 / 忘記密碼」按鈕導 Authentik 使用者流程頁 + +## 6. API 串接 +- `VITE_API_BASE_URL=https://memberapi.ose.tw` +- 所有請求帶上必要 auth header +- 管理操作走 `memberapi` 的 admin 路由 + +## 7. UI/互動規則 +- Element Plus 做表單/表格/對話框 +- Tailwind 做 spacing/layout/快速樣式 +- 權限編輯畫面要清楚顯示: + - scope_type(company/site) + - scope_id + - module + - action(view/edit) + +## 8. 第一版不做項目 +- 不做密碼重設畫面 +- 不做複雜儀表板 +- 不做跨系統 SSO 管理 UI