feat: bootstrap backend MVP and architecture docs
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
16
backend/.env.example
Normal 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
backend/.env.production.example
Normal file
16
backend/.env.production.example
Normal 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
25
backend/README.md
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""memberapi backend package."""
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API routers."""
|
||||||
60
backend/app/api/admin.py
Normal file
60
backend/app/api/admin.py
Normal 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
backend/app/api/internal.py
Normal file
60
backend/app/api/internal.py
Normal 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
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core settings and constants."""
|
||||||
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal 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
backend/app/db/__init__.py
Normal file
1
backend/app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Database wiring."""
|
||||||
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
18
backend/app/db/session.py
Normal file
18
backend/app/db/session.py
Normal 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
15
backend/app/main.py
Normal 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
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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
backend/app/models/api_client.py
Normal file
31
backend/app/models/api_client.py
Normal 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
backend/app/models/permission.py
Normal file
31
backend/app/models/permission.py
Normal 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
backend/app/models/user.py
Normal file
23
backend/app/models/user.py
Normal 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
|
||||||
|
)
|
||||||
1
backend/app/repositories/__init__.py
Normal file
1
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Repository layer."""
|
||||||
63
backend/app/repositories/permissions_repo.py
Normal file
63
backend/app/repositories/permissions_repo.py
Normal 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)
|
||||||
38
backend/app/repositories/users_repo.py
Normal file
38
backend/app/repositories/users_repo.py
Normal 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
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic schemas."""
|
||||||
31
backend/app/schemas/permissions.py
Normal file
31
backend/app/schemas/permissions.py
Normal 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
backend/app/schemas/users.py
Normal file
8
backend/app/schemas/users.py
Normal 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
backend/app/security/__init__.py
Normal file
1
backend/app/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Security dependencies and guards."""
|
||||||
75
backend/app/security/api_client_auth.py
Normal file
75
backend/app/security/api_client_auth.py
Normal 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
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Service layer."""
|
||||||
13
backend/app/services/permission_service.py
Normal file
13
backend/app/services/permission_service.py
Normal 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
29
backend/pyproject.toml
Normal 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
backend/scripts/init_schema.sql
Normal file
30
backend/scripts/init_schema.sql
Normal 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
backend/tests/test_healthz.py
Normal file
10
backend/tests/test_healthz.py
Normal 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
106
docs/API_CLIENTS_SQL.sql
Normal 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 個 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;
|
||||||
23
docs/ARCHITECTURE_AND_CONFIG.md
Normal file
23
docs/ARCHITECTURE_AND_CONFIG.md
Normal 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 深度整合:待補(目前先保留介接欄位與流程位置)
|
||||||
82
docs/BACKEND_ARCHITECTURE.md
Normal file
82
docs/BACKEND_ARCHITECTURE.md
Normal 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
|
||||||
|
- 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:<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
26
docs/BACKEND_BOOTSTRAP.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
55
docs/FRONTEND_ARCHITECTURE.md
Normal file
55
docs/FRONTEND_ARCHITECTURE.md
Normal 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_type(company/site)
|
||||||
|
- scope_id
|
||||||
|
- module
|
||||||
|
- action(view/edit)
|
||||||
|
|
||||||
|
## 8. 第一版不做項目
|
||||||
|
- 不做密碼重設畫面
|
||||||
|
- 不做複雜儀表板
|
||||||
|
- 不做跨系統 SSO 管理 UI
|
||||||
Reference in New Issue
Block a user