Compare commits
95 Commits
23baceed71
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd81ffbf2 | ||
|
|
cf39ea2b0c | ||
|
|
560f40ae8a | ||
|
|
838c0afc0b | ||
|
|
0666b8683e | ||
|
|
428b6292ea | ||
|
|
cd7feec38a | ||
|
|
01a4580faf | ||
|
|
649af715e2 | ||
|
|
3571cdf2ee | ||
|
|
099ed03be7 | ||
|
|
f62ed97e2b | ||
|
|
760902f53c | ||
|
|
998771bc11 | ||
|
|
576ba9b2fe | ||
|
|
b7b312e69a | ||
|
|
865be73d06 | ||
|
|
ed4b22a564 | ||
|
|
2da43cf027 | ||
|
|
200c86c924 | ||
|
|
e8058d1240 | ||
|
|
6dabc2eab6 | ||
|
|
8609d61f82 | ||
|
|
f01a228026 | ||
|
|
a6e5fbbb24 | ||
|
|
21dc3ea56f | ||
|
|
fdf17ecf85 | ||
|
|
a45aa5a6c7 | ||
|
|
c394e9153e | ||
|
|
0e248db1bf | ||
|
|
1d2a57fada | ||
|
|
528b988207 | ||
|
|
40d9fb8dcf | ||
|
|
fc81696abf | ||
|
|
daa21e81a9 | ||
|
|
955019e8d7 | ||
|
|
ed413ce39d | ||
|
|
fa624127c8 | ||
|
|
418a7b7099 | ||
|
|
223edd49b5 | ||
|
|
d59407d04c | ||
|
|
2004203758 | ||
|
|
1ff0589b29 | ||
|
|
6e43a3b2c8 | ||
|
|
21167659f8 | ||
|
|
f351fe6454 | ||
|
|
6adca8c229 | ||
|
|
ef27162903 | ||
|
|
467f2b4867 | ||
|
|
7660c662a5 | ||
|
|
7986160d9e | ||
|
|
6ae907d649 | ||
|
|
2ce9630a5e | ||
|
|
5837582c0f | ||
|
|
1e1d913103 | ||
|
|
e2dd3ce106 | ||
|
|
16bbfdba24 | ||
|
|
7cdf2b5a51 | ||
|
|
a9c7cb5f39 | ||
|
|
f0fd5d6e68 | ||
|
|
b0de6ad94a | ||
|
|
a1eb7ef41b | ||
|
|
07195e7efc | ||
|
|
dc2811ec61 | ||
|
|
0b61975c81 | ||
|
|
34ba57034d | ||
|
|
febfafc55c | ||
|
|
80a571d227 | ||
|
|
fe6453f6f8 | ||
|
|
316d17027b | ||
|
|
15da8a5341 | ||
|
|
671e27447b | ||
|
|
322db6ee1a | ||
|
|
f6f86d4bfb | ||
|
|
c4492a3072 | ||
|
|
1d9bdb7daa | ||
|
|
ccb99683b8 | ||
|
|
e1a6bbd844 | ||
|
|
15eee2fc9a | ||
|
|
fb515c6c44 | ||
|
|
b4c02835bd | ||
|
|
62776ac27e | ||
|
|
ea5285501a | ||
|
|
37a69081e3 | ||
|
|
f884f1043d | ||
|
|
31fff92e19 | ||
|
|
f85d3de5c5 | ||
|
|
2dd70dceff | ||
|
|
94441a4037 | ||
|
|
76fd22826b | ||
|
|
f33134ff53 | ||
|
|
c85109e09b | ||
|
|
4bb6ecf887 | ||
|
|
c3f6293c83 | ||
|
|
70b5f34a74 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ dist/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local deployment files
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal 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
1
backend
Submodule
Submodule backend added at 405000ded5
19
backend/.env
19
backend/.env
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -1 +0,0 @@
|
||||
"""memberapi backend package."""
|
||||
@@ -1 +0,0 @@
|
||||
"""API routers."""
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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=[])
|
||||
@@ -1 +0,0 @@
|
||||
"""Core settings and constants."""
|
||||
@@ -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()
|
||||
@@ -1 +0,0 @@
|
||||
"""Database wiring."""
|
||||
@@ -1,5 +0,0 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Repository layer."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
"""Pydantic schemas."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
"""Security dependencies and guards."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
"""Service layer."""
|
||||
@@ -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")
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS authentik_user_id INTEGER;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
29
docker-compose.example.yml
Normal file
29
docker-compose.example.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: memberapi_ose_tw
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- postgres
|
||||
- ose-cache
|
||||
- nginx
|
||||
|
||||
networks:
|
||||
postgres:
|
||||
external: true
|
||||
ose-cache:
|
||||
external: true
|
||||
nginx:
|
||||
external: true
|
||||
44
docs/ARCHITECTURE.md
Normal file
44
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# member-platform 架構總覽(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 白名單是兩條獨立安全線。
|
||||
@@ -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`)
|
||||
@@ -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`
|
||||
@@ -1,38 +0,0 @@
|
||||
# Backend Bootstrap(memberapi)
|
||||
|
||||
## 1. 環境準備
|
||||
```bash
|
||||
cd member.ose.tw/backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## 2. 建立資料表
|
||||
1. 先執行 `member.ose.tw/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
27
docs/BACKEND_TASKPLAN.md
Normal 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 Group(create/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`。
|
||||
104
docs/DB_SCHEMA.md
Normal file
104
docs/DB_SCHEMA.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 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
|
||||
- `role_code` TEXT NOT NULL(語意代碼,建議格式:`<system>:<module>:<action>`,例如 `mkt:marketing_card:edit`)
|
||||
- `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`)
|
||||
- UNIQUE(`system_id`, `role_code`)
|
||||
|
||||
## 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 指派。
|
||||
@@ -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)`
|
||||
@@ -1,100 +0,0 @@
|
||||
# Frontend API Contract(memberapi)
|
||||
|
||||
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`
|
||||
@@ -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
46
docs/FRONTEND_HANDOFF.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Frontend Handoff(Role-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 推導角色
|
||||
@@ -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`
|
||||
@@ -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
20
docs/FRONTEND_TASKPLAN.md
Normal 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 欄位)。
|
||||
49
docs/INTERNAL_API_HANDOFF.md
Normal file
49
docs/INTERNAL_API_HANDOFF.md
Normal 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`
|
||||
|
||||
## 角色聚合回應(`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_code": "mkt:marketing_card:edit",
|
||||
"role_name": "campaign_edit"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事項
|
||||
- 不提供 user direct role 寫入 API。
|
||||
- User 最終角色由 `user_sites` + `site_roles` 推導。
|
||||
- `role_key` 是唯一識別鍵;業務語意解析請使用 `role_code`。
|
||||
83
docs/LOCAL_DEV_RUNBOOK.md
Normal file
83
docs/LOCAL_DEV_RUNBOOK.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Local Dev Runbook(Keycloak)
|
||||
|
||||
## 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)
|
||||
|
||||
## 2) 啟動後端
|
||||
本地開發使用 `.env.development`:
|
||||
```bash
|
||||
cd backend
|
||||
# edit .env.development directly
|
||||
```
|
||||
|
||||
本機開發固定使用 `backend/.env.development`。
|
||||
|
||||
再啟動:
|
||||
```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/.env.development`。
|
||||
- production build 讀取 `frontend/.env.production`。
|
||||
- 專案路徑:[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 realm role 帳號打 `/admin/*` 應為 403。
|
||||
5. `POST /admin/sync/from-provider?force=true` 可手動觸發全量補齊同步。
|
||||
6. 列表 API 不會自動同步 IdP(避免高負載),需手動按同步按鈕或呼叫同步 API。
|
||||
|
||||
## 6) 新模型驗收路徑
|
||||
1. 新增 Company、Site。
|
||||
2. 在 Keycloak 建立 System(Client)與 Role(Client 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)` 章節。
|
||||
@@ -1,94 +0,0 @@
|
||||
# 組織與會員管理規劃(給前端/後端協作)
|
||||
|
||||
## 1. 目前狀態(你現在看到空白是正常)
|
||||
- 已完成:
|
||||
- Authentik 登入
|
||||
- `/me` 基本個人資料
|
||||
- 權限 grant/revoke 與 snapshot
|
||||
- 尚未完成:
|
||||
- 公司組織(Organization)CRUD
|
||||
- 會員(Member)清單/新增/編輯/停用
|
||||
- 會員與組織關聯管理
|
||||
|
||||
## 2. 建議產品資訊架構(IA)
|
||||
- `我的資料`:目前登入者基本資訊、登出
|
||||
- `我的權限`:目前登入者權限快照
|
||||
- `組織管理`:公司清單、建立公司、編輯公司、狀態切換
|
||||
- `會員管理`:會員清單、邀請/建立會員、編輯會員、停用會員、指派組織
|
||||
- `權限管理`:保留現有 grant/revoke(可作為管理員進階頁)
|
||||
|
||||
## 3. 後端 API(v1,已開)
|
||||
|
||||
### Organization(admin)
|
||||
- `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`
|
||||
|
||||
### Member(admin)
|
||||
- `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 Organization(admin)
|
||||
- `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`)
|
||||
@@ -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 通過
|
||||
@@ -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 store(auth + 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] Store:admin.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] 與後端契約文件一致 ✅
|
||||
109
docs/VPS_DEPLOY_RUNBOOK.md
Normal file
109
docs/VPS_DEPLOY_RUNBOOK.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# VPS Deploy Runbook
|
||||
|
||||
## 1) 拉整合層 + 子模組
|
||||
```bash
|
||||
cd /opt
|
||||
git clone --recurse-submodules http://127.0.0.1:8888/member/member-platform.git
|
||||
cd member-platform
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## 2) 後端部署(Docker)
|
||||
```bash
|
||||
cd /opt/member-platform/backend
|
||||
cp .env.production .env
|
||||
```
|
||||
編輯 `.env`(DB、Keycloak、Realm Roles、Cache)。
|
||||
|
||||
首次建表:
|
||||
```bash
|
||||
psql "postgresql://<user>:<pass>@<host>:<port>/<db>" -f scripts/init_schema.sql
|
||||
```
|
||||
|
||||
啟動:
|
||||
```bash
|
||||
docker build -t memberapi-backend:latest .
|
||||
docker rm -f memberapi-backend 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name memberapi-backend \
|
||||
--restart unless-stopped \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
--env-file .env \
|
||||
memberapi-backend:latest
|
||||
```
|
||||
|
||||
檢查:
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
docker logs -f memberapi-backend
|
||||
```
|
||||
|
||||
### 用 docker compose(建議)
|
||||
Compose 檔案:[docker-compose.example.yml](../docker-compose.example.yml)
|
||||
|
||||
啟動:
|
||||
```bash
|
||||
cd /opt/member-platform
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
檢查:
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
停止:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 3) 前端部署(Nginx)
|
||||
```bash
|
||||
cd /opt/member-platform/frontend
|
||||
```
|
||||
production build 會自動讀取 `.env.production`,請先確認設定:
|
||||
```env
|
||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||
```
|
||||
|
||||
Build:
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Nginx root 指向 `frontend/dist`,並加 SPA rewrite:
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
## 4) 更新流程
|
||||
```bash
|
||||
cd /opt/member-platform
|
||||
git pull
|
||||
git submodule update --init --recursive --remote
|
||||
```
|
||||
|
||||
後端更新:
|
||||
```bash
|
||||
cd backend
|
||||
docker build -t memberapi-backend:latest .
|
||||
docker rm -f memberapi-backend
|
||||
docker run -d --name memberapi-backend --restart unless-stopped -p 127.0.0.1:8000:8000 --env-file .env memberapi-backend:latest
|
||||
```
|
||||
|
||||
前端更新:
|
||||
```bash
|
||||
cd ../frontend
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 5) 建議網域
|
||||
- Frontend: `member.ose.tw`
|
||||
- API: `memberapi.ose.tw`(反代 `127.0.0.1:8000`)
|
||||
81
docs/directus/directus_key_autogen.sql
Normal file
81
docs/directus/directus_key_autogen.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- Directus key auto-generation triggers
|
||||
-- Target tables: companies, sites, users, roles
|
||||
-- Key format: PREFIX + yyyymmdd + 'X' + 4 digits
|
||||
-- Example: CP20260404X1234
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.directus_autogen_entity_key()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_column_name text := TG_ARGV[0];
|
||||
v_prefix text := TG_ARGV[1];
|
||||
v_current_value text;
|
||||
v_candidate text;
|
||||
v_exists boolean;
|
||||
v_attempt int;
|
||||
v_day text;
|
||||
v_suffix text;
|
||||
BEGIN
|
||||
v_current_value := to_jsonb(NEW) ->> v_column_name;
|
||||
IF v_current_value IS NOT NULL AND btrim(v_current_value) <> '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
v_day := to_char(clock_timestamp(), 'YYYYMMDD');
|
||||
|
||||
FOR v_attempt IN 0..9999 LOOP
|
||||
v_suffix := lpad((((extract(epoch FROM clock_timestamp()) * 1000)::bigint + v_attempt) % 10000)::text, 4, '0');
|
||||
v_candidate := v_prefix || v_day || 'X' || v_suffix;
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I = $1)',
|
||||
TG_TABLE_SCHEMA,
|
||||
TG_TABLE_NAME,
|
||||
v_column_name
|
||||
) INTO v_exists USING v_candidate;
|
||||
|
||||
IF NOT v_exists THEN
|
||||
NEW := jsonb_populate_record(NEW, jsonb_build_object(v_column_name, v_candidate));
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RAISE EXCEPTION 'Failed to generate unique key for %.% (column=%)', TG_TABLE_SCHEMA, TG_TABLE_NAME, v_column_name;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_companies_key_autogen ON public.companies;
|
||||
CREATE TRIGGER trg_companies_key_autogen
|
||||
BEFORE INSERT ON public.companies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'CP');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_sites_key_autogen ON public.sites;
|
||||
CREATE TRIGGER trg_sites_key_autogen
|
||||
BEFORE INSERT ON public.sites
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'ST');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_users_key_autogen ON public.users;
|
||||
CREATE TRIGGER trg_users_key_autogen
|
||||
BEFORE INSERT ON public.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'UE');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_roles_key_autogen ON public.roles;
|
||||
CREATE TRIGGER trg_roles_key_autogen
|
||||
BEFORE INSERT ON public.roles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'RL');
|
||||
|
||||
-- cleanup old trigger names to avoid duplicates from previous scripts
|
||||
DROP TRIGGER IF EXISTS trg_companies_company_key_autogen ON public.companies;
|
||||
DROP TRIGGER IF EXISTS trg_sites_site_key_autogen ON public.sites;
|
||||
DROP TRIGGER IF EXISTS trg_users_user_key_autogen ON public.users;
|
||||
DROP TRIGGER IF EXISTS trg_systems_system_key_autogen ON public.systems;
|
||||
DROP TRIGGER IF EXISTS trg_roles_role_key_autogen ON public.roles;
|
||||
|
||||
COMMIT;
|
||||
3657
docs/directus/member-schema.directus.json
Normal file
3657
docs/directus/member-schema.directus.json
Normal file
File diff suppressed because it is too large
Load Diff
3656
docs/directus/member-schema.directus.uuid.json
Normal file
3656
docs/directus/member-schema.directus.uuid.json
Normal file
File diff suppressed because it is too large
Load Diff
3702
docs/directus/member-schema.directus.uuid.keys.json
Normal file
3702
docs/directus/member-schema.directus.uuid.keys.json
Normal file
File diff suppressed because it is too large
Load Diff
182
docs/directus/member-schema.snapshot.json
Normal file
182
docs/directus/member-schema.snapshot.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"version": 1,
|
||||
"directus": "11.0.0",
|
||||
"vendor": "postgres",
|
||||
"collections": [
|
||||
{ "collection": "users", "meta": null, "schema": { "name": "users" } },
|
||||
{ "collection": "companies", "meta": null, "schema": { "name": "companies" } },
|
||||
{ "collection": "sites", "meta": null, "schema": { "name": "sites" } },
|
||||
{ "collection": "systems", "meta": null, "schema": { "name": "systems" } },
|
||||
{ "collection": "roles", "meta": null, "schema": { "name": "roles" } },
|
||||
{ "collection": "site_roles", "meta": null, "schema": { "name": "site_roles" } },
|
||||
{ "collection": "user_sites", "meta": null, "schema": { "name": "user_sites" } },
|
||||
{ "collection": "auth_sync_state", "meta": null, "schema": { "name": "auth_sync_state" } },
|
||||
{ "collection": "api_clients", "meta": null, "schema": { "name": "api_clients" } }
|
||||
],
|
||||
"fields": [
|
||||
{ "collection": "users", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "has_auto_increment": false, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "users", "field": "user_sub", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "users", "field": "provider_user_id", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||
{ "collection": "users", "field": "username", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||
{ "collection": "users", "field": "email", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||
{ "collection": "users", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "users", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "users", "field": "is_active", "type": "boolean", "meta": null, "schema": { "is_nullable": false, "default_value": true } },
|
||||
{ "collection": "users", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "users", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "companies", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "companies", "field": "company_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "companies", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "companies", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "companies", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "companies", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "companies", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "sites", "field": "site_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "sites", "field": "company_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "companies", "foreign_key_column": "id" } },
|
||||
{ "collection": "sites", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "sites", "field": "domain", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "sites", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "sites", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "systems", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "systems", "field": "system_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "systems", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "systems", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "systems", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "systems", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "roles", "field": "role_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "roles", "field": "role_code", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "roles", "field": "system_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "systems", "foreign_key_column": "id" } },
|
||||
{ "collection": "roles", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "roles", "field": "description", "type": "text", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "roles", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "roles", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "site_roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "site_roles", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
|
||||
{ "collection": "site_roles", "field": "role_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "roles", "foreign_key_column": "id" } },
|
||||
{ "collection": "site_roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "user_sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "user_sites", "field": "user_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "users", "foreign_key_column": "id" } },
|
||||
{ "collection": "user_sites", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
|
||||
{ "collection": "user_sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "user_sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "auth_sync_state", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "auth_sync_state", "field": "entity_type", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "auth_sync_state", "field": "entity_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "auth_sync_state", "field": "last_synced_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "auth_sync_state", "field": "source_version", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "auth_sync_state", "field": "last_error", "type": "text", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "auth_sync_state", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "api_clients", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "api_clients", "field": "client_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "api_clients", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "api_clients", "field": "api_key_hash", "type": "text", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "allowed_origins", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "allowed_ips", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "allowed_paths", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "rate_limit_per_min", "type": "integer", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "api_clients", "field": "expires_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "api_clients", "field": "last_used_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "api_clients", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "api_clients", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } }
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"collection": "sites",
|
||||
"field": "company_id",
|
||||
"related_collection": "companies",
|
||||
"schema": {
|
||||
"table": "sites",
|
||||
"column": "company_id",
|
||||
"foreign_key_table": "companies",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "roles",
|
||||
"field": "system_id",
|
||||
"related_collection": "systems",
|
||||
"schema": {
|
||||
"table": "roles",
|
||||
"column": "system_id",
|
||||
"foreign_key_table": "systems",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "site_roles",
|
||||
"field": "site_id",
|
||||
"related_collection": "sites",
|
||||
"schema": {
|
||||
"table": "site_roles",
|
||||
"column": "site_id",
|
||||
"foreign_key_table": "sites",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "site_roles",
|
||||
"field": "role_id",
|
||||
"related_collection": "roles",
|
||||
"schema": {
|
||||
"table": "site_roles",
|
||||
"column": "role_id",
|
||||
"foreign_key_table": "roles",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "user_sites",
|
||||
"field": "user_id",
|
||||
"related_collection": "users",
|
||||
"schema": {
|
||||
"table": "user_sites",
|
||||
"column": "user_id",
|
||||
"foreign_key_table": "users",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "user_sites",
|
||||
"field": "site_id",
|
||||
"related_collection": "sites",
|
||||
"schema": {
|
||||
"table": "user_sites",
|
||||
"column": "site_id",
|
||||
"foreign_key_table": "sites",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,24 +1,51 @@
|
||||
# member docs index
|
||||
# member-platform 文件入口(新架構)
|
||||
|
||||
## 先看這三份
|
||||
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)
|
||||
8. [docs/VPS_DEPLOY_RUNBOOK.md](./VPS_DEPLOY_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-platform`(docs / 部署 / 整合)
|
||||
- 後端子模組:[backend](../backend)(submodule: `../member-backend`)
|
||||
- 前端子模組:[frontend](../frontend)(submodule: `../member-frontend`)
|
||||
|
||||
## 開發工作目錄(防呆)
|
||||
- 只在 `member-platform` 內開發與提交。
|
||||
- 後端只改 `member-platform/backend`。
|
||||
- 前端只改 `member-platform/frontend`。
|
||||
- 根目錄外的 `member-backend` / `member-frontend` 若有另一份 clone,視為非主要工作副本,避免混用。
|
||||
|
||||
## 提交順序(固定)
|
||||
1. 先在 `member-platform/backend` 或 `member-platform/frontend` 各自 commit / push。
|
||||
2. 再回 `member-platform` 根目錄,提交子模組版本指標變更(submodule pointer)並 push。
|
||||
3. 部署端只需要更新 `member-platform`,再執行 `git submodule update --init --recursive`。
|
||||
|
||||
## 文件邊界
|
||||
- 本輪只保留可開發、可交辦、可驗收文件。
|
||||
- 最終規格白皮書延後到專案完成後再產出。
|
||||
|
||||
1
frontend
Submodule
1
frontend
Submodule
Submodule frontend added at ed63eaffc6
@@ -1,2 +0,0 @@
|
||||
VITE_APP_TITLE=member.ose.tw
|
||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_APP_TITLE=member.ose.tw (dev)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -1,3 +0,0 @@
|
||||
# member.ose.tw frontend env
|
||||
VITE_APP_TITLE=member.ose.tw
|
||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||
@@ -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>
|
||||
2922
frontend/package-lock.json
generated
2922
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user