feat: bootstrap backend MVP and architecture docs

This commit is contained in:
Chris
2026-03-29 23:01:34 +08:00
commit e9717d5214
36 changed files with 971 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -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/

16
backend/.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

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
backend/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
backend/app/__init__.py Normal file
View File

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

View File

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

60
backend/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"}

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)

View File

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

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()

View File

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

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

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

18
backend/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
backend/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)

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"]

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
)

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)

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

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]

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

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

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
backend/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"

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;

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"

106
docs/API_CLIENTS_SQL.sql Normal file
View File

@@ -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 個 clientapi_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;

View File

@@ -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 深度整合:待補(目前先保留介接欄位與流程位置)

View File

@@ -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
- PostgreSQLpsycopg
- 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:<hex>` 與 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

26
docs/BACKEND_BOOTSTRAP.md Normal file
View File

@@ -0,0 +1,26 @@
# Backend Bootstrapmemberapi
## 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
```

View File

@@ -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_typecompany/site
- scope_id
- module
- actionview/edit
## 8. 第一版不做項目
- 不做密碼重設畫面
- 不做複雜儀表板
- 不做跨系統 SSO 管理 UI