Compare commits

...

66 Commits

Author SHA1 Message Date
Chris
0e248db1bf Update submodule URLs to Gitea remotes 2026-04-03 03:28:03 +08:00
Chris
1d2a57fada Split frontend and backend into separate submodule repos 2026-04-03 03:19:48 +08:00
Chris
528b988207 Switch backend Docker image to Alpine multi-stage 2026-04-03 03:12:19 +08:00
Chris
40d9fb8dcf Add production Dockerfile for backend deployment 2026-04-03 03:11:28 +08:00
Chris
fc81696abf Switch access control from groups to realm roles 2026-04-03 03:03:43 +08:00
Chris
daa21e81a9 Remove duplicate internal permissions snapshot API 2026-04-03 02:46:55 +08:00
Chris
955019e8d7 Add Redis-backed cache backend with env switch 2026-04-03 02:38:54 +08:00
Chris
ed413ce39d Add in-memory read cache with CUD-based invalidation 2026-04-03 02:32:38 +08:00
Chris
fa624127c8 Speed up auth verification with JWKS/admin token caching 2026-04-03 02:20:54 +08:00
Chris
418a7b7099 Sync site-role assignments to Keycloak group role mappings 2026-04-03 02:14:01 +08:00
Chris
223edd49b5 fix: respect admin site list limit when loading role site options 2026-04-03 01:58:08 +08:00
Chris
d59407d04c feat: allow assigning sites directly from role page 2026-04-03 01:56:22 +08:00
Chris
2004203758 chore: silence introspection fallback warning noise 2026-04-03 01:53:35 +08:00
Chris
1ff0589b29 refactor: simplify schema names and remove provider id columns 2026-04-03 01:49:36 +08:00
Chris
6e43a3b2c8 feat: add global manual sync button in admin header 2026-04-03 01:25:34 +08:00
Chris
21167659f8 perf: disable read-time sync and keep provider sync manual 2026-04-03 01:23:42 +08:00
Chris
f351fe6454 fix: sync role CRUD with provider client roles 2026-04-03 01:17:13 +08:00
Chris
6adca8c229 fix: add provider column migration script for existing db 2026-04-03 01:10:13 +08:00
Chris
ef27162903 refactor: rename idp fields to provider naming 2026-04-03 01:05:01 +08:00
Chris
467f2b4867 refactor(idp-groups): use display name as keycloak group name 2026-04-03 00:53:31 +08:00
Chris
7660c662a5 feat(sync): keycloak as source-of-truth with auto catalog sync and token refresh 2026-04-03 00:46:46 +08:00
Chris
7986160d9e fix(auth): resolve admin groups via keycloak admin API when token lacks groups 2026-04-03 00:28:32 +08:00
Chris
6ae907d649 fix(auth): accept keycloak group path variants for admin guard 2026-04-03 00:24:32 +08:00
Chris
2ce9630a5e fix(backend): postpone annotations to avoid list() type shadowing crash 2026-04-03 00:22:00 +08:00
Chris
5837582c0f feat(frontend): migrate admin UI to role-site model and clean legacy pages 2026-04-03 00:18:39 +08:00
Chris
1e1d913103 refactor: rebuild backend around role-site authorization model 2026-04-02 23:58:13 +08:00
Chris
e2dd3ce106 docs: add clickable links for file paths in markdown 2026-04-02 23:38:17 +08:00
Chris
16bbfdba24 docs: rebuild architecture and taskplans for role-site model 2026-04-02 23:35:05 +08:00
Chris
7cdf2b5a51 refactor(keycloak): remove authentik naming and switch to keycloak-only paths 2026-04-01 02:01:41 +08:00
Chris
a9c7cb5f39 fix(auth): relax keycloak audience check and auto-redirect logged-in user 2026-04-01 01:48:06 +08:00
Chris
f0fd5d6e68 fix(auth-callback): redirect to login after successful oidc callback 2026-04-01 01:46:33 +08:00
Chris
b0de6ad94a fix(oidc): add PKCE support for keycloak login flow 2026-04-01 01:43:53 +08:00
Chris
a1eb7ef41b feat(login): simplify to single keycloak redirect button 2026-04-01 01:35:46 +08:00
Chris
07195e7efc fix(login): unify auth entry to single keycloak login page 2026-04-01 01:33:27 +08:00
Chris
dc2811ec61 chore(env): use member-frontend oidc client and keep member-backend admin client 2026-04-01 01:30:44 +08:00
Chris
0b61975c81 chore(env): configure keycloak master client for local backend 2026-04-01 01:20:46 +08:00
Chris
34ba57034d feat(idp): add keycloak-first support with authentik fallback 2026-04-01 00:41:38 +08:00
Chris
febfafc55c fix(login): switch frontend account login to oidc flow 2026-03-31 23:43:57 +08:00
Chris
80a571d227 feat(login): support both password and Google SSO entry on login page 2026-03-31 23:18:28 +08:00
Chris
fe6453f6f8 refactor(identity): rename authentik_sub to user_sub and authentik_user_id to idp_user_id 2026-03-31 22:32:48 +08:00
Chris
316d17027b docs(api): add internal API contract and expose response schemas in swagger 2026-03-31 22:20:24 +08:00
Chris
15da8a5341 fix(internal): return correct system_key in modules list 2026-03-31 22:02:56 +08:00
Chris
671e27447b refactor(internal): switch auth to api-client headers 2026-03-31 21:09:18 +08:00
Chris
322db6ee1a fix(member): delete authentik user when removing member 2026-03-31 21:01:15 +08:00
Chris
f6f86d4bfb feat(admin): add delete APIs and UI actions for all admin resources 2026-03-31 20:58:20 +08:00
Chris
c4492a3072 fix(api-clients): fallback api-key hashing without argon2; show site/module parent display names 2026-03-31 20:35:04 +08:00
Chris
1d9bdb7daa feat(admin): add api client management UI and backend CRUD/rotate endpoints 2026-03-30 23:28:27 +08:00
Chris
ccb99683b8 feat(members): split username/display_name, sync updates to authentik, add password reset API and refresh docs 2026-03-30 22:15:41 +08:00
Chris
e1a6bbd844 refactor(auth): use group-only admin access and remove admin api-key flow from frontend/admin routes 2026-03-30 21:39:43 +08:00
Chris
15eee2fc9a feat(security): enforce admin allowlist guard on admin APIs and attach bearer for admin client 2026-03-30 21:25:57 +08:00
Chris
fb515c6c44 fix(module-key): make module keys standalone MD format with system_key relation 2026-03-30 20:02:17 +08:00
Chris
b4c02835bd feat(keys): auto-generate entity keys and remove manual key input from admin create forms 2026-03-30 19:52:00 +08:00
Chris
62776ac27e chore(db): rebuild init schema with drop-recreate and group-centric constraints 2026-03-30 19:42:05 +08:00
Chris
ea5285501a feat(admin): implement group-centric relations and system/module/company linkage views 2026-03-30 19:38:49 +08:00
Chris
37a69081e3 docs: rebuild documentation set for new architecture and add DB schema guide 2026-03-30 19:21:54 +08:00
Chris
f884f1043d feat(flow): unify member-group-permission admin workflow and docs 2026-03-30 03:54:22 +08:00
Chris
31fff92e19 feat(flow): auto-resolve authentik sub and improve admin dropdown UX 2026-03-30 03:33:50 +08:00
Chris
f85d3de5c5 feat(admin): add edit flows for all catalogs and member authentik sync 2026-03-30 03:25:53 +08:00
Chris
2dd70dceff fix(auth): correct userinfo endpoint fallback for authentik profile enrichment 2026-03-30 03:13:29 +08:00
Chris
94441a4037 refactor(frontend): remove manual admin credential cards from permission pages 2026-03-30 03:06:31 +08:00
Chris
76fd22826b fix(frontend): auto-attach admin api keys and normalize admin list payloads 2026-03-30 03:03:17 +08:00
Chris
f33134ff53 refactor: Redesign navbar to single-row tab layout
- 單列 header(高度 56px),sticky top
- 左:logo 區(固定寬度,方便之後換圖)
- 中:tab 列,active 用藍色底線 + 淡藍底色
- 分隔用細豎線 | 區隔用戶與管理員 tab
- 右:輕量文字登出按鈕,不搶焦點
- NavTab 用行內 defineComponent 封裝,乾淨不額外建檔

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 02:58:38 +08:00
Chris
c85109e09b refactor: Replace admin dropdown with flat tab navigation
導覽列重新設計:
- 上方 bar:品牌名 + 登出按鈕
- 下方 tab 列:我的資料、我的權限 | 權限管理、系統、模組、公司、站台、會員、群組
- 用戶 tab(藍色底線)與管理員 tab(靛色底線)視覺分組
- 支持 overflow-x scroll,小螢幕也可橫滑
- 移除 el-dropdown 依賴,改用純 router-link + button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 02:56:19 +08:00
Chris
4bb6ecf887 refactor: Fix UI/UX issues across admin pages
- App.vue: max-w-4xl → max-w-6xl(讓表格不被截斷)
- 新增 AdminCredsCard.vue 共用元件,消除兩個頁面的重複認證卡片
- PermissionAdminPage / PermissionGroupsPage 改用 AdminCredsCard
- 所有 el-table 的 slot="empty" 換成 <template #empty>(Vue 3 正確用法)
- 4 個管理頁 Dialog 補 el-form rules + formRef.validate()(取代手動 if 檢查)
- MembersPage: authentik_sub / email 欄位加 show-overflow-tooltip
- PermissionGroupsPage: 成功/失敗訊息由 <p> 改為 el-alert(統一樣式)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 02:53:54 +08:00
Chris
c3f6293c83 fix(frontend): validate oidc state in callback flow 2026-03-30 02:47:16 +08:00
Chris
70b5f34a74 fix(frontend): harden auth routing and callback error handling 2026-03-30 02:46:35 +08:00
121 changed files with 423 additions and 8329 deletions

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "backend"]
path = backend
url = http://127.0.0.1:8888/member/member-backend.git
[submodule "frontend"]
path = frontend
url = http://127.0.0.1:8888/member/member-frontend.git

1
backend Submodule

Submodule backend added at ade60bdbaa

View File

@@ -1,19 +0,0 @@
# 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
AUTHENTIK_ISSUER=
AUTHENTIK_JWKS_URL=
AUTHENTIK_AUDIENCE=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,22 +0,0 @@
# memberapi.ose.tw backend env (local development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member.ose.tw
DB_USER=member_ose
DB_PASSWORD=Dmrax5bKDf
AUTHENTIK_BASE_URL=https://auth.ose.tw
AUTHENTIK_ADMIN_TOKEN=L7RspewJSjm3i7Y3eovYb49vr8jvEJ6oZzCm3X79spGNapbo3RqWilBrTDz3
AUTHENTIK_VERIFY_TLS=true
AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/
AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/
AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP
AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,23 +0,0 @@
# 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
AUTHENTIK_ISSUER=
AUTHENTIK_JWKS_URL=
AUTHENTIK_AUDIENCE=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT=
AUTHENTIK_USERINFO_ENDPOINT=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,23 +0,0 @@
# 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
AUTHENTIK_ISSUER=
AUTHENTIK_JWKS_URL=
AUTHENTIK_AUDIENCE=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT=
AUTHENTIK_USERINFO_ENDPOINT=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,67 +0,0 @@
# memberapi.ose.tw backend
## Quick start
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e .
cp .env.example .env
./scripts/start_dev.sh
```
## 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`.
3. Generate `api_key_hash` and update `api_clients` records, e.g.:
```bash
python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
```
## Authentik JWT setup
- Configure at least one of:
- `AUTHENTIK_JWKS_URL`
- `AUTHENTIK_ISSUER` (the service infers `<issuer>/jwks/`)
- Optional:
- `AUTHENTIK_AUDIENCE` (enables audience claim validation)
- `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
- `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims)
## Authentik Admin API setup
- Required for `/internal/authentik/users/ensure`:
- `AUTHENTIK_BASE_URL`
- `AUTHENTIK_ADMIN_TOKEN`
- `AUTHENTIK_VERIFY_TLS`
## Main APIs
- `GET /healthz`
- `GET /auth/oidc/url`
- `POST /auth/oidc/exchange`
- `GET /me` (Bearer token required)
- `GET /me/permissions/snapshot` (Bearer token required)
- `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{authentik_sub}/snapshot`
- `POST /internal/authentik/users/ensure`
- `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke`
- `GET|POST /admin/systems`
- `GET|POST /admin/modules`
- `GET|POST /admin/companies`
- `GET|POST /admin/sites`
- `GET /admin/members`
- `GET|POST /admin/permission-groups`
- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
- `GET /internal/systems`
- `GET /internal/modules`
- `GET /internal/companies`
- `GET /internal/sites`
- `GET /internal/members`

View File

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

View File

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

View File

@@ -1,100 +0,0 @@
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.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
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"])
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
target_module_key = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__"
module = modules_repo.get_by_key(target_module_key)
if not module:
module = modules_repo.create(module_key=target_module_key, name=target_module_key, status="active")
return module.id
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
if scope_type == "company":
company = companies_repo.get_by_key(scope_id)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
return company.id, None
if scope_type == "site":
site = sites_repo.get_by_key(scope_id)
if not site:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
return None, site.id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
@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,
)
module_id = _resolve_module_id(db, payload.system, payload.module)
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
permission = perms_repo.create_if_not_exists(
user_id=user.id,
module_id=module_id,
action=payload.action,
scope_type=payload.scope_type,
company_id=company_id,
site_id=site_id,
)
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")
module_id = _resolve_module_id(db, payload.system, payload.module)
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
deleted = perms_repo.revoke(
user_id=user.id,
module_id=module_id,
action=payload.action,
scope_type=payload.scope_type,
company_id=company_id,
site_id=site_id,
)
return {"deleted": deleted, "result": "revoked"}

View File

@@ -1,328 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.permission_groups_repo import PermissionGroupsRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.catalog import (
CompanyCreateRequest,
CompanyItem,
MemberItem,
ModuleCreateRequest,
ModuleItem,
PermissionGroupCreateRequest,
PermissionGroupItem,
SiteCreateRequest,
SiteItem,
SystemCreateRequest,
SystemItem,
)
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"])
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
target_module_key = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__"
module = modules_repo.get_by_key(target_module_key)
if not module:
module = modules_repo.create(module_key=target_module_key, name=target_module_key, status="active")
return module.id
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
if scope_type == "company":
company = companies_repo.get_by_key(scope_id)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
return company.id, None
if scope_type == "site":
site = sites_repo.get_by_key(scope_id)
if not site:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
return None, site.id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
@router.get("/systems")
def list_systems(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = SystemsRepository(db)
items, total = repo.list(limit=limit, offset=offset)
return {"items": [SystemItem(id=i.id, system_key=i.system_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.post("/systems", response_model=SystemItem)
def create_system(
payload: SystemCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> SystemItem:
repo = SystemsRepository(db)
if repo.get_by_key(payload.system_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_key_already_exists")
row = repo.create(system_key=payload.system_key, name=payload.name, status=payload.status)
return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status)
@router.get("/modules")
def list_modules(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
modules_repo = ModulesRepository(db)
items, total = modules_repo.list(limit=limit, offset=offset)
out = []
for i in items:
system_key = i.module_key.split(".", 1)[0] if "." in i.module_key else None
out.append(
ModuleItem(
id=i.id,
system_key=system_key,
module_key=i.module_key,
name=i.name,
status=i.status,
).model_dump()
)
return {"items": out, "total": total, "limit": limit, "offset": offset}
@router.post("/modules", response_model=ModuleItem)
def create_module(
payload: ModuleCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> ModuleItem:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(payload.system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
full_module_key = f"{payload.system_key}.{payload.module_key}"
if modules_repo.get_by_key(full_module_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="module_key_already_exists")
row = modules_repo.create(
module_key=full_module_key,
name=payload.name,
status=payload.status,
)
return ModuleItem(id=row.id, system_key=payload.system_key, module_key=row.module_key, name=row.name, status=row.status)
@router.get("/companies")
def list_companies(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = CompaniesRepository(db)
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [CompanyItem(id=i.id, company_key=i.company_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.post("/companies", response_model=CompanyItem)
def create_company(
payload: CompanyCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> CompanyItem:
repo = CompaniesRepository(db)
if repo.get_by_key(payload.company_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="company_key_already_exists")
row = repo.create(company_key=payload.company_key, name=payload.name, status=payload.status)
return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status)
@router.get("/sites")
def list_sites(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
company_key: str | None = Query(default=None),
keyword: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
company_lookup: dict[str, str] = {}
all_companies, _ = companies_repo.list(limit=1000, offset=0)
for c in all_companies:
company_lookup[c.id] = c.company_key
company_id = None
if company_key:
company = companies_repo.get_by_key(company_key)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
company_id = company.id
items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset)
return {
"items": [
SiteItem(
id=i.id,
site_key=i.site_key,
company_key=company_lookup.get(i.company_id, ""),
name=i.name,
status=i.status,
).model_dump()
for i in items
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/sites", response_model=SiteItem)
def create_site(
payload: SiteCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> SiteItem:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
company = companies_repo.get_by_key(payload.company_key)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
if sites_repo.get_by_key(payload.site_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="site_key_already_exists")
row = sites_repo.create(site_key=payload.site_key, company_id=company.id, name=payload.name, status=payload.status)
return SiteItem(id=row.id, site_key=row.site_key, company_key=payload.company_key, name=row.name, status=row.status)
@router.get("/members")
def list_members(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
users_repo = UsersRepository(db)
items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/permission-groups")
def list_permission_groups(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = PermissionGroupsRepository(db)
items, total = repo.list(limit=limit, offset=offset)
return {"items": [PermissionGroupItem(id=i.id, group_key=i.group_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.post("/permission-groups", response_model=PermissionGroupItem)
def create_permission_group(
payload: PermissionGroupCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> PermissionGroupItem:
repo = PermissionGroupsRepository(db)
if repo.get_by_key(payload.group_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="group_key_already_exists")
row = repo.create(group_key=payload.group_key, name=payload.name, status=payload.status)
return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status)
@router.post("/permission-groups/{group_key}/members/{authentik_sub}")
def add_group_member(
group_key: str,
authentik_sub: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
row = groups_repo.add_member_if_not_exists(group.id, authentik_sub)
return {"membership_id": row.id, "result": "added"}
@router.delete("/permission-groups/{group_key}/members/{authentik_sub}")
def remove_group_member(
group_key: str,
authentik_sub: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
deleted = groups_repo.remove_member(group.id, authentik_sub)
return {"deleted": deleted, "result": "removed"}
@router.post("/permission-groups/{group_key}/permissions/grant")
def grant_group_permission(
group_key: str,
payload: PermissionGrantRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
_resolve_module_id(db, payload.system, payload.module)
_resolve_scope_ids(db, payload.scope_type, payload.scope_id)
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__"
row = groups_repo.grant_group_permission(
group_id=group.id,
system=payload.system,
module=module_key,
action=payload.action,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
return {"permission_id": row.id, "result": "granted"}
@router.post("/permission-groups/{group_key}/permissions/revoke")
def revoke_group_permission(
group_key: str,
payload: PermissionRevokeRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
_resolve_module_id(db, payload.system, payload.module)
_resolve_scope_ids(db, payload.scope_type, payload.scope_id)
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__"
deleted = groups_repo.revoke_group_permission(
group_id=group.id,
system=payload.system,
module=module_key,
action=payload.action,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
return {"deleted": deleted, "result": "revoked"}

View File

@@ -1,179 +0,0 @@
import logging
import secrets
from urllib.parse import urljoin
import httpx
from fastapi import APIRouter, HTTPException, status
from app.core.config import get_settings
from app.schemas.login import (
LoginRequest,
LoginResponse,
OIDCAuthUrlResponse,
OIDCCodeExchangeRequest,
)
router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
def _resolve_username_by_email(settings, email: str) -> str | None:
if not settings.authentik_base_url or not settings.authentik_admin_token:
return None
url = urljoin(settings.authentik_base_url.rstrip("/") + "/", "api/v3/core/users/")
try:
resp = httpx.get(
url,
params={"email": email},
timeout=10,
verify=settings.authentik_verify_tls,
headers={
"Authorization": f"Bearer {settings.authentik_admin_token}",
"Accept": "application/json",
},
)
except Exception:
return None
if resp.status_code >= 400:
return None
data = resp.json()
results = data.get("results") if isinstance(data, dict) else None
if not isinstance(results, list) or not results:
return None
username = results[0].get("username")
return username if isinstance(username, str) and username else None
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest) -> LoginResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
token_endpoint = settings.authentik_token_endpoint or urljoin(
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
)
form = {
"grant_type": "password",
"client_id": client_id,
"client_secret": settings.authentik_client_secret,
"username": payload.username,
"password": payload.password,
"scope": "openid profile email",
}
def _token_request(form_data: dict[str, str]) -> httpx.Response:
resp = httpx.post(
token_endpoint,
data=form_data,
timeout=10,
verify=settings.authentik_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
return resp
try:
resp = _token_request(form)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
# If user entered email, try resolving username and retry once.
if resp.status_code >= 400 and "@" in payload.username:
resolved = _resolve_username_by_email(settings, payload.username)
if resolved and resolved != payload.username:
form["username"] = resolved
try:
resp = _token_request(form)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400:
logger.warning("authentik password grant failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
data = resp.json()
token = data.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
return LoginResponse(
access_token=token,
token_type=data.get("token_type", "Bearer"),
expires_in=data.get("expires_in"),
scope=data.get("scope"),
)
@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
def get_oidc_authorize_url(redirect_uri: str) -> OIDCAuthUrlResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
authorize_endpoint = urljoin(settings.authentik_base_url.rstrip("/") + "/", "application/o/authorize/")
state = secrets.token_urlsafe(24)
params = httpx.QueryParams(
{
"client_id": client_id,
"response_type": "code",
"scope": "openid profile email",
"redirect_uri": redirect_uri,
"state": state,
"prompt": "login",
}
)
return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
@router.post("/oidc/exchange", response_model=LoginResponse)
def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
token_endpoint = settings.authentik_token_endpoint or urljoin(
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
)
form = {
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": settings.authentik_client_secret,
"code": payload.code,
"redirect_uri": payload.redirect_uri,
}
try:
resp = httpx.post(
token_endpoint,
data=form,
timeout=10,
verify=settings.authentik_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400:
logger.warning("authentik auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed")
data = resp.json()
token = data.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
return LoginResponse(
access_token=token,
token_type=data.get("token_type", "Bearer"),
expires_in=data.get("expires_in"),
scope=data.get("scope"),
)

View File

@@ -1,88 +0,0 @@
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.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.users import UserUpsertBySubRequest
from app.services.authentik_admin_service import AuthentikAdminService
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,
"authentik_user_id": user.authentik_user_id,
"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(user.id, user.authentik_sub)
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=permissions)
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
def ensure_authentik_user(
payload: AuthentikEnsureUserRequest,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> AuthentikEnsureUserResponse:
settings = get_settings()
authentik_service = AuthentikAdminService(settings=settings)
sync_result = authentik_service.ensure_user(
sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
)
users_repo = UsersRepository(db)
users_repo.upsert_by_sub(
authentik_sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
authentik_user_id=sync_result.user_id,
)
return AuthentikEnsureUserResponse(authentik_user_id=sync_result.user_id, action=sync_result.action)

View File

@@ -1,97 +0,0 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.internal import verify_internal_secret
from app.db.session import get_db
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get("/systems")
def internal_list_systems(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
limit: int = Query(default=200, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = SystemsRepository(db)
items, total = repo.list(limit=limit, offset=offset)
return {"items": [{"id": i.id, "system_key": i.system_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/modules")
def internal_list_modules(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> dict:
modules_repo = ModulesRepository(db)
items, total = modules_repo.list(limit=limit, offset=offset)
return {
"items": [
{
"id": i.id,
"module_key": i.module_key,
"system_key": i.module_key.split(".", 1)[0] if "." in i.module_key else None,
"name": i.name,
"status": i.status,
}
for i in items
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/companies")
def internal_list_companies(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = CompaniesRepository(db)
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [{"id": i.id, "company_key": i.company_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/sites")
def internal_list_sites(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
company_key: str | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> dict:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
company_id = None
if company_key:
company = companies_repo.get_by_key(company_key)
if company:
company_id = company.id
companies, _ = companies_repo.list(limit=2000, offset=0)
mapping = {c.id: c.company_key for c in companies}
items, total = sites_repo.list(company_id=company_id, limit=limit, offset=offset)
return {"items": [{"id": i.id, "site_key": i.site_key, "company_key": mapping.get(i.company_id), "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/members")
def internal_list_members(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = UsersRepository(db)
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [{"id": i.id, "authentik_sub": i.authentik_sub, "email": i.email, "display_name": i.display_name, "is_active": i.is_active} for i in items], "total": total, "limit": limit, "offset": offset}

View File

@@ -1,57 +0,0 @@
from fastapi import APIRouter, Depends
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.auth import AuthentikPrincipal, MeSummaryResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.security.authentik_jwt import require_authenticated_principal
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/me", tags=["me"])
@router.get("", response_model=MeSummaryResponse)
def get_me(
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> MeSummaryResponse:
try:
users_repo = UsersRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
)
return MeSummaryResponse(sub=user.authentik_sub, email=user.email, display_name=user.display_name)
except SQLAlchemyError:
# DB schema compatibility fallback for local bring-up.
return MeSummaryResponse(
sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
)
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
def get_my_permission_snapshot(
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
try:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
)
permissions = perms_repo.list_by_user(user.id, user.authentik_sub)
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=permissions)
except SQLAlchemyError:
return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[])

View File

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

View File

@@ -1,53 +0,0 @@
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
authentik_issuer: str = ""
authentik_jwks_url: str = ""
authentik_audience: str = ""
authentik_client_id: str = ""
authentik_client_secret: str = ""
authentik_token_endpoint: str = ""
authentik_userinfo_endpoint: str = ""
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

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

View File

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

View File

@@ -1,18 +0,0 @@
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()

View File

@@ -1,34 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.admin import router as admin_router
from app.api.admin_catalog import router as admin_catalog_router
from app.api.auth import router as auth_router
from app.api.internal_catalog import router as internal_catalog_router
from app.api.internal import router as internal_router
from app.api.me import router as me_router
from app.core.config import get_settings
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.public_frontend_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/healthz", tags=["health"])
def healthz() -> dict[str, str]:
return {"status": "ok"}
app.include_router(internal_router)
app.include_router(internal_catalog_router)
app.include_router(admin_router)
app.include_router(admin_catalog_router)
app.include_router(me_router)
app.include_router(auth_router)

View File

@@ -1,25 +0,0 @@
from app.models.api_client import ApiClient
from app.models.company import Company
from app.models.module import Module
from app.models.permission import Permission
from app.models.permission_group import PermissionGroup
from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission
from app.models.site import Site
from app.models.system import System
from app.models.user import User
from app.models.user_scope_permission import UserScopePermission
__all__ = [
"ApiClient",
"Company",
"Module",
"Permission",
"PermissionGroup",
"PermissionGroupMember",
"PermissionGroupPermission",
"Site",
"System",
"User",
"UserScopePermission",
]

View File

@@ -1,31 +0,0 @@
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

@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Company(Base):
__tablename__ = "companies"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
company_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
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

@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Module(Base):
__tablename__ = "modules"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
module_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
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

@@ -1,31 +0,0 @@
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

@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PermissionGroup(Base):
__tablename__ = "permission_groups"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
group_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
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

@@ -1,20 +0,0 @@
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 PermissionGroupMember(Base):
__tablename__ = "permission_group_members"
__table_args__ = (UniqueConstraint("group_id", "authentik_sub", name="uq_permission_group_members_group_sub"),)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
group_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False
)
authentik_sub: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -1,23 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PermissionGroupPermission(Base):
__tablename__ = "permission_group_permissions"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
group_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False
)
system: Mapped[str] = mapped_column(String(64), nullable=False)
module: Mapped[str] = mapped_column(String(128), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
scope_type: Mapped[str] = mapped_column(String(16), nullable=False)
scope_id: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -1,22 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Site(Base):
__tablename__ = "sites"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
site_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
company_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
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

@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class System(Base):
__tablename__ = "systems"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
system_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
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

@@ -1,24 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, Integer, 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)
authentik_user_id: Mapped[int | None] = mapped_column(Integer)
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

@@ -1,24 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class UserScopePermission(Base):
__tablename__ = "user_scope_permissions"
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)
module_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
scope_type: Mapped[str] = mapped_column(String(16), nullable=False)
company_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"))
site_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"))
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

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

View File

@@ -1,35 +0,0 @@
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.models.company import Company
class CompaniesRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, company_key: str) -> Company | None:
stmt = select(Company).where(Company.company_key == company_key)
return self.db.scalar(stmt)
def get_by_id(self, company_id: str) -> Company | None:
stmt = select(Company).where(Company.id == company_id)
return self.db.scalar(stmt)
def list(self, keyword: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[Company], int]:
stmt = select(Company)
count_stmt = select(func.count()).select_from(Company)
if keyword:
pattern = f"%{keyword}%"
cond = or_(Company.company_key.ilike(pattern), Company.name.ilike(pattern))
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
stmt = stmt.order_by(Company.created_at.desc()).limit(limit).offset(offset)
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
def create(self, company_key: str, name: str, status: str = "active") -> Company:
item = Company(company_key=company_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -1,26 +0,0 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.module import Module
class ModulesRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, module_key: str) -> Module | None:
stmt = select(Module).where(Module.module_key == module_key)
return self.db.scalar(stmt)
def list(self, limit: int = 200, offset: int = 0) -> tuple[list[Module], int]:
stmt = select(Module)
count_stmt = select(func.count()).select_from(Module)
stmt = stmt.order_by(Module.created_at.desc()).limit(limit).offset(offset)
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
def create(self, module_key: str, name: str, status: str = "active") -> Module:
item = Module(module_key=module_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -1,106 +0,0 @@
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.models.permission_group import PermissionGroup
from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission
class PermissionGroupsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, group_key: str) -> PermissionGroup | None:
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.group_key == group_key))
def get_by_id(self, group_id: str) -> PermissionGroup | None:
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id))
def list(self, limit: int = 100, offset: int = 0) -> tuple[list[PermissionGroup], int]:
stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset)
count_stmt = select(func.count()).select_from(PermissionGroup)
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
def create(self, group_key: str, name: str, status: str = "active") -> PermissionGroup:
item = PermissionGroup(group_key=group_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item
def add_member_if_not_exists(self, group_id: str, authentik_sub: str) -> PermissionGroupMember:
existing = self.db.scalar(
select(PermissionGroupMember).where(
PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub
)
)
if existing:
return existing
row = PermissionGroupMember(group_id=group_id, authentik_sub=authentik_sub)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return row
def remove_member(self, group_id: str, authentik_sub: str) -> int:
result = self.db.execute(
delete(PermissionGroupMember).where(
PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub
)
)
self.db.commit()
return int(result.rowcount or 0)
def grant_group_permission(
self,
group_id: str,
system: str,
module: str,
action: str,
scope_type: str,
scope_id: str,
) -> PermissionGroupPermission:
where = [
PermissionGroupPermission.group_id == group_id,
PermissionGroupPermission.system == system,
PermissionGroupPermission.module == module,
PermissionGroupPermission.action == action,
PermissionGroupPermission.scope_type == scope_type,
PermissionGroupPermission.scope_id == scope_id,
]
existing = self.db.scalar(select(PermissionGroupPermission).where(*where))
if existing:
return existing
row = PermissionGroupPermission(
group_id=group_id,
system=system,
module=module,
action=action,
scope_type=scope_type,
scope_id=scope_id,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return row
def revoke_group_permission(
self,
group_id: str,
system: str,
module: str,
action: str,
scope_type: str,
scope_id: str,
) -> int:
stmt = delete(PermissionGroupPermission).where(
PermissionGroupPermission.group_id == group_id,
PermissionGroupPermission.system == system,
PermissionGroupPermission.module == module,
PermissionGroupPermission.action == action,
PermissionGroupPermission.scope_type == scope_type,
PermissionGroupPermission.scope_id == scope_id,
)
result = self.db.execute(stmt)
self.db.commit()
return int(result.rowcount or 0)

View File

@@ -1,121 +0,0 @@
from sqlalchemy import and_, delete, literal, or_, select
from sqlalchemy.orm import Session
from app.models.company import Company
from app.models.module import Module
from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission
from app.models.site import Site
from app.models.user_scope_permission import UserScopePermission
class PermissionsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_by_user(self, user_id: str, authentik_sub: str) -> list[tuple[str, str, str | None, str, str]]:
direct_stmt = (
select(
literal("direct"),
UserScopePermission.scope_type,
Company.company_key,
Site.site_key,
Module.module_key,
UserScopePermission.action,
)
.select_from(UserScopePermission)
.join(Module, Module.id == UserScopePermission.module_id)
.join(Company, Company.id == UserScopePermission.company_id, isouter=True)
.join(Site, Site.id == UserScopePermission.site_id, isouter=True)
.where(UserScopePermission.user_id == user_id)
)
group_stmt = (
select(
literal("group"),
PermissionGroupPermission.scope_type,
PermissionGroupPermission.scope_id,
PermissionGroupPermission.system,
PermissionGroupPermission.module,
PermissionGroupPermission.action,
)
.select_from(PermissionGroupPermission)
.join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id)
.where(PermissionGroupMember.authentik_sub == authentik_sub)
)
rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all()
result: list[tuple[str, str, str | None, str, str]] = []
dedup = set()
for row in rows:
source = row[0]
if source == "group":
_, scope_type, scope_id, system_key, module_key, action = row
else:
_, scope_type, company_key, site_key, module_key, action = row
scope_id = company_key if scope_type == "company" else site_key
system_key = module_key.split(".", 1)[0] if isinstance(module_key, str) and "." in module_key else None
key = (scope_type, scope_id or "", system_key, module_key, action)
if key in dedup:
continue
dedup.add(key)
result.append(key)
return result
def create_if_not_exists(
self,
user_id: str,
module_id: str,
action: str,
scope_type: str,
company_id: str | None,
site_id: str | None,
) -> UserScopePermission:
where_expr = [
UserScopePermission.user_id == user_id,
UserScopePermission.module_id == module_id,
UserScopePermission.action == action,
UserScopePermission.scope_type == scope_type,
]
if scope_type == "company":
where_expr.append(UserScopePermission.company_id == company_id)
else:
where_expr.append(UserScopePermission.site_id == site_id)
existing = self.db.scalar(select(UserScopePermission).where(and_(*where_expr)))
if existing:
return existing
item = UserScopePermission(
user_id=user_id,
module_id=module_id,
action=action,
scope_type=scope_type,
company_id=company_id,
site_id=site_id,
)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item
def revoke(
self,
user_id: str,
module_id: str,
action: str,
scope_type: str,
company_id: str | None,
site_id: str | None,
) -> int:
stmt = delete(UserScopePermission).where(
UserScopePermission.user_id == user_id,
UserScopePermission.module_id == module_id,
UserScopePermission.action == action,
UserScopePermission.scope_type == scope_type,
or_(
and_(scope_type == "company", UserScopePermission.company_id == company_id),
and_(scope_type == "site", UserScopePermission.site_id == site_id),
),
)
result = self.db.execute(stmt)
self.db.commit()
return int(result.rowcount or 0)

View File

@@ -1,40 +0,0 @@
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.models.site import Site
class SitesRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, site_key: str) -> Site | None:
stmt = select(Site).where(Site.site_key == site_key)
return self.db.scalar(stmt)
def list(
self,
keyword: str | None = None,
company_id: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[Site], int]:
stmt = select(Site)
count_stmt = select(func.count()).select_from(Site)
if keyword:
pattern = f"%{keyword}%"
cond = or_(Site.site_key.ilike(pattern), Site.name.ilike(pattern))
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if company_id:
stmt = stmt.where(Site.company_id == company_id)
count_stmt = count_stmt.where(Site.company_id == company_id)
stmt = stmt.order_by(Site.created_at.desc()).limit(limit).offset(offset)
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
def create(self, site_key: str, company_id: str, name: str, status: str = "active") -> Site:
item = Site(site_key=site_key, company_id=company_id, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -1,33 +0,0 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.system import System
class SystemsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, system_key: str) -> System | None:
stmt = select(System).where(System.system_key == system_key)
return self.db.scalar(stmt)
def get_by_id(self, system_id: str) -> System | None:
stmt = select(System).where(System.id == system_id)
return self.db.scalar(stmt)
def list(self, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[System], int]:
stmt = select(System)
count_stmt = select(func.count()).select_from(System)
if status:
stmt = stmt.where(System.status == status)
count_stmt = count_stmt.where(System.status == status)
stmt = stmt.order_by(System.created_at.desc()).limit(limit).offset(offset)
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
def create(self, system_key: str, name: str, status: str = "active") -> System:
item = System(system_key=system_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -1,89 +0,0 @@
from sqlalchemy import func, or_, 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 get_by_id(self, user_id: str) -> User | None:
stmt = select(User).where(User.id == user_id)
return self.db.scalar(stmt)
def list(
self,
keyword: str | None = None,
is_active: bool | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[User], int]:
stmt = select(User)
count_stmt = select(func.count()).select_from(User)
if keyword:
pattern = f"%{keyword}%"
cond = or_(User.authentik_sub.ilike(pattern), User.email.ilike(pattern), User.display_name.ilike(pattern))
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if is_active is not None:
stmt = stmt.where(User.is_active == is_active)
count_stmt = count_stmt.where(User.is_active == is_active)
stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
items = list(self.db.scalars(stmt).all())
total = int(self.db.scalar(count_stmt) or 0)
return items, total
def upsert_by_sub(
self,
authentik_sub: str,
email: str | None,
display_name: str | None,
is_active: bool,
authentik_user_id: int | None = None,
) -> User:
user = self.get_by_sub(authentik_sub)
if user is None:
user = User(
authentik_sub=authentik_sub,
authentik_user_id=authentik_user_id,
email=email,
display_name=display_name,
is_active=is_active,
)
self.db.add(user)
else:
if authentik_user_id is not None:
user.authentik_user_id = authentik_user_id
user.email = email
user.display_name = display_name
user.is_active = is_active
self.db.commit()
self.db.refresh(user)
return user
def update_member(
self,
user: User,
*,
email: str | None = None,
display_name: str | None = None,
is_active: bool | None = None,
) -> User:
if email is not None:
user.email = email
if display_name is not None:
user.display_name = display_name
if is_active is not None:
user.is_active = is_active
self.db.commit()
self.db.refresh(user)
return user

View File

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

View File

@@ -1,14 +0,0 @@
from pydantic import BaseModel
class AuthentikPrincipal(BaseModel):
sub: str
email: str | None = None
name: str | None = None
preferred_username: str | None = None
class MeSummaryResponse(BaseModel):
sub: str
email: str | None = None
display_name: str | None = None

View File

@@ -1,13 +0,0 @@
from pydantic import BaseModel
class AuthentikEnsureUserRequest(BaseModel):
sub: str
email: str
display_name: str | None = None
is_active: bool = True
class AuthentikEnsureUserResponse(BaseModel):
authentik_user_id: int
action: str

View File

@@ -1,85 +0,0 @@
from pydantic import BaseModel
class SystemCreateRequest(BaseModel):
system_key: str
name: str
status: str = "active"
class SystemItem(BaseModel):
id: str
system_key: str
name: str
status: str
class ModuleCreateRequest(BaseModel):
system_key: str
module_key: str
name: str
status: str = "active"
class ModuleItem(BaseModel):
id: str
system_key: str | None = None
module_key: str
name: str
status: str
class CompanyCreateRequest(BaseModel):
company_key: str
name: str
status: str = "active"
class CompanyItem(BaseModel):
id: str
company_key: str
name: str
status: str
class SiteCreateRequest(BaseModel):
site_key: str
company_key: str
name: str
status: str = "active"
class SiteItem(BaseModel):
id: str
site_key: str
company_key: str
name: str
status: str
class MemberItem(BaseModel):
id: str
authentik_sub: str
email: str | None = None
display_name: str | None = None
is_active: bool
class ListResponse(BaseModel):
items: list
total: int
limit: int
offset: int
class PermissionGroupCreateRequest(BaseModel):
group_key: str
name: str
status: str = "active"
class PermissionGroupItem(BaseModel):
id: str
group_key: str
name: str
status: str

View File

@@ -1,22 +0,0 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "Bearer"
expires_in: int | None = None
scope: str | None = None
class OIDCAuthUrlResponse(BaseModel):
authorize_url: str
class OIDCCodeExchangeRequest(BaseModel):
code: str
redirect_uri: str

View File

@@ -1,34 +0,0 @@
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
system: str
module: str | None = None
action: str
class PermissionRevokeRequest(BaseModel):
authentik_sub: str
scope_type: str
scope_id: str
system: str
module: str | None = None
action: str
class PermissionItem(BaseModel):
scope_type: str
scope_id: str
system: str | None = None
module: str
action: str
class PermissionSnapshotResponse(BaseModel):
authentik_sub: str
permissions: list[PermissionItem]

View File

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

View File

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

View File

@@ -1,75 +0,0 @@
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

@@ -1,164 +0,0 @@
from __future__ import annotations
from functools import lru_cache
import httpx
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import get_settings
from app.schemas.auth import AuthentikPrincipal
bearer_scheme = HTTPBearer(auto_error=False)
class AuthentikTokenVerifier:
def __init__(
self,
issuer: str | None,
jwks_url: str | None,
audience: str | None,
client_secret: str | None,
base_url: str | None,
userinfo_endpoint: str | None,
verify_tls: bool,
) -> None:
self.issuer = issuer.strip() if issuer else None
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
self.audience = audience.strip() if audience else None
self.client_secret = client_secret.strip() if client_secret else None
self.base_url = base_url.strip() if base_url else None
self.userinfo_endpoint = (
userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url)
)
self.verify_tls = verify_tls
if not self.jwks_url:
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
@staticmethod
def _infer_jwks_url(issuer: str | None) -> str | None:
if not issuer:
return None
normalized = issuer.rstrip("/") + "/"
if normalized.endswith("/jwks/"):
return normalized
return normalized + "jwks/"
@staticmethod
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
if issuer:
return issuer.rstrip("/") + "/userinfo/"
if base_url:
return base_url.rstrip("/") + "/application/o/userinfo/"
return None
def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
if principal.email and (principal.name or principal.preferred_username):
return principal
if not self.userinfo_endpoint:
return principal
try:
resp = httpx.get(
self.userinfo_endpoint,
timeout=5,
verify=self.verify_tls,
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
except Exception:
return principal
if resp.status_code >= 400:
return principal
data = resp.json() if resp.content else {}
sub = data.get("sub")
if isinstance(sub, str) and sub and sub != principal.sub:
return principal
email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None)
name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None)
preferred_username = principal.preferred_username or (
data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None
)
return AuthentikPrincipal(
sub=principal.sub,
email=email,
name=name,
preferred_username=preferred_username,
)
def verify_access_token(self, token: str) -> AuthentikPrincipal:
try:
header = jwt.get_unverified_header(token)
algorithm = str(header.get("alg", "")).upper()
options = {
"verify_signature": True,
"verify_exp": True,
"verify_aud": bool(self.audience),
"verify_iss": bool(self.issuer),
}
if algorithm.startswith("HS"):
if not self.client_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing_authentik_client_secret",
)
key = self.client_secret
allowed_algorithms = ["HS256", "HS384", "HS512"]
else:
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
key = signing_key.key
allowed_algorithms = ["RS256", "RS384", "RS512"]
claims = jwt.decode(
token,
key,
algorithms=allowed_algorithms,
audience=self.audience,
issuer=self.issuer,
options=options,
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
sub = claims.get("sub")
if not sub:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
principal = AuthentikPrincipal(
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
preferred_username=claims.get("preferred_username"),
)
return self._enrich_from_userinfo(principal, token)
@lru_cache
def _get_verifier() -> AuthentikTokenVerifier:
settings = get_settings()
return AuthentikTokenVerifier(
issuer=settings.authentik_issuer,
jwks_url=settings.authentik_jwks_url,
audience=settings.authentik_audience,
client_secret=settings.authentik_client_secret,
base_url=settings.authentik_base_url,
userinfo_endpoint=settings.authentik_userinfo_endpoint,
verify_tls=settings.authentik_verify_tls,
)
def require_authenticated_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> AuthentikPrincipal:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
verifier = _get_verifier()
return verifier.verify_access_token(credentials.credentials)

View File

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

View File

@@ -1,75 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import httpx
from fastapi import HTTPException, status
from app.core.config import Settings
@dataclass
class AuthentikSyncResult:
user_id: int
action: str
class AuthentikAdminService:
def __init__(self, settings: Settings) -> None:
self.base_url = settings.authentik_base_url.rstrip("/")
self.admin_token = settings.authentik_admin_token
self.verify_tls = settings.authentik_verify_tls
if not self.base_url or not self.admin_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="authentik_admin_not_configured",
)
def _client(self) -> httpx.Client:
return httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Bearer {self.admin_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
verify=self.verify_tls,
)
@staticmethod
def _safe_username(sub: str, email: str) -> str:
if email and "@" in email:
return email.split("@", 1)[0]
return sub.replace("|", "_")[:150]
def ensure_user(self, sub: str, email: str, display_name: str | None, is_active: bool = True) -> AuthentikSyncResult:
payload = {
"username": self._safe_username(sub=sub, email=email),
"name": display_name or email,
"email": email,
"is_active": is_active,
}
with self._client() as client:
resp = client.get("/api/v3/core/users/", params={"email": email})
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
data = resp.json()
results = data.get("results") if isinstance(data, dict) else None
existing = results[0] if isinstance(results, list) and results else None
if existing and existing.get("pk") is not None:
user_pk = int(existing["pk"])
patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload)
if patch_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_update_failed")
return AuthentikSyncResult(user_id=user_pk, action="updated")
create_resp = client.post("/api/v3/core/users/", json=payload)
if create_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_create_failed")
created = create_resp.json()
return AuthentikSyncResult(user_id=int(created["pk"]), action="created")

View File

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

View File

@@ -1,30 +0,0 @@
[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",
"pyjwt[crypto]>=2.10.1",
"httpx>=0.28.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.4.2",
"ruff>=0.13.0",
]
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-q"
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate API key hash for api_clients table")
parser.add_argument("api_key", help="Plain API key")
parser.add_argument(
"--algo",
choices=["argon2", "bcrypt", "sha256"],
default="argon2",
help="Hash algorithm (default: argon2)",
)
args = parser.parse_args()
if args.algo == "sha256":
print("sha256:" + hashlib.sha256(args.api_key.encode("utf-8")).hexdigest())
return
print(pwd_context.hash(args.api_key, scheme=args.algo))
if __name__ == "__main__":
main()

View File

@@ -1,162 +0,0 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_sub TEXT NOT NULL UNIQUE,
authentik_user_id INTEGER,
email TEXT UNIQUE,
display_name TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'active',
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 auth_sync_state (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_synced_at TIMESTAMPTZ,
source_version TEXT,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_key TEXT NOT NULL UNIQUE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS systems (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
system_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- legacy table: 保留相容舊流程
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 TABLE IF NOT EXISTS user_scope_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
action VARCHAR(32) NOT NULL,
scope_type VARCHAR(16) NOT NULL,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check;
ALTER TABLE user_scope_permissions
ADD CONSTRAINT user_scope_permissions_check
CHECK (
((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL)
OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL))
);
CREATE TABLE IF NOT EXISTS permission_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS permission_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
authentik_sub TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_sub)
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
system TEXT NOT NULL,
module TEXT NOT NULL,
action TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_pgp_group_rule UNIQUE (group_id, system, module, action, scope_type, scope_id)
);
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 VARCHAR(16) NOT NULL DEFAULT 'active',
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()
);
INSERT INTO systems (system_key, name, status)
VALUES ('member', 'Member Center', 'active')
ON CONFLICT (system_key) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_sites_company_id ON sites(company_id);
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_usp_user_id ON user_scope_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_usp_module_id ON user_scope_permissions(module_id);
CREATE INDEX IF NOT EXISTS idx_usp_company_id ON user_scope_permissions(company_id);
CREATE INDEX IF NOT EXISTS idx_usp_site_id ON user_scope_permissions(site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_company
ON user_scope_permissions(user_id, module_id, action, scope_type, company_id)
WHERE scope_type = 'company';
CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_site
ON user_scope_permissions(user_id, module_id, action, scope_type, site_id)
WHERE scope_type = 'site';
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 INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key);
CREATE INDEX IF NOT EXISTS idx_modules_module_key ON modules(module_key);
COMMIT;

View File

@@ -1,2 +0,0 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS authentik_user_id INTEGER;

View File

@@ -1,73 +0,0 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'record_status') THEN
CREATE TYPE record_status AS ENUM ('active','inactive');
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS systems (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
system_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO systems (system_key, name, status)
VALUES ('member', 'Member Center', 'active')
ON CONFLICT (system_key) DO NOTHING;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scope_type') THEN
CREATE TYPE scope_type AS ENUM ('company','site');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN
CREATE TYPE permission_action AS ENUM ('view','create','update','delete','manage');
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS permission_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS permission_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
authentik_sub TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_sub)
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
system TEXT NOT NULL,
module TEXT NOT NULL,
action TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key);
CREATE INDEX IF NOT EXISTS idx_pgm_group_id ON permission_group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_pgm_authentik_sub ON permission_group_members(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_pgp_group_id ON permission_group_permissions(group_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_pgp_group_rule
ON permission_group_permissions(group_id, system, module, action, scope_type, scope_id);
COMMIT;

View File

@@ -1,27 +0,0 @@
BEGIN;
-- users / master tables
ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE companies ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE sites ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE systems ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE modules ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE permission_groups ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
-- api_clients
ALTER TABLE api_clients ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
-- user scoped permissions
ALTER TABLE user_scope_permissions ALTER COLUMN action TYPE VARCHAR(32) USING action::text;
ALTER TABLE user_scope_permissions ALTER COLUMN scope_type TYPE VARCHAR(16) USING scope_type::text;
-- keep check constraint compatible with varchar
ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check;
ALTER TABLE user_scope_permissions
ADD CONSTRAINT user_scope_permissions_check
CHECK (
((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL)
OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL))
);
COMMIT;

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source .venv/bin/activate
exec uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 --reload

View File

@@ -1,17 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
from app.security.authentik_jwt import AuthentikTokenVerifier
def test_infer_jwks_url() -> None:
assert AuthentikTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == (
"https://auth.ose.tw/application/o/member/jwks/"
)
def test_me_requires_bearer_token() -> None:
client = TestClient(app)
resp = client.get("/me")
assert resp.status_code == 401
assert resp.json()["detail"] == "missing_bearer_token"

View File

@@ -1,10 +0,0 @@
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"

View File

@@ -1,19 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
def test_internal_authentik_ensure_requires_config() -> None:
client = TestClient(app)
resp = client.post(
"/internal/authentik/users/ensure",
headers={"X-Internal-Secret": "CHANGE_ME"},
json={
"sub": "authentik-sub-1",
"email": "user@example.com",
"display_name": "User Example",
"is_active": True,
},
)
assert resp.status_code == 503
assert resp.json()["detail"] == "authentik_admin_not_configured"

44
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,44 @@
# member.ose.tw 架構總覽Keycloak 版)
## 核心模型
- 業務層:`companies -> sites`
- 身分層:`users <-> sites`(多對多,透過 `user_sites`
- 能力層:`systems -> roles`
- 授權層:`sites <-> roles`(多對多,透過 `site_roles`
## 權限模型(已定版)
- `permission` 正式改名為 `role`
- `role` 僅能指派給 `site`,不可直接指派給 `user`
- `system` / `role` 以 Keycloak 為唯一建立來源member 後台只做同步顯示與關聯。
- `user` 的有效角色由以下關聯推導:
- `user_sites`(使用者屬於哪些 site
- `site_roles`site 擁有哪些 role
- 不再使用舊的 `permission_groups` 主流程。
## Key 規則
- `system_key`: `SYyyyyMMddX####`
- `company_key`: `CPyyyyMMddX####`
- `site_key`: `STyyyyMMddX####`
- `role_key`: `RLyyyyMMddX####`
## Keycloak 同步策略
- Keycloak 為唯一 IdP。
- 群組階層:`Company Group -> Site SubGroup`
- 系統角色:以 Keycloak client role 表示,對應 DB `roles`
- `site_roles` 代表某 Site 擁有的 Keycloak role 集合。
- 同步策略改為手動觸發:不在列表讀取 (`R`) 時自動同步。
- 補齊策略:僅在手動同步按鈕(`POST /admin/sync/from-provider`)或 CUD 流程時同步。
- 站台角色指派(`PUT /admin/sites/{site_key}/roles``PUT /admin/roles/{role_key}/sites`)會即時同步到 Keycloak Group Role Mapping。
- 使用者加入 Site 時,透過同步邏輯使其在 IdP 端取得對應角色能力。
- 讀取效能:後端採用 memory cache後續可換 Redis`GET` 先讀快取;`POST/PUT/PATCH/DELETE` 成功後自動失效快取。
- 快取後端可由 `.env` 切換:`CACHE_BACKEND=memory|redis`(無需改程式)。
## 後台安全線
- `/admin/*` 必須 Bearer token。
- 後端以 Keycloak realm role 判定是否可進站與後台。
- 未具備 `MEMBER_REQUIRED_REALM_ROLES` 的帳號,`/me``/admin/*` 皆拒絕。
- 未具備 `ADMIN_REQUIRED_REALM_ROLES` 的帳號,`/admin/*` 拒絕。
## API 白名單
- 保留 `api_clients` 做系統對系統呼叫控管。
- 管理後台登入控管與 API client 白名單是兩條獨立安全線。

View File

@@ -1,36 +0,0 @@
# member 系統文件入口Architecture & Config
## 入口說明
這份文件是入口索引。若你只要快速開始,先看:
1. `docs/BACKEND_BOOTSTRAP.md`
2. `docs/BACKEND_ARCHITECTURE.md`
3. `docs/FRONTEND_ARCHITECTURE.md`
## 文件地圖
- `docs/index.md`
- 前端開工入口(給前端 AI 的第一份)
- `docs/BACKEND_BOOTSTRAP.md`
- 後端啟動步驟(環境、安裝、建表、啟動)
- `docs/BACKEND_ARCHITECTURE.md`
- memberapi 後端模組、資料流、API、安全策略
- `docs/FRONTEND_ARCHITECTURE.md`
- member 前端架構(可直接開工版)
- `docs/FRONTEND_API_CONTRACT.md`
- 前端 API request/response 契約
- `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
- 前端實作與交付核對清單
- `docs/TASKPLAN_FRONTEND.md`
- 前端任務進度與驗收條件
- `docs/TASKPLAN_BACKEND.md`
- 後端任務進度與驗收條件
- `backend/scripts/init_schema.sql`
- 一次建立完整 schema`api_clients`
- `docs/DB_SCHEMA_SNAPSHOT.md`
- 目前資料庫 schema 快照(欄位/索引/約束)
## 目前狀態2026-03-29
- 後端骨架已建立FastAPI + SQLAlchemy
- 核心 API已建立health/internal/admin
- API key 驗證:已建立(`X-Client-Key` + `X-API-Key`
- Authentik JWT 驗證:已建立(`/me` 路由 + JWKS 驗簽)
- Authentik Admin API建立/更新使用者):已建立(`/internal/authentik/users/ensure`

View File

@@ -1,37 +0,0 @@
# memberapi.ose.tw 後端架構(公司/品牌站台/會員)
## 核心主檔(對齊 DB Schema
- `users`:會員
- `companies`:公司
- `sites`:品牌站台(隸屬 company
- `systems`系統層member/mkt/...
- `modules`:模組(使用 `system.module` key
## 權限模型
- 直接權限:`user_scope_permissions`
- 群組權限:`permission_groups` + `permission_group_members` + `permission_group_permissions`
- Snapshot 回傳合併「user 直接 + group」去重
## 授權層級
- `system` 必填
- `module` 選填
- 有值:`{system}.{module}`(例:`mkt.campaign`
- 無值:系統層權限,使用 `system.__system__`
## 主要 API
- `GET /me`
- `GET /me/permissions/snapshot`
- `POST /admin/permissions/grant|revoke`
- `GET|POST /admin/systems`
- `GET|POST /admin/modules`
- `GET|POST /admin/companies`
- `GET|POST /admin/sites`
- `GET /admin/members`
- `GET|POST /admin/permission-groups`
- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
- `GET /internal/systems|modules|companies|sites|members`
## DB Migration
- 初始化:`backend/scripts/init_schema.sql`
- 舊庫補齊:`backend/scripts/migrate_align_company_site_member_system.sql`

View File

@@ -1,38 +0,0 @@
# 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/backend/scripts/init_schema.sql`(已含 `api_clients`
2. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_align_company_site_member_system.sql`
3. 若是更舊資料庫,再補 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
## 3. 啟動服務
```bash
cd member.ose.tw/backend
./scripts/start_dev.sh
```
說明:
- `start_dev.sh` 會用 `--env-file .env.development` 啟動,不需要每次手改 `.env`
## 4. Authentik JWT 最小設定
`.env` 至少填一種:
- `AUTHENTIK_JWKS_URL=<jwks endpoint>`
- `AUTHENTIK_ISSUER=<issuer url>`(系統會推導 `<issuer>/jwks/`
可選:
- `AUTHENTIK_AUDIENCE=<audience>`
- `AUTHENTIK_CLIENT_SECRET=<client_secret>`token 為 HS256 時需要)
## 5. 快速驗證
```bash
curl -sS http://127.0.0.1:8000/healthz
```

27
docs/BACKEND_TASKPLAN.md Normal file
View File

@@ -0,0 +1,27 @@
# Backend TaskPlan
## 待辦
- [ ] Keycloak 同步器優化Company/Site group 同步、System client role 同步、Site 角色套用同步(含效能與重複同步抑制)。
- [ ] 補齊 pytest API 測試CRUD、關聯、同步、刪除、錯誤碼
- [ ] 補一支「一鍵重建 schema」腳本`init_schema.sql`)。
## 進行中
- [ ] Swagger response model 與前端實際畫面持續對齊。
## 已完成
- [x] schema 重建:清除舊表,改為 `roles``site_roles``user_sites` 主流程。
- [x] 移除舊後端程式:`module/permission_group/user_scope_permissions` 相關 model/repo/api。
- [x] Admin API 改版:`Company/Site/System/Role/User` CRUD 與關聯 API。
- [x] Role 指派只允許綁 Site不提供 user direct role API。
- [x] `/me/permissions/snapshot` 改為 role 聚合格式。
- [x] Internal API 改版:可取 `users/{user_sub}/roles` 聚合結果。
- [x] 保留 `api_clients` 白名單管理 API。
- [x] Keycloak OIDC 登入主流程。
- [x] `/admin/*` Bearer + admin 群組白名單安全線。
- [x] 公司/站台 CRUD 同步 Keycloak Groupcreate/update/delete
- [x] Keycloak -> DB 補齊同步(公司/站台/系統/角色/使用者)。
- [x] 系統改為 Keycloak 唯一來源(後台停用 system CRUD
- [x] Role CRUD 同步 Provider Client Role新增/修改/刪除會同步到 Provider
- [x] Site/Role 關聯指派同步 Keycloak Group Role Mapping雙向指派入口皆同步
- [x] 後端讀取快取memory與 CUD 自動失效機制(可後續切 Redis
- [x] 快取後端抽象完成:`.env` 可切換 `memory` / `redis`

102
docs/DB_SCHEMA.md Normal file
View File

@@ -0,0 +1,102 @@
# DB Schema新架構目標版
> 本文件是新架構的目標資料模型,供後端 schema 重建與 migration 依據。
> DB 真實來源仍以 [backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql) 為準。
## 1) companies
- `id` UUID PK default `gen_random_uuid()`
- `company_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `provider_group_id` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 2) sites
- `id` UUID PK default `gen_random_uuid()`
- `site_key` TEXT NOT NULL UNIQUE
- `company_id` UUID NOT NULL FK -> `companies(id)` ON DELETE CASCADE
- `display_name` TEXT NOT NULL
- `domain` TEXT
- `provider_group_id` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 3) systems
- `id` UUID PK default `gen_random_uuid()`
- `system_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 4) roles
- `id` UUID PK default `gen_random_uuid()`
- `role_key` TEXT NOT NULL UNIQUE
- `system_id` UUID NOT NULL FK -> `systems(id)` ON DELETE CASCADE
- `name` TEXT NOT NULL
- `description` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`system_id`, `name`)
## 5) site_roles
- `id` UUID PK default `gen_random_uuid()`
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
- `role_id` UUID NOT NULL FK -> `roles(id)` ON DELETE CASCADE
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`site_id`, `role_id`)
## 6) users
- `id` UUID PK default `gen_random_uuid()`
- `user_sub` TEXT NOT NULL UNIQUE
- `provider_user_id` TEXT UNIQUE
- `username` TEXT UNIQUE
- `email` TEXT UNIQUE
- `display_name` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `is_active` BOOLEAN NOT NULL default `true`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 7) user_sites
- `id` UUID PK default `gen_random_uuid()`
- `user_id` UUID NOT NULL FK -> `users(id)` ON DELETE CASCADE
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`user_id`, `site_id`)
## 8) auth_sync_state
- `id` UUID PK default `gen_random_uuid()`
- `entity_type` VARCHAR(32) NOT NULL
- `entity_id` UUID NOT NULL
- `last_synced_at` TIMESTAMPTZ
- `source_version` TEXT
- `last_error` TEXT
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`entity_type`, `entity_id`)
## 9) api_clients
- `id` UUID PK default `gen_random_uuid()`
- `client_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `status` VARCHAR(16) NOT NULL default `'active'`
- `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()`
## 關聯總結
- Company 1:N Site
- System 1:N Role
- Site M:N Role`site_roles`
- User M:N Site`user_sites`
- User 最終角色由 Site 推導,不做 user direct role 指派。

View File

@@ -1,215 +0,0 @@
# DB Schema Snapshot
- Date: 2026-03-30
- Database: `member.ose.tw`
- Schema: `public`
## Tables
- `api_clients`
- `auth_sync_state`
- `companies`
- `modules`
- `permissions`
- `sites`
- `user_scope_permissions`
- `users`
## `api_clients`
### Columns
- `id`: `uuid` (not null: `true`)
- `client_key`: `text` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `client_status` (not null: `true`)
- `api_key_hash`: `text` (not null: `true`)
- `allowed_origins`: `jsonb` (not null: `true`)
- `allowed_ips`: `jsonb` (not null: `true`)
- `allowed_paths`: `jsonb` (not null: `true`)
- `rate_limit_per_min`: `integer` (not null: `false`)
- `expires_at`: `timestamp with time zone` (not null: `false`)
- `last_used_at`: `timestamp with time zone` (not null: `false`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `api_clients_client_key_key` (`u`): UNIQUE (client_key)
- `api_clients_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `api_clients_client_key_key`: `CREATE UNIQUE INDEX api_clients_client_key_key ON public.api_clients USING btree (client_key)`
- `api_clients_pkey`: `CREATE UNIQUE INDEX api_clients_pkey ON public.api_clients USING btree (id)`
- `idx_api_clients_expires_at`: `CREATE INDEX idx_api_clients_expires_at ON public.api_clients USING btree (expires_at)`
- `idx_api_clients_status`: `CREATE INDEX idx_api_clients_status ON public.api_clients USING btree (status)`
## `auth_sync_state`
### Columns
- `user_id`: `uuid` (not null: `true`)
- `last_synced_at`: `timestamp with time zone` (not null: `false`)
- `source_version`: `text` (not null: `false`)
- `last_error`: `text` (not null: `false`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `auth_sync_state_pkey` (`p`): PRIMARY KEY (user_id)
- `auth_sync_state_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
### Indexes
- `auth_sync_state_pkey`: `CREATE UNIQUE INDEX auth_sync_state_pkey ON public.auth_sync_state USING btree (user_id)`
## `companies`
### Columns
- `id`: `uuid` (not null: `true`)
- `company_key`: `text` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `companies_company_key_key` (`u`): UNIQUE (company_key)
- `companies_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `companies_company_key_key`: `CREATE UNIQUE INDEX companies_company_key_key ON public.companies USING btree (company_key)`
- `companies_pkey`: `CREATE UNIQUE INDEX companies_pkey ON public.companies USING btree (id)`
## `modules`
### Columns
- `id`: `uuid` (not null: `true`)
- `module_key`: `text` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `modules_module_key_key` (`u`): UNIQUE (module_key)
- `modules_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `modules_module_key_key`: `CREATE UNIQUE INDEX modules_module_key_key ON public.modules USING btree (module_key)`
- `modules_pkey`: `CREATE UNIQUE INDEX modules_pkey ON public.modules USING btree (id)`
## `permissions`
### Columns
- `id`: `uuid` (not null: `true`)
- `user_id`: `uuid` (not null: `true`)
- `scope_type`: `character varying(32)` (not null: `true`)
- `scope_id`: `character varying(128)` (not null: `true`)
- `module`: `character varying(128)` (not null: `true`)
- `action`: `character varying(32)` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `permissions_pkey` (`p`): PRIMARY KEY (id)
- `permissions_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- `uq_permissions_user_scope_module_action` (`u`): UNIQUE (user_id, scope_type, scope_id, module, action)
### Indexes
- `idx_permissions_user_id`: `CREATE INDEX idx_permissions_user_id ON public.permissions USING btree (user_id)`
- `permissions_pkey`: `CREATE UNIQUE INDEX permissions_pkey ON public.permissions USING btree (id)`
- `uq_permissions_user_scope_module_action`: `CREATE UNIQUE INDEX uq_permissions_user_scope_module_action ON public.permissions USING btree (user_id, scope_type, scope_id, module, action)`
## `sites`
### Columns
- `id`: `uuid` (not null: `true`)
- `site_key`: `text` (not null: `true`)
- `company_id`: `uuid` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `sites_company_id_fkey` (`f`): FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
- `sites_pkey` (`p`): PRIMARY KEY (id)
- `sites_site_key_key` (`u`): UNIQUE (site_key)
### Indexes
- `idx_sites_company_id`: `CREATE INDEX idx_sites_company_id ON public.sites USING btree (company_id)`
- `sites_pkey`: `CREATE UNIQUE INDEX sites_pkey ON public.sites USING btree (id)`
- `sites_site_key_key`: `CREATE UNIQUE INDEX sites_site_key_key ON public.sites USING btree (site_key)`
## `user_scope_permissions`
### Columns
- `id`: `uuid` (not null: `true`)
- `user_id`: `uuid` (not null: `true`)
- `module_id`: `uuid` (not null: `true`)
- `action`: `permission_action` (not null: `true`)
- `scope_type`: `scope_type` (not null: `true`)
- `company_id`: `uuid` (not null: `false`)
- `site_id`: `uuid` (not null: `false`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `user_scope_permissions_check` (`c`): CHECK ((((scope_type = 'company'::scope_type) AND (company_id IS NOT NULL) AND (site_id IS NULL)) OR ((scope_type = 'site'::scope_type) AND (site_id IS NOT NULL) AND (company_id IS NULL))))
- `user_scope_permissions_company_id_fkey` (`f`): FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
- `user_scope_permissions_module_id_fkey` (`f`): FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
- `user_scope_permissions_pkey` (`p`): PRIMARY KEY (id)
- `user_scope_permissions_site_id_fkey` (`f`): FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
- `user_scope_permissions_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
### Indexes
- `idx_usp_company_id`: `CREATE INDEX idx_usp_company_id ON public.user_scope_permissions USING btree (company_id)`
- `idx_usp_module_id`: `CREATE INDEX idx_usp_module_id ON public.user_scope_permissions USING btree (module_id)`
- `idx_usp_site_id`: `CREATE INDEX idx_usp_site_id ON public.user_scope_permissions USING btree (site_id)`
- `idx_usp_user_id`: `CREATE INDEX idx_usp_user_id ON public.user_scope_permissions USING btree (user_id)`
- `uq_usp_company`: `CREATE UNIQUE INDEX uq_usp_company ON public.user_scope_permissions USING btree (user_id, module_id, action, scope_type, company_id) WHERE (scope_type = 'company'::scope_type)`
- `uq_usp_site`: `CREATE UNIQUE INDEX uq_usp_site ON public.user_scope_permissions USING btree (user_id, module_id, action, scope_type, site_id) WHERE (scope_type = 'site'::scope_type)`
- `user_scope_permissions_pkey`: `CREATE UNIQUE INDEX user_scope_permissions_pkey ON public.user_scope_permissions USING btree (id)`
## `users`
### Columns
- `id`: `uuid` (not null: `true`)
- `authentik_sub`: `text` (not null: `true`)
- `email`: `text` (not null: `false`)
- `display_name`: `text` (not null: `false`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
- `authentik_user_id`: `integer` (not null: `false`)
- `is_active`: `boolean` (not null: `true`)
### Constraints
- `users_authentik_sub_key` (`u`): UNIQUE (authentik_sub)
- `users_email_key` (`u`): UNIQUE (email)
- `users_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `idx_users_authentik_sub`: `CREATE INDEX idx_users_authentik_sub ON public.users USING btree (authentik_sub)`
- `users_authentik_sub_key`: `CREATE UNIQUE INDEX users_authentik_sub_key ON public.users USING btree (authentik_sub)`
- `users_email_key`: `CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)`
- `users_pkey`: `CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)`

View File

@@ -1,100 +0,0 @@
# Frontend API Contractmemberapi
Base URL`https://memberapi.ose.tw`
## 0. OIDC 登入
- `GET /auth/oidc/url?redirect_uri=...`
- `POST /auth/oidc/exchange`
## 1. 使用者資訊
- `GET /me`
- `GET /me/permissions/snapshot`
`permissions` item:
```json
{
"scope_type": "company|site",
"scope_id": "company_key_or_site_key",
"system": "mkt",
"module": "mkt.campaign",
"action": "view"
}
```
## 2. 權限User 直接授權)
Headers:
- `X-Client-Key`
- `X-API-Key`
### POST `/admin/permissions/grant`
```json
{
"authentik_sub": "authentik-sub",
"email": "user@example.com",
"display_name": "User",
"scope_type": "company",
"scope_id": "ose-main",
"system": "mkt",
"module": "campaign",
"action": "view"
}
```
### POST `/admin/permissions/revoke`
```json
{
"authentik_sub": "authentik-sub",
"scope_type": "site",
"scope_id": "tw-main",
"system": "mkt",
"module": "campaign",
"action": "view"
}
```
說明:
- `module` 可省略,代表系統層權限,後端會使用 `system.__system__`
- `module` 有值時會組成 `{system}.{module}` 存入(例如 `mkt.campaign`)。
## 3. 主資料管理admin
Headers:
- `X-Client-Key`
- `X-API-Key`
- `GET/POST /admin/systems`
- `GET/POST /admin/modules`
- `GET/POST /admin/companies`
- `GET/POST /admin/sites`
- `GET /admin/members`
## 4. 權限群組(一組權限綁多個 user
Headers:
- `X-Client-Key`
- `X-API-Key`
- `GET/POST /admin/permission-groups`
- `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `POST /admin/permission-groups/{group_key}/permissions/grant`
- `POST /admin/permission-groups/{group_key}/permissions/revoke`
群組授權 payload 與 user 授權 payload 相同(用 `system/module/scope/action`)。
## 5. Internal 查詢 API其他系統
Headers:
- `X-Internal-Secret`
- `GET /internal/systems`
- `GET /internal/modules`
- `GET /internal/companies`
- `GET /internal/sites`
- `GET /internal/members`
- `GET /internal/permissions/{authentik_sub}/snapshot`
## 6. 常見錯誤
- `401 invalid_client`
- `401 invalid_api_key`
- `401 invalid_internal_secret`
- `404 system_not_found`
- `404 company_not_found`
- `404 site_not_found`

View File

@@ -1,87 +0,0 @@
# member.ose.tw 前端架構(可直接開工版)
## 1. 前端責任邊界
- 站點:`member.ose.tw`
- 主要責任:
- 顯示目前登入使用者資訊
- 顯示目前使用者權限快照
- 透過管理 API 執行 grant/revoke
- 不處理:
- Authentik 管理 API 直連
- 密碼重設流程(導向 Authentik
## 2. 建議技術堆疊
- Vue 3 + Vite + JavaScript
- Vue Router
- Pinia
- Axios
- Element Plus + Tailwind
## 3. 建議目錄結構
- `frontend/src/main.js`
- `frontend/src/router/index.js`
- `frontend/src/stores/`
- `auth.js`token、me、登入狀態
- `permission.js`snapshot 與快取
- `frontend/src/api/`
- `http.js`axios instance + interceptor
- `me.js`
- `permission-admin.js`
- `frontend/src/pages/`
- `profile/MePage.vue`
- `permissions/PermissionSnapshotPage.vue`
- `permissions/PermissionAdminPage.vue`
- `frontend/src/types/`
- `api.js`
## 4. 路由規劃(第一版)
- `/me`
- 顯示 `GET /me` 資料
- `/me/permissions`
- 顯示 `GET /me/permissions/snapshot`
- `/admin/permissions`
- grant/revoke 表單與結果顯示
## 5. Token 與 Header 策略
- 使用者路由(`/me*`
- 登入用 `POST /auth/login`(帳號密碼)取得 access token
- header: `Authorization: Bearer <access_token>`
- 管理路由(`/admin*`
- headers:
- `X-Client-Key`
- `X-API-Key`
- 建議:
- 使用 axios request interceptor 統一注入 header
- 401 時統一導向登入或刷新流程
## 6. 狀態管理最小模型
- `auth store`
- `accessToken: string | null`
- `me: { sub, email, display_name } | null`
- actions: `setToken`, `fetchMe`, `logout`
- `permission store`
- `snapshot: { authentik_sub, permissions[] } | null`
- actions: `fetchMySnapshot`, `grantPermission`, `revokePermission`
## 7. 錯誤處理規則
- `401`
- 使用者 token 過期或無效,導回登入
- `403`
- API client 白名單限制origin/ip/path
- `404`
- revoke 時 user 不存在
- `503`
- 後端必要設定缺失(例如 internal secret / authentik admin
## 8. 開工順序(建議)
1. 建立 axios client 與 interceptor
2. 完成 `/me``/me/permissions` 畫面
3. 完成 grant/revoke 表單
4. 補通知、loading、錯誤提示
詳細 request/response 契約請看 `docs/FRONTEND_API_CONTRACT.md`
## 9. 本地開發環境
- Vite 會自動讀取 `frontend/.env.development`
- 已預設:
- `VITE_API_BASE_URL=http://127.0.0.1:8000`

46
docs/FRONTEND_HANDOFF.md Normal file
View File

@@ -0,0 +1,46 @@
# Frontend HandoffRole-Site 模型)
## 目標
前端只實作新模型,不再使用舊 `permission_groups` / `module-action` 流程。
## 主要頁面
1. 公司管理CRUD
- 欄位:`company_key`, `name`, `status`
- 詳情頁需顯示底下 `sites` 列表
2. 站台管理CRUD
- 欄位:`site_key`, `company_key`, `display_name`, `domain`, `status`
- 站台詳情需顯示:
- 此站台綁定的 `roles`
- 此站台包含的 `users`
3. 系統管理(唯讀 + 同步)
- 欄位:`system_key`, `name`, `status`
- 系統詳情需顯示底下 `roles` 列表
- 建立/修改/刪除在 Keycloak 處理member 後台提供「同步 Keycloak」按鈕
- 所有資料列表頁不自動同步;需由使用者按下「同步」按鈕才觸發。
4. 角色管理DB 關聯為主)
- 欄位:`role_key`, `system_key`, `name`, `description`, `status`
- 關聯操作:指派到 Site新增/刪除 `site_roles`
- 需支援在「角色頁」直接多選站台並儲存(不必切到站台頁)。
5. 會員管理CRUD
- 欄位:`user_sub`, `username`, `email`, `display_name`, `is_active`, `status`
- 關聯操作:加入/移除 Site新增/刪除 `user_sites`
- 顯示推導角色(唯讀)
6. API Clients 管理CRUD
- 欄位:`client_key`, `name`, `status`, `allowed_origins`, `allowed_ips`, `allowed_paths`, `rate_limit_per_min`, `expires_at`
## 前端互動規則
- 角色不可直接綁會員UI 不提供此操作)。
- Site 與 User 的關聯調整後,角色顯示即時刷新。
- 刪除操作一律二次確認,顯示影響提示。
## 驗收重點
- 看不到舊 permission group 流程。
- 可以完整做:
- Site 綁 Role
- User 綁 Site
- 顯示 User 推導角色

View File

@@ -1,101 +0,0 @@
# Frontend 交辦清單Schema v2✅ 已完成
## 目標
前端實現對應後端新模型:
- 公司companies
- 品牌站台sites
- 會員users
- 系統/模組systems/modules
- 權限群組permission-groups
## 既有頁面調整
### 1) 權限管理頁 `/admin/permissions` ✅
- [x] Grant/Revoke payload 改為:
- [x] `scope_type`: `company``site`(下拉選單)
- [x] `scope_id`: `company_key``site_key`
- [x] `system`: 必填(例如 `mkt`
- [x] `module`: 選填(空值代表系統層權限)
- [x] `action`
- [x] 表單新增 `system` 欄位
- [x] `module` 改成可選
### 2) 我的權限頁 `/me/permissions` ✅
- [x] 表格新增顯示欄位:
- [x] `scope_type`
- [x] `scope_id`
- [x] `system`
- [x] `module`
- [x] `action`
## 新增頁面 ✅
### 3) 系統管理 `/admin/systems` ✅
- [x] 列表:`GET /admin/systems`
- [x] 新增:`POST /admin/systems`
- [x] 表格顯示 system_key 與 name
- [x] Dialog 表單新增系統
### 4) 模組管理 `/admin/modules` ✅
- [x] 列表:`GET /admin/modules`
- [x] 新增:`POST /admin/modules`
- [x] `system_key`
- [x] `module_key`
- [x] `name`
- [x] 表格顯示三個欄位
- [x] Dialog 表單新增模組
### 5) 公司管理 `/admin/companies` ✅
- [x] 列表:`GET /admin/companies`
- [x] 新增:`POST /admin/companies`
- [x] 表格顯示 company_key 與 name
- [x] Dialog 表單新增公司
### 6) 站台管理 `/admin/sites` ✅
- [x] 列表:`GET /admin/sites`
- [x] 新增:`POST /admin/sites`
- [x] `site_key`
- [x] `company_key`
- [x] `name`
- [x] 表格顯示三個欄位
- [x] Dialog 表單新增站台
### 7) 會員列表 `/admin/members` ✅
- [x] 列表:`GET /admin/members`
- [x] 表格顯示 authentik_sub、email、display_name
- [x] 可重新整理
### 8) 權限群組 `/admin/permission-groups` ✅
- [x] 群組管理 Tab
- [x] 列表:`GET /admin/permission-groups`
- [x] 新增:`POST /admin/permission-groups`
- [x] Dialog 表單新增群組
- [x] 綁定會員 Tab
- [x] `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
- [x] UI 支援群組選擇 + authentik_sub 輸入 + 加入按鈕
- [x] 群組授權 Tab
- [x] `POST /admin/permission-groups/{group_key}/permissions/grant`
- [x] `POST /admin/permission-groups/{group_key}/permissions/revoke`
- [x] UI 支援選擇群組、輸入權限資訊、grant/revoke 按鈕
## 共用資料管理 ✅
- [x] admin.js store 實現:
- [x] 統一載入 systems、modules、companies、sites
- [x] 供各管理頁使用,避免重複 API 呼叫
## 認證(管理 API
- [x] 所有 `/admin/*` API 一律帶:
- [x] `X-Client-Key`
- [x] `X-API-Key`
- [x] axios adminHttp client 自動注入 headers
## 驗收條件 ✅
- [x] 可以新增 system/module/company/site
- [x] 可以做 user 直接 grant/revoke新 payload
- [x] 可以建立 permission-group、加會員、做群組 grant/revoke
- [x] `/me/permissions/snapshot` 能看到所有權限欄位scope_type/scope_id/system/module/action
## 完成日期
- 開始2026-03-29
- 完成2026-03-30
- 提交 Commit`c4b9789`

View File

@@ -1,29 +0,0 @@
# Frontend Implementation Checklist
## A. 專案初始化
- [ ] 建立 `src/api/http.js`
- [ ] 建立 `src/stores/auth.js`
- [ ] 建立 `src/stores/permission.js`
- [ ] 配置 `VITE_API_BASE_URL`
## B. API 對接
- [ ] `GET /me`
- [ ] `GET /me/permissions/snapshot`
- [ ] `POST /admin/permissions/grant`
- [ ] `POST /admin/permissions/revoke`
## C. 頁面
- [ ] Me 頁面(顯示 sub/email/display_name
- [ ] 我的權限頁(表格)
- [ ] 權限管理頁grant/revoke
## D. 行為驗證
- [ ] Bearer token 遺失時顯示重新登入
- [ ] grant 成功後自動刷新 snapshot
- [ ] revoke 成功後自動刷新 snapshot
- [ ] 404 user_not_found 顯示可讀錯誤訊息
## E. 交付條件
- [ ] 三個頁面都可獨立刷新
- [ ] 所有 API 錯誤碼都有 UI 提示
- [ ] 主要操作有 loading 與成功提示

20
docs/FRONTEND_TASKPLAN.md Normal file
View File

@@ -0,0 +1,20 @@
# Frontend TaskPlan
## 待辦
- [ ] API 失敗狀態頁統一401/403/409/422/500
## 進行中
- [ ] OIDC callback 與 token 持久化穩定性檢查(含 token 到期後重新登入行為)。
## 已完成
- [x] Vue3 + JS + Vite + Element Plus + Tailwind 基礎框架。
- [x] OIDC 登入按鈕導轉與 callback 路由骨架。
- [x] 後台導覽改版:`公司 / 站台 / 系統 / 角色 / 會員 / API Clients`
- [x] 公司頁CRUD + 公司底下站台列表。
- [x] 站台頁CRUD + 站台角色指派 + 站台會員列表。
- [x] 系統頁:改為 Keycloak 唯一來源(唯讀 + 手動同步按鈕 + 角色列表)。
- [x] 角色頁CRUD + 角色綁定站台列表。
- [x] 會員頁CRUD + User 綁 Site + 顯示推導角色 + 重設密碼。
- [x] 刪除流程補齊(公司/站台/系統/角色/會員/API client
- [x] 移除舊流程前端檔案:`modules` / `permission_groups` / `permission_admin` 頁面與 API。
- [x] 我的角色頁改為 role snapshot 顯示(不再顯示舊 permission 欄位)。

View File

@@ -0,0 +1,49 @@
# Internal API Handoff新模型
## Base URL
- Local: `http://127.0.0.1:8000`
- Prod: 由部署環境提供
## Auth Headers`/internal/*`
- `X-Client-Key: <client_key>`
- `X-API-Key: <api_key>`
## Common Error Response
```json
{ "detail": "error_code" }
```
## 已實作端點
1. `GET /internal/companies`
2. `GET /internal/sites`
3. `GET /internal/systems`
4. `GET /internal/roles`
5. `GET /internal/members`
6. `POST /internal/users/upsert-by-sub`
7. `GET /internal/users/{user_sub}/roles`
8. `POST /internal/provider/users/ensure`
9. `POST /internal/idp/users/ensure`(舊路徑相容,不建議新串接使用)
10. `POST /internal/keycloak/users/ensure`(舊路徑相容,不建議新串接使用)
## 角色聚合回應(`GET /internal/users/{user_sub}/roles`
```json
{
"user_sub": "xxxxxxxx",
"roles": [
{
"site_key": "ST20260402X1234",
"site_display_name": "OSE Main",
"company_key": "CP20260402X5678",
"company_display_name": "OSE",
"system_key": "SY20260402X0001",
"system_name": "Marketing",
"role_key": "RL20260402X0002",
"role_name": "campaign_edit"
}
]
}
```
## 注意事項
- 不提供 user direct role 寫入 API。
- User 最終角色由 `user_sites` + `site_roles` 推導。

93
docs/LOCAL_DEV_RUNBOOK.md Normal file
View File

@@ -0,0 +1,93 @@
# Local Dev RunbookKeycloak
## 1) 先重建 DB schema本次改版必做
```bash
cd backend
psql "$DATABASE_URL" -f scripts/init_schema.sql
```
- DB schema 檔案:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
如果你是 macOS 本機沒裝 `psql`,改用:
```bash
cd backend
./.venv/bin/python - <<'PY'
import psycopg
from pathlib import Path
sql = Path('scripts/migrate_provider_columns.sql').read_text()
with psycopg.connect(
host='127.0.0.1',
port=54321,
dbname='member.ose.tw',
user='member_ose',
password='你的DB密碼'
) as conn:
with conn.cursor() as cur:
cur.execute(sql)
print('provider column migration done')
PY
```
- 欄位改名 migration[backend/scripts/migrate_provider_columns.sql](../backend/scripts/migrate_provider_columns.sql)
## 2) 啟動後端
```bash
cd backend
./scripts/start_dev.sh
```
- 專案路徑:[backend](../backend)
- 啟動腳本:[backend/scripts/start_dev.sh](../backend/scripts/start_dev.sh)
## 3) 啟動前端
```bash
cd frontend
npm install
npm run dev
```
- 專案路徑:[frontend](../frontend)
## 4) 必要環境變數([backend/.env.development](../backend/.env.development)
- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- `MEMBER_REQUIRED_REALM_ROLES`
- `ADMIN_REQUIRED_REALM_ROLES`
- `CACHE_BACKEND``memory``redis`
- `CACHE_REDIS_URL`
- `CACHE_PREFIX`
- `CACHE_DEFAULT_TTL_SECONDS`
### Cache 切換範例
- 本地(預設):
- `CACHE_BACKEND=memory`
- 切 Redis
- `CACHE_BACKEND=redis`
- `CACHE_REDIS_URL=redis://127.0.0.1:6379/0`
調整後重啟後端生效。
## 5) 基本檢查
1. `GET http://127.0.0.1:8000/healthz` 應為 200。
2. 前端按「前往 Keycloak 登入」應可成功導轉與回跳。
3. `GET /me` 登入後應有資料。
4. 非 admin 群組帳號打 `/admin/*` 應為 403。
5. `POST /admin/sync/from-provider?force=true` 可手動觸發全量補齊同步。
6. 列表 API 不會自動同步 IdP避免高負載需手動按同步按鈕或呼叫同步 API。
## 6) 新模型驗收路徑
1. 新增 Company、Site。
2. 在 Keycloak 建立 SystemClient與 RoleClient Role
3. 在後台按「同步 Keycloak」確認 DB 補齊 System/Role。
4. 對 Site 指派 Role。
5. 新增 User加入 Site。
6. 驗證 User 的角色是由 Site 推導,不是 direct assign。
## 7) API 白名單驗收
1. 建立 `api_client`
2.`X-Client-Key` + `X-API-Key` 呼叫 `/internal/*`
3. 驗證未授權 key 會被拒絕。
## 8) VPS Docker 啟動Backend
- Dockerfile: [backend/Dockerfile](../backend/Dockerfile)
- 建置與啟動指令:參考 [backend/README.md](../backend/README.md) 的 `Docker (VPS / Production)` 章節。

View File

@@ -1,94 +0,0 @@
# 組織與會員管理規劃(給前端/後端協作)
## 1. 目前狀態(你現在看到空白是正常)
- 已完成:
- Authentik 登入
- `/me` 基本個人資料
- 權限 grant/revoke 與 snapshot
- 尚未完成:
- 公司組織OrganizationCRUD
- 會員Member清單/新增/編輯/停用
- 會員與組織關聯管理
## 2. 建議產品資訊架構IA
- `我的資料`:目前登入者基本資訊、登出
- `我的權限`:目前登入者權限快照
- `組織管理`:公司清單、建立公司、編輯公司、狀態切換
- `會員管理`:會員清單、邀請/建立會員、編輯會員、停用會員、指派組織
- `權限管理`:保留現有 grant/revoke可作為管理員進階頁
## 3. 後端 APIv1已開
### Organizationadmin
- `GET /admin/organizations`
- `POST /admin/organizations`
- `GET /admin/organizations/{org_id}`
- `PATCH /admin/organizations/{org_id}`
- `POST /admin/organizations/{org_id}/activate`
- `POST /admin/organizations/{org_id}/deactivate`
### Memberadmin
- `GET /admin/members`
- `POST /admin/members`
- `GET /admin/members/{member_id}`
- `PATCH /admin/members/{member_id}`
- `POST /admin/members/{member_id}/activate`
- `POST /admin/members/{member_id}/deactivate`
### Member x Organizationadmin
- `GET /admin/members/{member_id}/organizations`
- `POST /admin/members/{member_id}/organizations/{org_id}`
- `DELETE /admin/members/{member_id}/organizations/{org_id}`
### Internal 查詢 API給其他系統
- `GET /internal/members`
- `GET /internal/members/by-sub/{authentik_sub}`
- `GET /internal/organizations`
- `GET /internal/organizations/by-code/{org_code}`
- `GET /internal/members/{member_id}/organizations`
## 4. 建議資料表(最小可行)
- `organizations`
- `id` (uuid)
- `org_code` (unique)
- `name`
- `tax_id` (nullable)
- `status` (`active|inactive`)
- `created_at`, `updated_at`
- `members`
- `id` (uuid)
- `authentik_sub` (unique)
- `email`
- `display_name`
- `status` (`active|inactive`)
- `created_at`, `updated_at`
- `member_organizations`
- `member_id`
- `organization_id`
- unique(`member_id`, `organization_id`)
## 5. 前端頁面需求(給另一個 AI
- `/admin/organizations`
- 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用
- `/admin/members`
- 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用
- `/admin/members/:id/organizations`
- 左側會員資訊,右側組織綁定清單 + 加入/移除
## 6. 權限模型(建議)
- `org.manage`:組織管理
- `member.manage`:會員管理
- `permission.manage`:權限管理
可映射到現有權限欄位:
- `scope_type=global`
- `scope_id=member-center`
- `module=organization|member|permission`
- `action=view|create|update|deactivate|grant|revoke`
## 7. 驗收標準
- 可以建立/修改/停用組織
- 可以建立/修改/停用會員
- 可以將會員加入/移出組織
- UI 顯示成功/失敗訊息,並可重新整理資料
- 所有管理 API 都有管理員金鑰驗證(`X-Client-Key` + `X-API-Key`

View File

@@ -1,35 +0,0 @@
# Backend TaskPlan
## 目標
提供可被前端穩定串接的 memberapi包含身份驗證、權限快照、權限管理與 Authentik 同步能力。
## 已完成
- [x] FastAPI 專案骨架與分層api/service/repository/model
- [x] `GET /healthz`
- [x] `GET /me`
- [x] `GET /me/permissions/snapshot`
- [x] `POST /admin/permissions/grant`
- [x] `POST /admin/permissions/revoke`
- [x] `POST /internal/users/upsert-by-sub`
- [x] `GET /internal/permissions/{authentik_sub}/snapshot`
- [x] `POST /internal/authentik/users/ensure`
- [x] API client 驗證(`X-Client-Key` + `X-API-Key`
- [x] Authentik JWT 驗證JWKS
- [x] DB schema 初始化 SQL + migration SQL
- [x] 基本測試與 lint
## 進行中(建議近期)
- [ ] 增加 Alembic migration 正式流程
- [ ] 補齊更多 API 測試admin/internal success + fail cases
- [ ] 增加 request id / logging 格式
## 待辦(上線前)
- [ ] 加入 rate limit依 client 或 IP
- [ ] 針對 admin API 加 audit log授權/撤銷行為)
- [ ] 加上 CORS 白名單與反向代理配置驗證
- [ ] 製作部署腳本systemd / container
## 驗收條件
- [ ] 前端可完成 `/me` + `/me/permissions/snapshot` + grant/revoke 全流程
- [ ] 401/403/404/503 錯誤碼與文件一致
- [ ] 至少一輪 staging smoke test 通過

View File

@@ -1,60 +0,0 @@
# Frontend TaskPlan
## 目標
完成 member.ose.tw 前端Vue3 + JS + Vite + Element Plus + Tailwind支援 OIDC 登入、個人資料查看、權限管理Schema v2
## Phase 1: 基礎框架 ✅
- [x] Vite + Vue3 專案結構
- [x] Element Plus + Tailwind 基礎接入
- [x] Router 與頁面骨架
- [x] Pinia storeauth + permission
- [x] Axios 分離 user/admin client
- [x] Production build 可通過
## Phase 2: OIDC 登入流程 ✅
- [x] `LoginPage`OIDC 前往按鈕,跳轉 Authentik
- [x] `AuthCallbackPage`(接收 code交換 access_token
- [x] Token 自動存儲與路由守衛
- [x] 401 時自動導向重新登入
## Phase 3: 用戶資訊與權限 ✅
- [x] `MePage``GET /me` 顯示個人資料)
- [x] `PermissionSnapshotPage``GET /me/permissions/snapshot`
- [x] 表格新增 `system` 欄位Schema v2
## Phase 4: 管理員授權v1
- [x] `PermissionAdminPage`(直接 grant/revoke 使用者)
- [x] Payload 新增 `system` 必填、`module` 改為選填
- [x] `scope_type` 改為 company/site 下拉選單
## Phase 5: Schema v2 管理頁面 ✅
- [x] API 層systems、modules、companies、sites、members、permission-groups
- [x] Storeadmin.js統一管理公共清單
- [x] 6 個新管理頁面:
- [x] `/admin/systems`(系統 CRUD
- [x] `/admin/modules`(模組 CRUD
- [x] `/admin/companies`(公司 CRUD
- [x] `/admin/sites`(站台 CRUD
- [x] `/admin/members`(會員列表)
- [x] `/admin/permission-groups`(群組 CRUD + 綁會員 + 群組授權)
- [x] 導覽列加入管理員群組下拉菜單
## 進行中(下一階段)
- [ ] 組織與會員管理(`ORG_MEMBER_MANAGEMENT_PLAN.md`
- [ ] 路由守衛策略完善(是否限制某些管理頁)
- [ ] 錯誤訊息 i18n 與統一顯示格式
## 待辦(上線前)
- [ ] 增加 e2e / UI smoke 測試
- [ ] 優化 bundle size目前 main chunk 1.2MB,需考慮 lazy loading
- [ ] 加入環境切換策略dev/staging/prod
- [ ] 加入登入來源與 token 取得說明頁
## 驗收條件Schema v2
- [x] 未登入時導向登入頁 → OIDC 流程 ✅
- [x] 登入後可穩定讀取 `/me` 與快照 ✅
- [x] 可新增 system/module/company/site ✅
- [x] 可做用戶直接 grant/revoke新 payload
- [x] 可建立 permission-group、加會員、群組 grant/revoke ✅
- [x] `/me/permissions/snapshot` 表格可顯示 system + module + action ✅
- [x] 與後端契約文件一致 ✅

View File

@@ -1,24 +1,39 @@
# member docs index
# member.ose.tw 文件入口(新架構)
## 先看這三份
1. `docs/FRONTEND_ARCHITECTURE.md`
2. `docs/FRONTEND_API_CONTRACT.md`
3. `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
## 閱讀順序
1. [docs/ARCHITECTURE.md](./ARCHITECTURE.md)
2. [docs/DB_SCHEMA.md](./DB_SCHEMA.md)
3. [docs/BACKEND_TASKPLAN.md](./BACKEND_TASKPLAN.md)
4. [docs/FRONTEND_TASKPLAN.md](./FRONTEND_TASKPLAN.md)
5. [docs/FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md)
6. [docs/INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)
7. [docs/LOCAL_DEV_RUNBOOK.md](./LOCAL_DEV_RUNBOOK.md)
## 系統架構與後端
- `docs/ARCHITECTURE_AND_CONFIG.md`
- `docs/BACKEND_ARCHITECTURE.md`
- `docs/BACKEND_BOOTSTRAP.md`
## 交辦順序(建議)
1. 先看 [ARCHITECTURE.md](./ARCHITECTURE.md) 鎖定資料模型與權限模型。
2. 再看 [DB_SCHEMA.md](./DB_SCHEMA.md) 對齊 table/欄位/關聯。
3. 後端依 [BACKEND_TASKPLAN.md](./BACKEND_TASKPLAN.md) 執行 schema/API/Keycloak 同步調整。
4. 前端依 [FRONTEND_TASKPLAN.md](./FRONTEND_TASKPLAN.md) + [FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md) 開工。
5. 其他系統串接時看 [INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)。
## 任務管理
- `docs/TASKPLAN_FRONTEND.md`
- `docs/TASKPLAN_BACKEND.md`
- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃)
- `docs/FRONTEND_HANDOFF_SCHEMA_V2.md`(前端交辦清單,直接給另一隻 AI
## 目前狀態
- 架構定版:`Company -> Site``System -> Role`
- 權限定版:`Role` 只能指派給 `Site`(透過 `site_roles`)。
- 成員授權定版:`User` 不直接綁 `Role`,僅透過 `user_sites` 取得 Site 角色。
- IdP 定版Keycloak 為唯一 IdP。
- 系統定版:`System`/`Role` 由 Keycloak 管理member 後台僅同步與顯示。
- API 白名單:保留 `api_clients`
- 後端:新 schema 與 admin/internal API 已切到 role-site 模型。
- 前端:管理頁已切到新模型(公司/站台/系統/角色/會員/API Clients
## SQL 與配置
- `backend/scripts/init_schema.sql`
- `docs/DB_SCHEMA_SNAPSHOT.md`
## 單一真實來源
- DB SQL[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
## 給前端 AI 的一句話交接
請先完成 `/me``/me/permissions/snapshot``/admin/permissions/grant|revoke` 三組 API 對接,並依 `FRONTEND_IMPLEMENTATION_CHECKLIST.md` 逐項完成。
## Repo 結構(已拆分)
- 整合層(本 repo`member.ose.tw`docs / 部署 / 整合)
- 後端子模組:[backend](../backend)submodule: `../member-backend`
- 前端子模組:[frontend](../frontend)submodule: `../member-frontend`
## 文件邊界
- 本輪只保留可開發、可交辦、可驗收文件。
- 最終規格白皮書延後到專案完成後再產出。

1
frontend Submodule

Submodule frontend added at cf54146606

View File

@@ -1,2 +0,0 @@
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

View File

@@ -1,2 +0,0 @@
VITE_APP_TITLE=member.ose.tw (dev)
VITE_API_BASE_URL=http://127.0.0.1:8000

View File

@@ -1,3 +0,0 @@
# member.ose.tw frontend env
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>member.ose.tw</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
{
"name": "member-ose-tw",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.11"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@@ -1,78 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50">
<nav v-if="!isLoginPage" class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shadow-sm">
<div class="flex items-center gap-6">
<span class="font-bold text-gray-800 text-base">member.ose.tw</span>
<router-link
to="/me"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
我的資料
</router-link>
<router-link
to="/me/permissions"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
我的權限
</router-link>
<div class="flex items-center gap-4 border-l border-gray-300 pl-6">
<el-dropdown @command="handleAdminNav">
<span class="text-sm text-gray-600 hover:text-blue-600 cursor-pointer transition-colors">
管理員 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="permissions">權限管理</el-dropdown-item>
<el-dropdown-divider />
<el-dropdown-item command="systems">系統管理</el-dropdown-item>
<el-dropdown-item command="modules">模組管理</el-dropdown-item>
<el-dropdown-item command="companies">公司管理</el-dropdown-item>
<el-dropdown-item command="sites">站台管理</el-dropdown-item>
<el-dropdown-item command="members">會員列表</el-dropdown-item>
<el-dropdown-item command="groups">權限群組</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
</nav>
<main class="p-6 max-w-4xl mx-auto">
<router-view />
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ArrowDown } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isLoginPage = computed(() => route.name === 'login')
function handleAdminNav(command) {
const routes = {
permissions: '/admin/permissions',
systems: '/admin/systems',
modules: '/admin/modules',
companies: '/admin/companies',
sites: '/admin/sites',
members: '/admin/members',
groups: '/admin/permission-groups'
}
router.push(routes[command])
}
function logout() {
authStore.logout()
router.push('/login')
}
</script>

View File

@@ -1,10 +0,0 @@
import { userHttp } from './http'
export const loginWithPassword = (username, password) =>
userHttp.post('/auth/login', { username, password })
export const getOidcAuthorizeUrl = (redirectUri) =>
userHttp.get('/auth/oidc/url', { params: { redirect_uri: redirectUri } })
export const exchangeOidcCode = (code, redirectUri) =>
userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri })

View File

@@ -1,4 +0,0 @@
import { adminHttp } from './http'
export const getCompanies = () => adminHttp.get('/admin/companies')
export const createCompany = (data) => adminHttp.post('/admin/companies', data)

View File

@@ -1,37 +0,0 @@
import axios from 'axios'
import router from '@/router'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
// 使用者 API帶 Bearer token
export const userHttp = axios.create({ baseURL: BASE_URL })
userHttp.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
userHttp.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('access_token')
router.push('/login')
}
return Promise.reject(err)
}
)
// 管理員 API帶 X-Client-Key / X-API-Key
export const adminHttp = axios.create({ baseURL: BASE_URL })
adminHttp.interceptors.request.use(config => {
const clientKey = sessionStorage.getItem('admin_client_key')
const apiKey = sessionStorage.getItem('admin_api_key')
if (clientKey) config.headers['X-Client-Key'] = clientKey
if (apiKey) config.headers['X-API-Key'] = apiKey
return config
})

View File

@@ -1,4 +0,0 @@
import { userHttp } from './http'
export const getMe = () => userHttp.get('/me')
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')

View File

@@ -1,3 +0,0 @@
import { adminHttp } from './http'
export const getMembers = () => adminHttp.get('/admin/members')

View File

@@ -1,4 +0,0 @@
import { adminHttp } from './http'
export const getModules = () => adminHttp.get('/admin/modules')
export const createModule = (data) => adminHttp.post('/admin/modules', data)

View File

@@ -1,4 +0,0 @@
import { adminHttp } from './http'
export const grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data)
export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', data)

View File

@@ -1,16 +0,0 @@
import { adminHttp } from './http'
export const getPermissionGroups = () => adminHttp.get('/admin/permission-groups')
export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data)
export const addMemberToGroup = (groupKey, authentikSub) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
export const removeMemberFromGroup = (groupKey, authentikSub) =>
adminHttp.delete(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
export const groupGrant = (groupKey, data) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/permissions/grant`, data)
export const groupRevoke = (groupKey, data) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/permissions/revoke`, data)

Some files were not shown because too many files have changed in this diff Show More