Compare commits
23 Commits
64246984ba
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405000ded5 | ||
|
|
94cec746cb | ||
|
|
c032020f59 | ||
|
|
60b34a0817 | ||
|
|
d2b6957013 | ||
|
|
7b9915e81c | ||
|
|
dc51af8c39 | ||
|
|
60608fe199 | ||
|
|
7c4364b52f | ||
|
|
065f1d52f0 | ||
|
|
4ae7e75a96 | ||
|
|
d430b69888 | ||
|
|
ed7a0344e0 | ||
|
|
ade60bdbaa | ||
|
|
75cf22f7e5 | ||
|
|
a187acf58c | ||
|
|
6d6f5aa2a6 | ||
|
|
73ed4ff0ff | ||
|
|
55e640f2fb | ||
|
|
e912d1498e | ||
|
|
c431fe180b | ||
|
|
da08bc01ec | ||
|
|
d5418d47e7 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
tests
|
||||||
|
.env
|
||||||
|
.env.development
|
||||||
|
*.log
|
||||||
19
.env
19
.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
|
|
||||||
|
|
||||||
KEYCLOAK_BASE_URL=
|
|
||||||
KEYCLOAK_REALM=
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_ISSUER=
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
KEYCLOAK_AUDIENCE=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# memberapi.ose.tw backend env (local development)
|
# memberapi.ose.tw backend env (development)
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|
||||||
@@ -8,15 +8,34 @@ DB_NAME=member.ose.tw
|
|||||||
DB_USER=member_ose
|
DB_USER=member_ose
|
||||||
DB_PASSWORD=Dmrax5bKDf
|
DB_PASSWORD=Dmrax5bKDf
|
||||||
|
|
||||||
KEYCLOAK_BASE_URL=https://auth.ose.tw
|
# Keycloak 參數說明:
|
||||||
|
# - KEYCLOAK_ISSUER 必須與 token 的 iss 完全一致(建議填公開網址)。
|
||||||
|
# - KEYCLOAK_BASE_URL 是後端對 Keycloak 的基底網址(development 統一走公開入口)。
|
||||||
|
# - KEYCLOAK_JWKS_URL / KEYCLOAK_TOKEN_ENDPOINT / KEYCLOAK_USERINFO_ENDPOINT 可明確覆寫端點。
|
||||||
|
# - KEYCLOAK_AUDIENCE 可選,但建議設定以啟用 aud 驗證。
|
||||||
|
# - KEYCLOAK_CLIENT_* 給 /auth/oidc/exchange 與 /auth/refresh 使用。
|
||||||
|
# - KEYCLOAK_ADMIN_CLIENT_* 給 Keycloak Admin API 同步流程使用。
|
||||||
|
KEYCLOAK_BASE_URL=https://auth.ose.tw/
|
||||||
KEYCLOAK_REALM=master
|
KEYCLOAK_REALM=master
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
KEYCLOAK_VERIFY_TLS=true
|
||||||
|
KEYCLOAK_ISSUER=https://auth.ose.tw/realms/master
|
||||||
|
KEYCLOAK_JWKS_URL=https://auth.ose.tw/realms/master/protocol/openid-connect/certs
|
||||||
|
KEYCLOAK_AUDIENCE=
|
||||||
KEYCLOAK_CLIENT_ID=member-frontend
|
KEYCLOAK_CLIENT_ID=member-frontend
|
||||||
KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv
|
KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv
|
||||||
|
KEYCLOAK_TOKEN_ENDPOINT=https://auth.ose.tw/realms/master/protocol/openid-connect/token
|
||||||
|
KEYCLOAK_USERINFO_ENDPOINT=https://auth.ose.tw/realms/master/protocol/openid-connect/userinfo
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID=member-backend
|
KEYCLOAK_ADMIN_CLIENT_ID=member-backend
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9
|
KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9
|
||||||
KEYCLOAK_ADMIN_REALM=master
|
KEYCLOAK_ADMIN_REALM=master
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
ADMIN_REQUIRED_GROUPS=member-admin
|
MEMBER_REQUIRED_REALM_ROLES=admin,manager
|
||||||
|
ADMIN_REQUIRED_REALM_ROLES=admin,manager
|
||||||
|
|
||||||
|
# Cache backend: memory | redis
|
||||||
|
CACHE_BACKEND=memory
|
||||||
|
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
CACHE_PREFIX=memberapi
|
||||||
|
CACHE_DEFAULT_TTL_SECONDS=30
|
||||||
|
|||||||
28
.env.example
28
.env.example
@@ -1,28 +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
|
|
||||||
|
|
||||||
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
|
|
||||||
KEYCLOAK_BASE_URL=
|
|
||||||
KEYCLOAK_REALM=
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_ISSUER=
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
KEYCLOAK_AUDIENCE=
|
|
||||||
KEYCLOAK_CLIENT_ID=
|
|
||||||
KEYCLOAK_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_TOKEN_ENDPOINT=
|
|
||||||
KEYCLOAK_USERINFO_ENDPOINT=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_ADMIN_REALM=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
ADMIN_REQUIRED_GROUPS=member-admin
|
|
||||||
41
.env.production
Normal file
41
.env.production
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# memberapi.ose.tw backend env (development)
|
||||||
|
APP_ENV=development
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
DB_HOST=postgresql
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=member.ose.tw
|
||||||
|
DB_USER=member_ose
|
||||||
|
DB_PASSWORD=Dmrax5bKDf
|
||||||
|
|
||||||
|
# Keycloak 參數說明:
|
||||||
|
# - KEYCLOAK_ISSUER 必須與 token 的 iss 完全一致(建議填公開網址)。
|
||||||
|
# - KEYCLOAK_BASE_URL 是後端對 Keycloak 的基底網址(development 統一走公開入口)。
|
||||||
|
# - KEYCLOAK_JWKS_URL / KEYCLOAK_TOKEN_ENDPOINT / KEYCLOAK_USERINFO_ENDPOINT 可明確覆寫端點。
|
||||||
|
# - KEYCLOAK_AUDIENCE 可選,但建議設定以啟用 aud 驗證。
|
||||||
|
# - KEYCLOAK_CLIENT_* 給 /auth/oidc/exchange 與 /auth/refresh 使用。
|
||||||
|
# - KEYCLOAK_ADMIN_CLIENT_* 給 Keycloak Admin API 同步流程使用。
|
||||||
|
KEYCLOAK_BASE_URL=http://auth_ose_tw:8080
|
||||||
|
KEYCLOAK_REALM=master
|
||||||
|
KEYCLOAK_VERIFY_TLS=true
|
||||||
|
KEYCLOAK_ISSUER=https://auth.ose.tw/realms/master
|
||||||
|
KEYCLOAK_JWKS_URL=http://auth_ose_tw:8080/realms/master/protocol/openid-connect/certs
|
||||||
|
KEYCLOAK_AUDIENCE=
|
||||||
|
KEYCLOAK_CLIENT_ID=member-frontend
|
||||||
|
KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv
|
||||||
|
KEYCLOAK_TOKEN_ENDPOINT=http://auth_ose_tw:8080/realms/master/protocol/openid-connect/token
|
||||||
|
KEYCLOAK_USERINFO_ENDPOINT=http://auth_ose_tw:8080/realms/master/protocol/openid-connect/userinfo
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_ID=member-backend
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9
|
||||||
|
KEYCLOAK_ADMIN_REALM=master
|
||||||
|
|
||||||
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
MEMBER_REQUIRED_REALM_ROLES=admin,manager
|
||||||
|
ADMIN_REQUIRED_REALM_ROLES=admin,manager
|
||||||
|
|
||||||
|
# Cache backend: memory | redis
|
||||||
|
CACHE_BACKEND=redis
|
||||||
|
CACHE_REDIS_URL=redis://cache/0
|
||||||
|
CACHE_PREFIX=memberapi
|
||||||
|
CACHE_DEFAULT_TTL_SECONDS=30
|
||||||
@@ -1,27 +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
|
|
||||||
|
|
||||||
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
|
|
||||||
KEYCLOAK_BASE_URL=
|
|
||||||
KEYCLOAK_REALM=
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_ISSUER=
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
KEYCLOAK_AUDIENCE=
|
|
||||||
KEYCLOAK_CLIENT_ID=
|
|
||||||
KEYCLOAK_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_TOKEN_ENDPOINT=
|
|
||||||
KEYCLOAK_USERINFO_ENDPOINT=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_ADMIN_REALM=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Virtualenv
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Build metadata
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Local env and logs
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.12-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache build-base libffi-dev openssl-dev cargo
|
||||||
|
|
||||||
|
COPY pyproject.toml /app/pyproject.toml
|
||||||
|
COPY app /app/app
|
||||||
|
COPY scripts /app/scripts
|
||||||
|
COPY README.md /app/README.md
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip wheel --no-cache-dir --wheel-dir /wheels .
|
||||||
|
|
||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache libstdc++ libffi openssl
|
||||||
|
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir /wheels/*
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
42
README.md
42
README.md
@@ -7,11 +7,35 @@ cd backend
|
|||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -e .
|
pip install -e .
|
||||||
cp .env.example .env
|
# local development uses .env.development directly
|
||||||
psql "$DATABASE_URL" -f scripts/init_schema.sql
|
psql "$DATABASE_URL" -f scripts/init_schema.sql
|
||||||
./scripts/start_dev.sh
|
./scripts/start_dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker (VPS / Production)
|
||||||
|
> 目前 Dockerfile 為 Alpine 多階段建置(較小體積)。
|
||||||
|
|
||||||
|
Build image:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
docker build -t memberapi-backend:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run container:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name memberapi-backend \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 127.0.0.1:8000:8000 \
|
||||||
|
--env-file .env \
|
||||||
|
memberapi-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Health check:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
## Keycloak env
|
## Keycloak env
|
||||||
|
|
||||||
- Required:
|
- Required:
|
||||||
@@ -29,6 +53,8 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
|
|||||||
- `KEYCLOAK_USERINFO_ENDPOINT`
|
- `KEYCLOAK_USERINFO_ENDPOINT`
|
||||||
- `KEYCLOAK_AUDIENCE`
|
- `KEYCLOAK_AUDIENCE`
|
||||||
- `KEYCLOAK_VERIFY_TLS`
|
- `KEYCLOAK_VERIFY_TLS`
|
||||||
|
- `MEMBER_REQUIRED_REALM_ROLES` (default: `admin,manager`)
|
||||||
|
- `ADMIN_REQUIRED_REALM_ROLES` (default: `admin,manager`)
|
||||||
|
|
||||||
## Main APIs
|
## Main APIs
|
||||||
|
|
||||||
@@ -38,7 +64,7 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
|
|||||||
- `GET /me` (Bearer token required)
|
- `GET /me` (Bearer token required)
|
||||||
- `GET /me/permissions/snapshot` (Bearer token required)
|
- `GET /me/permissions/snapshot` (Bearer token required)
|
||||||
|
|
||||||
### Admin APIs (Bearer + admin group required)
|
### Admin APIs (Bearer + admin realm role required)
|
||||||
- `GET/POST/PATCH/DELETE /admin/companies`
|
- `GET/POST/PATCH/DELETE /admin/companies`
|
||||||
- `GET/POST/PATCH/DELETE /admin/sites`
|
- `GET/POST/PATCH/DELETE /admin/sites`
|
||||||
- `GET/POST/PATCH/DELETE /admin/systems`
|
- `GET/POST/PATCH/DELETE /admin/systems`
|
||||||
@@ -49,6 +75,8 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
|
|||||||
- `GET /admin/members/{user_sub}/roles`
|
- `GET /admin/members/{user_sub}/roles`
|
||||||
- `GET/POST/PATCH/DELETE /admin/api-clients`
|
- `GET/POST/PATCH/DELETE /admin/api-clients`
|
||||||
|
|
||||||
|
> `roles` 現在包含 `role_code` 欄位(建議用於跨系統權限語意解析);`role_key` 保留為唯一識別鍵。
|
||||||
|
|
||||||
### Internal APIs (`X-Client-Key` + `X-API-Key`)
|
### Internal APIs (`X-Client-Key` + `X-API-Key`)
|
||||||
- `GET /internal/companies`
|
- `GET /internal/companies`
|
||||||
- `GET /internal/sites`
|
- `GET /internal/sites`
|
||||||
@@ -57,5 +85,11 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
|
|||||||
- `GET /internal/members`
|
- `GET /internal/members`
|
||||||
- `POST /internal/users/upsert-by-sub`
|
- `POST /internal/users/upsert-by-sub`
|
||||||
- `GET /internal/users/{user_sub}/roles`
|
- `GET /internal/users/{user_sub}/roles`
|
||||||
- `GET /internal/permissions/{user_sub}/snapshot`
|
- `POST /internal/provider/users/ensure`
|
||||||
- `POST /internal/idp/users/ensure`
|
|
||||||
|
## DB Migration
|
||||||
|
|
||||||
|
- 既有 DB 升級(新增 `roles.role_code`):
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f scripts/migrate_add_role_code.sql
|
||||||
|
```
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from app.security.admin_guard import require_admin_principal
|
|||||||
from app.security.api_client_auth import hash_api_key
|
from app.security.api_client_auth import hash_api_key
|
||||||
from app.services.idp_admin_service import ProviderAdminService
|
from app.services.idp_admin_service import ProviderAdminService
|
||||||
from app.services.idp_catalog_sync import sync_from_provider
|
from app.services.idp_catalog_sync import sync_from_provider
|
||||||
|
from app.services.runtime_cache import runtime_cache
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -70,6 +71,13 @@ def _generate_unique_key(prefix: str, exists_check) -> str:
|
|||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_role_code(value: str | None, fallback_name: str) -> str:
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
return fallback_name.strip()
|
||||||
|
|
||||||
|
|
||||||
def _company_item(company) -> CompanyItem:
|
def _company_item(company) -> CompanyItem:
|
||||||
return CompanyItem(
|
return CompanyItem(
|
||||||
id=company.id,
|
id=company.id,
|
||||||
@@ -129,6 +137,31 @@ def _site_group_name(display_name: str, site_key: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_site_client_roles(
|
||||||
|
*,
|
||||||
|
idp: ProviderAdminService,
|
||||||
|
site,
|
||||||
|
site_role_rows,
|
||||||
|
provider_client_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
if not site.provider_group_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"site_provider_group_missing:{site.site_key}")
|
||||||
|
|
||||||
|
role_names_by_client: dict[str, list[str]] = {}
|
||||||
|
for _, role, system in site_role_rows:
|
||||||
|
provider_client_id = str(system.name or "").strip()
|
||||||
|
if not provider_client_id:
|
||||||
|
continue
|
||||||
|
role_names_by_client.setdefault(provider_client_id, []).append(role.name)
|
||||||
|
|
||||||
|
for provider_client_id in sorted(provider_client_ids):
|
||||||
|
idp.set_group_client_roles(
|
||||||
|
group_id=site.provider_group_id,
|
||||||
|
provider_client_id=provider_client_id,
|
||||||
|
role_names=role_names_by_client.get(provider_client_id, []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/companies", response_model=ListResponse)
|
@router.get("/companies", response_model=ListResponse)
|
||||||
def list_companies(
|
def list_companies(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -136,9 +169,16 @@ def list_companies(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListResponse:
|
) -> ListResponse:
|
||||||
|
cache_key = f"admin:companies:{keyword or ''}:{limit}:{offset}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, ListResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
repo = CompaniesRepository(db)
|
repo = CompaniesRepository(db)
|
||||||
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
||||||
return ListResponse(items=[_company_item(i) for i in items], total=total, limit=limit, offset=offset)
|
result = ListResponse(items=[_company_item(i) for i in items], total=total, limit=limit, offset=offset)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=20)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/companies", response_model=CompanyItem)
|
@router.post("/companies", response_model=CompanyItem)
|
||||||
@@ -226,6 +266,11 @@ def list_sites(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListResponse:
|
) -> ListResponse:
|
||||||
|
cache_key = f"admin:sites:{keyword or ''}:{company_key or ''}:{limit}:{offset}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, ListResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
companies_repo = CompaniesRepository(db)
|
companies_repo = CompaniesRepository(db)
|
||||||
sites_repo = SitesRepository(db)
|
sites_repo = SitesRepository(db)
|
||||||
company_id = None
|
company_id = None
|
||||||
@@ -239,7 +284,9 @@ def list_sites(
|
|||||||
company_map = {c.id: c for c in companies}
|
company_map = {c.id: c for c in companies}
|
||||||
items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset)
|
items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset)
|
||||||
response_items = [_site_item(i, company_map[i.company_id]) for i in items if i.company_id in company_map]
|
response_items = [_site_item(i, company_map[i.company_id]) for i in items if i.company_id in company_map]
|
||||||
return ListResponse(items=response_items, total=total, limit=limit, offset=offset)
|
result = ListResponse(items=response_items, total=total, limit=limit, offset=offset)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=20)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sites", response_model=SiteItem)
|
@router.post("/sites", response_model=SiteItem)
|
||||||
@@ -350,9 +397,16 @@ def list_systems(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListResponse:
|
) -> ListResponse:
|
||||||
|
cache_key = f"admin:systems:{keyword or ''}:{status_filter or ''}:{limit}:{offset}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, ListResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
repo = SystemsRepository(db)
|
repo = SystemsRepository(db)
|
||||||
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
|
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
|
||||||
return ListResponse(items=[_system_item(i) for i in items], total=total, limit=limit, offset=offset)
|
result = ListResponse(items=[_system_item(i) for i in items], total=total, limit=limit, offset=offset)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=20)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/systems", response_model=SystemItem)
|
@router.post("/systems", response_model=SystemItem)
|
||||||
@@ -379,6 +433,11 @@ def list_roles(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListResponse:
|
) -> ListResponse:
|
||||||
|
cache_key = f"admin:roles:{keyword or ''}:{system_key or ''}:{status_filter or ''}:{limit}:{offset}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, ListResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
systems_repo = SystemsRepository(db)
|
systems_repo = SystemsRepository(db)
|
||||||
roles_repo = RolesRepository(db)
|
roles_repo = RolesRepository(db)
|
||||||
|
|
||||||
@@ -399,6 +458,7 @@ def list_roles(
|
|||||||
RoleItem(
|
RoleItem(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
role_key=row.role_key,
|
role_key=row.role_key,
|
||||||
|
role_code=row.role_code,
|
||||||
system_key=system_map[row.system_id].system_key,
|
system_key=system_map[row.system_id].system_key,
|
||||||
system_name=system_map[row.system_id].name,
|
system_name=system_map[row.system_id].name,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
@@ -408,7 +468,9 @@ def list_roles(
|
|||||||
for row in rows
|
for row in rows
|
||||||
if row.system_id in system_map
|
if row.system_id in system_map
|
||||||
]
|
]
|
||||||
return ListResponse(items=items, total=total, limit=limit, offset=offset)
|
result = ListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=20)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/roles", response_model=RoleItem)
|
@router.post("/roles", response_model=RoleItem)
|
||||||
@@ -427,9 +489,11 @@ def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> Ro
|
|||||||
)
|
)
|
||||||
|
|
||||||
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
||||||
|
role_code = _resolve_role_code(payload.role_code, payload.name)
|
||||||
try:
|
try:
|
||||||
row = roles_repo.create(
|
row = roles_repo.create(
|
||||||
role_key=role_key,
|
role_key=role_key,
|
||||||
|
role_code=role_code,
|
||||||
system_id=system.id,
|
system_id=system.id,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
@@ -442,6 +506,7 @@ def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> Ro
|
|||||||
return RoleItem(
|
return RoleItem(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
role_key=row.role_key,
|
role_key=row.role_key,
|
||||||
|
role_code=row.role_code,
|
||||||
system_key=system.system_key,
|
system_key=system.system_key,
|
||||||
system_name=system.name,
|
system_name=system.name,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
@@ -473,6 +538,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
|
|||||||
system_id = system.id
|
system_id = system.id
|
||||||
target_system = system
|
target_system = system
|
||||||
next_provider_role_name = payload.name if payload.name is not None else role.name
|
next_provider_role_name = payload.name if payload.name is not None else role.name
|
||||||
|
next_role_code = _resolve_role_code(payload.role_code, next_provider_role_name)
|
||||||
next_description = payload.description if payload.description is not None else role.description
|
next_description = payload.description if payload.description is not None else role.description
|
||||||
|
|
||||||
if target_system.id != old_system.id:
|
if target_system.id != old_system.id:
|
||||||
@@ -497,6 +563,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
|
|||||||
role = roles_repo.update(
|
role = roles_repo.update(
|
||||||
role,
|
role,
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
|
role_code=next_role_code,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
@@ -512,6 +579,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
|
|||||||
return RoleItem(
|
return RoleItem(
|
||||||
id=role.id,
|
id=role.id,
|
||||||
role_key=role.role_key,
|
role_key=role.role_key,
|
||||||
|
role_code=role.role_code,
|
||||||
system_key=system.system_key,
|
system_key=system.system_key,
|
||||||
system_name=system.name,
|
system_name=system.name,
|
||||||
name=role.name,
|
name=role.name,
|
||||||
@@ -556,6 +624,7 @@ def list_system_roles(system_key: str, db: Session = Depends(get_db)) -> SystemR
|
|||||||
RoleItem(
|
RoleItem(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
role_key=row.role_key,
|
role_key=row.role_key,
|
||||||
|
role_code=row.role_code,
|
||||||
system_key=system.system_key,
|
system_key=system.system_key,
|
||||||
system_name=system.name,
|
system_name=system.name,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
@@ -583,6 +652,7 @@ def list_site_roles(site_key: str, db: Session = Depends(get_db)) -> SiteRolesRe
|
|||||||
SiteRoleItem(
|
SiteRoleItem(
|
||||||
id=site_role.id,
|
id=site_role.id,
|
||||||
role_key=role.role_key,
|
role_key=role.role_key,
|
||||||
|
role_code=role.role_code,
|
||||||
role_name=role.name,
|
role_name=role.name,
|
||||||
system_key=system.system_key,
|
system_key=system.system_key,
|
||||||
system_name=system.name,
|
system_name=system.name,
|
||||||
@@ -597,11 +667,15 @@ def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session
|
|||||||
sites_repo = SitesRepository(db)
|
sites_repo = SitesRepository(db)
|
||||||
roles_repo = RolesRepository(db)
|
roles_repo = RolesRepository(db)
|
||||||
site_roles_repo = SiteRolesRepository(db)
|
site_roles_repo = SiteRolesRepository(db)
|
||||||
|
idp = ProviderAdminService(get_settings())
|
||||||
|
|
||||||
site = sites_repo.get_by_key(site_key)
|
site = sites_repo.get_by_key(site_key)
|
||||||
if not site:
|
if not site:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
||||||
|
|
||||||
|
current_rows = site_roles_repo.list_site_role_rows(site.id)
|
||||||
|
current_client_ids = {str(system.name or "").strip() for _, _, system in current_rows if str(system.name or "").strip()}
|
||||||
|
|
||||||
role_ids: list[str] = []
|
role_ids: list[str] = []
|
||||||
for role_key in list(dict.fromkeys(payload.role_keys)):
|
for role_key in list(dict.fromkeys(payload.role_keys)):
|
||||||
role = roles_repo.get_by_key(role_key)
|
role = roles_repo.get_by_key(role_key)
|
||||||
@@ -609,7 +683,23 @@ def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"role_not_found:{role_key}")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"role_not_found:{role_key}")
|
||||||
role_ids.append(role.id)
|
role_ids.append(role.id)
|
||||||
|
|
||||||
site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids)
|
site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids, commit=False)
|
||||||
|
updated_rows = site_roles_repo.list_site_role_rows(site.id)
|
||||||
|
updated_client_ids = {str(system.name or "").strip() for _, _, system in updated_rows if str(system.name or "").strip()}
|
||||||
|
clients_to_sync = current_client_ids | updated_client_ids
|
||||||
|
|
||||||
|
try:
|
||||||
|
_sync_site_client_roles(
|
||||||
|
idp=idp,
|
||||||
|
site=site,
|
||||||
|
site_role_rows=updated_rows,
|
||||||
|
provider_client_ids=clients_to_sync,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
return list_site_roles(site_key=site_key, db=db)
|
return list_site_roles(site_key=site_key, db=db)
|
||||||
|
|
||||||
|
|
||||||
@@ -646,6 +736,58 @@ def list_role_sites(role_key: str, db: Session = Depends(get_db)) -> RoleSitesRe
|
|||||||
return RoleSitesResponse(role_key=role_key, sites=result)
|
return RoleSitesResponse(role_key=role_key, sites=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/roles/{role_key}/sites", response_model=RoleSitesResponse)
|
||||||
|
def assign_role_sites(role_key: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> RoleSitesResponse:
|
||||||
|
roles_repo = RolesRepository(db)
|
||||||
|
sites_repo = SitesRepository(db)
|
||||||
|
systems_repo = SystemsRepository(db)
|
||||||
|
site_roles_repo = SiteRolesRepository(db)
|
||||||
|
idp = ProviderAdminService(get_settings())
|
||||||
|
|
||||||
|
role = roles_repo.get_by_key(role_key)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found")
|
||||||
|
|
||||||
|
system = systems_repo.get_by_id(role.system_id)
|
||||||
|
if not system:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||||
|
provider_client_id = str(system.name or "").strip()
|
||||||
|
if not provider_client_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"provider_client_id_missing:{system.system_key}")
|
||||||
|
|
||||||
|
previous_rows = site_roles_repo.list_role_site_rows(role.id)
|
||||||
|
previous_site_ids = {site.id for _, site in previous_rows}
|
||||||
|
|
||||||
|
site_ids: list[str] = []
|
||||||
|
for site_key in list(dict.fromkeys(payload.site_keys)):
|
||||||
|
site = sites_repo.get_by_key(site_key)
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{site_key}")
|
||||||
|
site_ids.append(site.id)
|
||||||
|
|
||||||
|
site_roles_repo.set_role_sites(role_id=role.id, site_ids=site_ids, commit=False)
|
||||||
|
|
||||||
|
affected_site_ids = previous_site_ids | set(site_ids)
|
||||||
|
try:
|
||||||
|
for site_id in affected_site_ids:
|
||||||
|
site = sites_repo.get_by_id(site_id)
|
||||||
|
if not site:
|
||||||
|
continue
|
||||||
|
site_rows = site_roles_repo.list_site_role_rows(site.id)
|
||||||
|
_sync_site_client_roles(
|
||||||
|
idp=idp,
|
||||||
|
site=site,
|
||||||
|
site_role_rows=site_rows,
|
||||||
|
provider_client_ids={provider_client_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return list_role_sites(role_key=role_key, db=db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/members", response_model=ListResponse)
|
@router.get("/members", response_model=ListResponse)
|
||||||
def list_members(
|
def list_members(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -654,9 +796,17 @@ def list_members(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListResponse:
|
) -> ListResponse:
|
||||||
|
is_active_key = "" if is_active is None else ("1" if is_active else "0")
|
||||||
|
cache_key = f"admin:members:{keyword or ''}:{is_active_key}:{limit}:{offset}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, ListResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
repo = UsersRepository(db)
|
repo = UsersRepository(db)
|
||||||
rows, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset)
|
rows, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset)
|
||||||
return ListResponse(items=[_member_item(r) for r in rows], total=total, limit=limit, offset=offset)
|
result = ListResponse(items=[_member_item(r) for r in rows], total=total, limit=limit, offset=offset)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=20)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/members", response_model=MemberItem)
|
@router.post("/members", response_model=MemberItem)
|
||||||
@@ -838,6 +988,7 @@ def list_member_effective_roles(user_sub: str, db: Session = Depends(get_db)) ->
|
|||||||
system_key=system.system_key,
|
system_key=system.system_key,
|
||||||
system_name=system.name,
|
system_name=system.name,
|
||||||
role_key=role.role_key,
|
role_key=role.role_key,
|
||||||
|
role_code=role.role_code,
|
||||||
role_name=role.name,
|
role_name=role.name,
|
||||||
)
|
)
|
||||||
for site, company, role, system in rows
|
for site, company, role, system in rows
|
||||||
@@ -853,18 +1004,24 @@ def list_api_clients(
|
|||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListResponse:
|
) -> ListResponse:
|
||||||
|
cache_key = f"admin:api_clients:{keyword or ''}:{status_filter or ''}:{limit}:{offset}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, ListResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
repo = ApiClientsRepository(db)
|
repo = ApiClientsRepository(db)
|
||||||
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
|
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
|
||||||
return ListResponse(
|
result = ListResponse(
|
||||||
items=[ApiClientItem.model_validate(i, from_attributes=True) for i in items],
|
items=[ApiClientItem.model_validate(i, from_attributes=True) for i in items],
|
||||||
total=total,
|
total=total,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=20)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/from-provider")
|
@router.post("/sync/from-provider")
|
||||||
@router.post("/sync/from-keycloak", include_in_schema=False)
|
|
||||||
def sync_catalog_from_provider(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]:
|
def sync_catalog_from_provider(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]:
|
||||||
return sync_from_provider(db, force=force)
|
return sync_from_provider(db, force=force)
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ from app.repositories.users_repo import UsersRepository
|
|||||||
from app.repositories.user_sites_repo import UserSitesRepository
|
from app.repositories.user_sites_repo import UserSitesRepository
|
||||||
from app.schemas.idp_admin import ProviderEnsureUserRequest, ProviderEnsureUserResponse
|
from app.schemas.idp_admin import ProviderEnsureUserRequest, ProviderEnsureUserResponse
|
||||||
from app.schemas.internal import InternalUpsertUserBySubResponse, InternalUserRoleItem, InternalUserRoleResponse
|
from app.schemas.internal import InternalUpsertUserBySubResponse, InternalUserRoleItem, InternalUserRoleResponse
|
||||||
from app.schemas.permissions import RoleSnapshotResponse
|
|
||||||
from app.schemas.users import UserUpsertBySubRequest
|
from app.schemas.users import UserUpsertBySubRequest
|
||||||
from app.security.api_client_auth import require_api_client
|
from app.security.api_client_auth import require_api_client
|
||||||
from app.services.idp_admin_service import ProviderAdminService
|
from app.services.idp_admin_service import ProviderAdminService
|
||||||
from app.services.permission_service import PermissionService
|
from app.services.runtime_cache import runtime_cache
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)])
|
router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)])
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ def upsert_user_by_sub(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str]]:
|
def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str, str]]:
|
||||||
users_repo = UsersRepository(db)
|
users_repo = UsersRepository(db)
|
||||||
user_sites_repo = UserSitesRepository(db)
|
user_sites_repo = UserSitesRepository(db)
|
||||||
|
|
||||||
@@ -60,6 +59,7 @@ def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, st
|
|||||||
system.system_key,
|
system.system_key,
|
||||||
system.name,
|
system.name,
|
||||||
role.role_key,
|
role.role_key,
|
||||||
|
role.role_code,
|
||||||
role.name,
|
role.name,
|
||||||
)
|
)
|
||||||
for site, company, role, system in rows
|
for site, company, role, system in rows
|
||||||
@@ -68,8 +68,13 @@ def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, st
|
|||||||
|
|
||||||
@router.get("/users/{user_sub}/roles", response_model=InternalUserRoleResponse)
|
@router.get("/users/{user_sub}/roles", response_model=InternalUserRoleResponse)
|
||||||
def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUserRoleResponse:
|
def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUserRoleResponse:
|
||||||
|
cache_key = f"internal:user_roles:{user_sub}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, InternalUserRoleResponse):
|
||||||
|
return cached
|
||||||
|
|
||||||
rows = _build_user_role_rows(db, user_sub)
|
rows = _build_user_role_rows(db, user_sub)
|
||||||
return InternalUserRoleResponse(
|
result = InternalUserRoleResponse(
|
||||||
user_sub=user_sub,
|
user_sub=user_sub,
|
||||||
roles=[
|
roles=[
|
||||||
InternalUserRoleItem(
|
InternalUserRoleItem(
|
||||||
@@ -80,6 +85,7 @@ def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUser
|
|||||||
system_key=system_key,
|
system_key=system_key,
|
||||||
system_name=system_name,
|
system_name=system_name,
|
||||||
role_key=role_key,
|
role_key=role_key,
|
||||||
|
role_code=role_code,
|
||||||
role_name=role_name,
|
role_name=role_name,
|
||||||
)
|
)
|
||||||
for (
|
for (
|
||||||
@@ -90,24 +96,16 @@ def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUser
|
|||||||
system_key,
|
system_key,
|
||||||
system_name,
|
system_name,
|
||||||
role_key,
|
role_key,
|
||||||
|
role_code,
|
||||||
role_name,
|
role_name,
|
||||||
) in rows
|
) in rows
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=30)
|
||||||
|
return result
|
||||||
@router.get("/permissions/{user_sub}/snapshot", response_model=RoleSnapshotResponse)
|
|
||||||
def get_permission_snapshot(
|
|
||||||
user_sub: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> RoleSnapshotResponse:
|
|
||||||
rows = _build_user_role_rows(db, user_sub)
|
|
||||||
return PermissionService.build_role_snapshot(user_sub=user_sub, rows=rows)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/provider/users/ensure", response_model=ProviderEnsureUserResponse)
|
@router.post("/provider/users/ensure", response_model=ProviderEnsureUserResponse)
|
||||||
@router.post("/idp/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False)
|
|
||||||
@router.post("/keycloak/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False)
|
|
||||||
def ensure_idp_user(
|
def ensure_idp_user(
|
||||||
payload: ProviderEnsureUserRequest,
|
payload: ProviderEnsureUserRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ def internal_list_roles(
|
|||||||
InternalRoleItem(
|
InternalRoleItem(
|
||||||
id=i.id,
|
id=i.id,
|
||||||
role_key=i.role_key,
|
role_key=i.role_key,
|
||||||
|
role_code=i.role_code,
|
||||||
system_key=system_map[i.system_id].system_key,
|
system_key=system_map[i.system_id].system_key,
|
||||||
system_name=system_map[i.system_id].name,
|
system_name=system_map[i.system_id].name,
|
||||||
name=i.name,
|
name=i.name,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.schemas.auth import ProviderPrincipal, MeSummaryResponse
|
|||||||
from app.schemas.permissions import RoleSnapshotResponse
|
from app.schemas.permissions import RoleSnapshotResponse
|
||||||
from app.security.idp_jwt import require_authenticated_principal
|
from app.security.idp_jwt import require_authenticated_principal
|
||||||
from app.services.permission_service import PermissionService
|
from app.services.permission_service import PermissionService
|
||||||
|
from app.services.runtime_cache import runtime_cache
|
||||||
|
|
||||||
router = APIRouter(prefix="/me", tags=["me"])
|
router = APIRouter(prefix="/me", tags=["me"])
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ def get_me(
|
|||||||
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> MeSummaryResponse:
|
) -> MeSummaryResponse:
|
||||||
|
cache_key = f"me:{principal.sub}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, MeSummaryResponse):
|
||||||
|
return cached
|
||||||
try:
|
try:
|
||||||
users_repo = UsersRepository(db)
|
users_repo = UsersRepository(db)
|
||||||
user = users_repo.upsert_by_sub(
|
user = users_repo.upsert_by_sub(
|
||||||
@@ -28,13 +33,17 @@ def get_me(
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
status="active",
|
status="active",
|
||||||
)
|
)
|
||||||
return MeSummaryResponse(sub=user.user_sub, email=user.email, display_name=user.display_name)
|
result = MeSummaryResponse(sub=user.user_sub, email=user.email, display_name=user.display_name)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=30)
|
||||||
|
return result
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
return MeSummaryResponse(
|
result = MeSummaryResponse(
|
||||||
sub=principal.sub,
|
sub=principal.sub,
|
||||||
email=principal.email,
|
email=principal.email,
|
||||||
display_name=principal.name or principal.preferred_username,
|
display_name=principal.name or principal.preferred_username,
|
||||||
)
|
)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=15)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions/snapshot", response_model=RoleSnapshotResponse)
|
@router.get("/permissions/snapshot", response_model=RoleSnapshotResponse)
|
||||||
@@ -42,6 +51,10 @@ def get_my_permission_snapshot(
|
|||||||
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> RoleSnapshotResponse:
|
) -> RoleSnapshotResponse:
|
||||||
|
cache_key = f"me:permissions_snapshot:{principal.sub}"
|
||||||
|
cached = runtime_cache.get(cache_key)
|
||||||
|
if isinstance(cached, RoleSnapshotResponse):
|
||||||
|
return cached
|
||||||
try:
|
try:
|
||||||
users_repo = UsersRepository(db)
|
users_repo = UsersRepository(db)
|
||||||
user_sites_repo = UserSitesRepository(db)
|
user_sites_repo = UserSitesRepository(db)
|
||||||
@@ -64,10 +77,15 @@ def get_my_permission_snapshot(
|
|||||||
system.system_key,
|
system.system_key,
|
||||||
system.name,
|
system.name,
|
||||||
role.role_key,
|
role.role_key,
|
||||||
|
role.role_code,
|
||||||
role.name,
|
role.name,
|
||||||
)
|
)
|
||||||
for site, company, role, system in rows
|
for site, company, role, system in rows
|
||||||
]
|
]
|
||||||
return PermissionService.build_role_snapshot(user_sub=principal.sub, rows=serialized)
|
result = PermissionService.build_role_snapshot(user_sub=principal.sub, rows=serialized)
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=30)
|
||||||
|
return result
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
return RoleSnapshotResponse(user_sub=principal.sub, roles=[])
|
result = RoleSnapshotResponse(user_sub=principal.sub, roles=[])
|
||||||
|
runtime_cache.set(cache_key, result, ttl_seconds=10)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class Settings(BaseSettings):
|
|||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
||||||
internal_shared_secret: str = ""
|
internal_shared_secret: str = ""
|
||||||
admin_required_groups: Annotated[list[str], NoDecode] = []
|
admin_required_groups: Annotated[list[str], NoDecode] = []
|
||||||
|
member_required_realm_roles: Annotated[list[str], NoDecode] = ["admin", "manager"]
|
||||||
|
admin_required_realm_roles: Annotated[list[str], NoDecode] = ["admin", "manager"]
|
||||||
|
cache_backend: str = "memory"
|
||||||
|
cache_redis_url: str = "redis://127.0.0.1:6379/0"
|
||||||
|
cache_prefix: str = "memberapi"
|
||||||
|
cache_default_ttl_seconds: int = 30
|
||||||
|
|
||||||
@field_validator("public_frontend_origins", mode="before")
|
@field_validator("public_frontend_origins", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -54,6 +60,15 @@ class Settings(BaseSettings):
|
|||||||
return []
|
return []
|
||||||
return [part.strip() for part in value.split(",") if part.strip()]
|
return [part.strip() for part in value.split(",") if part.strip()]
|
||||||
|
|
||||||
|
@field_validator("member_required_realm_roles", "admin_required_realm_roles", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_roles_csv(cls, value: str | list[str]) -> list[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(v).strip() for v in value if str(v).strip()]
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
return [part.strip() for part in value.split(",") if part.strip()]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
13
app/main.py
13
app/main.py
@@ -7,6 +7,7 @@ from app.api.internal_catalog import router as internal_catalog_router
|
|||||||
from app.api.internal import router as internal_router
|
from app.api.internal import router as internal_router
|
||||||
from app.api.me import router as me_router
|
from app.api.me import router as me_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.services.runtime_cache import runtime_cache
|
||||||
|
|
||||||
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
|
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
|
||||||
|
|
||||||
@@ -20,6 +21,18 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def invalidate_runtime_cache_on_cud(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if (
|
||||||
|
request.method in {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
and request.url.path.startswith(("/admin", "/internal"))
|
||||||
|
and response.status_code < 400
|
||||||
|
):
|
||||||
|
runtime_cache.bump_revision()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz", tags=["health"])
|
@app.get("/healthz", tags=["health"])
|
||||||
def healthz() -> dict[str, str]:
|
def healthz() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ from app.db.base import Base
|
|||||||
|
|
||||||
class Role(Base):
|
class Role(Base):
|
||||||
__tablename__ = "roles"
|
__tablename__ = "roles"
|
||||||
__table_args__ = (UniqueConstraint("system_id", "name", name="uq_roles_system_name"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("system_id", "name", name="uq_roles_system_name"),
|
||||||
|
UniqueConstraint("system_id", "role_code", name="uq_roles_system_role_code"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||||
role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||||
|
role_code: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False)
|
system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(String(1024))
|
description: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class RolesRepository:
|
|||||||
pattern = f"%{keyword}%"
|
pattern = f"%{keyword}%"
|
||||||
cond = or_(
|
cond = or_(
|
||||||
Role.role_key.ilike(pattern),
|
Role.role_key.ilike(pattern),
|
||||||
|
Role.role_code.ilike(pattern),
|
||||||
Role.name.ilike(pattern),
|
Role.name.ilike(pattern),
|
||||||
Role.description.ilike(pattern),
|
Role.description.ilike(pattern),
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,7 @@ class RolesRepository:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
role_key: str,
|
role_key: str,
|
||||||
|
role_code: str,
|
||||||
system_id: str,
|
system_id: str,
|
||||||
name: str,
|
name: str,
|
||||||
description: str | None,
|
description: str | None,
|
||||||
@@ -55,6 +57,7 @@ class RolesRepository:
|
|||||||
) -> Role:
|
) -> Role:
|
||||||
item = Role(
|
item = Role(
|
||||||
role_key=role_key,
|
role_key=role_key,
|
||||||
|
role_code=role_code,
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -70,12 +73,15 @@ class RolesRepository:
|
|||||||
item: Role,
|
item: Role,
|
||||||
*,
|
*,
|
||||||
system_id: str | None = None,
|
system_id: str | None = None,
|
||||||
|
role_code: str | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> Role:
|
) -> Role:
|
||||||
if system_id is not None:
|
if system_id is not None:
|
||||||
item.system_id = system_id
|
item.system_id = system_id
|
||||||
|
if role_code is not None:
|
||||||
|
item.role_code = role_code
|
||||||
if name is not None:
|
if name is not None:
|
||||||
item.name = name
|
item.name = name
|
||||||
if description is not None:
|
if description is not None:
|
||||||
|
|||||||
@@ -30,8 +30,16 @@ class SiteRolesRepository:
|
|||||||
)
|
)
|
||||||
return list(self.db.execute(stmt).all())
|
return list(self.db.execute(stmt).all())
|
||||||
|
|
||||||
def set_site_roles(self, *, site_id: str, role_ids: list[str]) -> None:
|
def set_site_roles(self, *, site_id: str, role_ids: list[str], commit: bool = True) -> None:
|
||||||
self.db.execute(delete(SiteRole).where(SiteRole.site_id == site_id))
|
self.db.execute(delete(SiteRole).where(SiteRole.site_id == site_id))
|
||||||
for role_id in role_ids:
|
for role_id in role_ids:
|
||||||
self.db.add(SiteRole(site_id=site_id, role_id=role_id))
|
self.db.add(SiteRole(site_id=site_id, role_id=role_id))
|
||||||
|
if commit:
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def set_role_sites(self, *, role_id: str, site_ids: list[str], commit: bool = True) -> None:
|
||||||
|
self.db.execute(delete(SiteRole).where(SiteRole.role_id == role_id))
|
||||||
|
for site_id in site_ids:
|
||||||
|
self.db.add(SiteRole(site_id=site_id, role_id=role_id))
|
||||||
|
if commit:
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class UsersRepository:
|
|||||||
provider_user_id: str | None = None,
|
provider_user_id: str | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
user = self.get_by_sub(user_sub)
|
user = self.get_by_sub(user_sub)
|
||||||
|
changed = False
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(
|
user = User(
|
||||||
user_sub=user_sub,
|
user_sub=user_sub,
|
||||||
@@ -68,15 +69,28 @@ class UsersRepository:
|
|||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
self.db.add(user)
|
self.db.add(user)
|
||||||
|
changed = True
|
||||||
else:
|
else:
|
||||||
if provider_user_id is not None:
|
if provider_user_id is not None and user.provider_user_id != provider_user_id:
|
||||||
user.provider_user_id = provider_user_id
|
user.provider_user_id = provider_user_id
|
||||||
|
changed = True
|
||||||
|
if user.username != username:
|
||||||
user.username = username
|
user.username = username
|
||||||
|
changed = True
|
||||||
|
if user.email != email:
|
||||||
user.email = email
|
user.email = email
|
||||||
|
changed = True
|
||||||
|
if user.display_name != display_name:
|
||||||
user.display_name = display_name
|
user.display_name = display_name
|
||||||
|
changed = True
|
||||||
|
if user.is_active != is_active:
|
||||||
user.is_active = is_active
|
user.is_active = is_active
|
||||||
|
changed = True
|
||||||
|
if user.status != status:
|
||||||
user.status = status
|
user.status = status
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(user)
|
self.db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ProviderPrincipal(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
preferred_username: str | None = None
|
preferred_username: str | None = None
|
||||||
groups: list[str] = Field(default_factory=list)
|
groups: list[str] = Field(default_factory=list)
|
||||||
|
realm_roles: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MeSummaryResponse(BaseModel):
|
class MeSummaryResponse(BaseModel):
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class SystemItem(BaseModel):
|
|||||||
|
|
||||||
class RoleCreateRequest(BaseModel):
|
class RoleCreateRequest(BaseModel):
|
||||||
system_key: str
|
system_key: str
|
||||||
|
role_code: str | None = None
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
@@ -81,6 +82,7 @@ class RoleCreateRequest(BaseModel):
|
|||||||
|
|
||||||
class RoleUpdateRequest(BaseModel):
|
class RoleUpdateRequest(BaseModel):
|
||||||
system_key: str | None = None
|
system_key: str | None = None
|
||||||
|
role_code: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
@@ -89,6 +91,7 @@ class RoleUpdateRequest(BaseModel):
|
|||||||
class RoleItem(BaseModel):
|
class RoleItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
role_key: str
|
role_key: str
|
||||||
|
role_code: str
|
||||||
system_key: str
|
system_key: str
|
||||||
system_name: str
|
system_name: str
|
||||||
name: str
|
name: str
|
||||||
@@ -138,6 +141,7 @@ class SiteRoleAssignRequest(BaseModel):
|
|||||||
class SiteRoleItem(BaseModel):
|
class SiteRoleItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
role_key: str
|
role_key: str
|
||||||
|
role_code: str
|
||||||
role_name: str
|
role_name: str
|
||||||
system_key: str
|
system_key: str
|
||||||
system_name: str
|
system_name: str
|
||||||
@@ -163,6 +167,7 @@ class UserEffectiveRoleItem(BaseModel):
|
|||||||
system_key: str
|
system_key: str
|
||||||
system_name: str
|
system_name: str
|
||||||
role_key: str
|
role_key: str
|
||||||
|
role_code: str
|
||||||
role_name: str
|
role_name: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class InternalSystemListResponse(BaseModel):
|
|||||||
class InternalRoleItem(BaseModel):
|
class InternalRoleItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
role_key: str
|
role_key: str
|
||||||
|
role_code: str
|
||||||
system_key: str
|
system_key: str
|
||||||
system_name: str
|
system_name: str
|
||||||
name: str
|
name: str
|
||||||
@@ -99,6 +100,7 @@ class InternalUserRoleItem(BaseModel):
|
|||||||
system_key: str
|
system_key: str
|
||||||
system_name: str
|
system_name: str
|
||||||
role_key: str
|
role_key: str
|
||||||
|
role_code: str
|
||||||
role_name: str
|
role_name: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class RoleSnapshotItem(BaseModel):
|
|||||||
system_key: str
|
system_key: str
|
||||||
system_name: str
|
system_name: str
|
||||||
role_key: str
|
role_key: str
|
||||||
|
role_code: str
|
||||||
role_name: str
|
role_name: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,33 +5,27 @@ from app.schemas.auth import ProviderPrincipal
|
|||||||
from app.security.idp_jwt import require_authenticated_principal
|
from app.security.idp_jwt import require_authenticated_principal
|
||||||
|
|
||||||
|
|
||||||
def _expand_group_aliases(groups: set[str]) -> set[str]:
|
def _normalize_roles(values: set[str]) -> set[str]:
|
||||||
expanded: set[str] = set()
|
normalized: set[str] = set()
|
||||||
for group in groups:
|
for value in values:
|
||||||
value = group.strip().lower()
|
role = value.strip().lower()
|
||||||
if not value:
|
if role:
|
||||||
continue
|
normalized.add(role)
|
||||||
expanded.add(value)
|
return normalized
|
||||||
stripped = value.lstrip("/")
|
|
||||||
if stripped:
|
|
||||||
expanded.add(stripped)
|
|
||||||
if "/" in stripped:
|
|
||||||
expanded.add(stripped.rsplit("/", 1)[-1])
|
|
||||||
return expanded
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin_principal(
|
def require_admin_principal(
|
||||||
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
||||||
) -> ProviderPrincipal:
|
) -> ProviderPrincipal:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
required_groups = _expand_group_aliases(set(settings.admin_required_groups))
|
required_roles = _normalize_roles(set(settings.admin_required_realm_roles))
|
||||||
|
|
||||||
if not required_groups:
|
if not required_roles:
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="admin_policy_not_configured")
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="admin_policy_not_configured")
|
||||||
|
|
||||||
principal_groups = _expand_group_aliases(set(principal.groups))
|
principal_roles = _normalize_roles(set(principal.realm_roles))
|
||||||
group_ok = bool(required_groups.intersection(principal_groups))
|
role_ok = bool(required_roles.intersection(principal_roles))
|
||||||
|
|
||||||
if not group_ok:
|
if not role_ok:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_forbidden")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_forbidden")
|
||||||
return principal
|
return principal
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
@@ -30,6 +31,7 @@ class ProviderTokenVerifier:
|
|||||||
admin_realm: str | None,
|
admin_realm: str | None,
|
||||||
admin_client_id: str | None,
|
admin_client_id: str | None,
|
||||||
admin_client_secret: str | None,
|
admin_client_secret: str | None,
|
||||||
|
member_required_realm_roles: list[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.issuer = issuer.strip() if issuer else 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.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
||||||
@@ -49,7 +51,20 @@ class ProviderTokenVerifier:
|
|||||||
if not self.jwks_url:
|
if not self.jwks_url:
|
||||||
raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required")
|
raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required")
|
||||||
|
|
||||||
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
|
self._jwk_client = jwt.PyJWKClient(
|
||||||
|
self.jwks_url,
|
||||||
|
cache_jwk_set=True,
|
||||||
|
lifespan=600,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "member-ose-backend/1.0",
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
self._admin_token_cached: str | None = None
|
||||||
|
self._admin_token_expires_at: float = 0
|
||||||
|
self._principal_cache: dict[str, tuple[float, ProviderPrincipal]] = {}
|
||||||
|
self.member_required_realm_roles = {r.strip().lower() for r in member_required_realm_roles if r and r.strip()}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _infer_introspection_endpoint(issuer: str | None) -> str | None:
|
def _infer_introspection_endpoint(issuer: str | None) -> str | None:
|
||||||
@@ -138,10 +153,14 @@ class ProviderTokenVerifier:
|
|||||||
name=name,
|
name=name,
|
||||||
preferred_username=preferred_username,
|
preferred_username=preferred_username,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
|
realm_roles=principal.realm_roles,
|
||||||
)
|
)
|
||||||
return self._enrich_groups_from_admin(enriched)
|
return self._enrich_groups_from_admin(enriched)
|
||||||
|
|
||||||
def _get_admin_token(self) -> str | None:
|
def _get_admin_token(self) -> str | None:
|
||||||
|
now = time.time()
|
||||||
|
if self._admin_token_cached and now < self._admin_token_expires_at:
|
||||||
|
return self._admin_token_cached
|
||||||
if (
|
if (
|
||||||
not self.base_url
|
not self.base_url
|
||||||
or not self.admin_realm
|
or not self.admin_realm
|
||||||
@@ -167,7 +186,14 @@ class ProviderTokenVerifier:
|
|||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
return None
|
return None
|
||||||
token = resp.json().get("access_token")
|
token = resp.json().get("access_token")
|
||||||
return str(token) if token else None
|
expires_in = resp.json().get("expires_in")
|
||||||
|
if token:
|
||||||
|
ttl = int(expires_in) if isinstance(expires_in, int) else 30
|
||||||
|
# Keep a small buffer to avoid using near-expiry admin token.
|
||||||
|
self._admin_token_cached = str(token)
|
||||||
|
self._admin_token_expires_at = max(now + ttl - 15, now + 5)
|
||||||
|
return self._admin_token_cached
|
||||||
|
return None
|
||||||
|
|
||||||
def _enrich_groups_from_admin(self, principal: ProviderPrincipal) -> ProviderPrincipal:
|
def _enrich_groups_from_admin(self, principal: ProviderPrincipal) -> ProviderPrincipal:
|
||||||
if principal.groups:
|
if principal.groups:
|
||||||
@@ -210,9 +236,22 @@ class ProviderTokenVerifier:
|
|||||||
name=principal.name,
|
name=principal.name,
|
||||||
preferred_username=principal.preferred_username,
|
preferred_username=principal.preferred_username,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
|
realm_roles=principal.realm_roles,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _require_member_role(self, principal: ProviderPrincipal) -> None:
|
||||||
|
if not self.member_required_realm_roles:
|
||||||
|
return
|
||||||
|
user_roles = {r.strip().lower() for r in principal.realm_roles if isinstance(r, str) and r.strip()}
|
||||||
|
if not user_roles.intersection(self.member_required_realm_roles):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="member_forbidden")
|
||||||
|
|
||||||
def verify_access_token(self, token: str) -> ProviderPrincipal:
|
def verify_access_token(self, token: str) -> ProviderPrincipal:
|
||||||
|
now = time.time()
|
||||||
|
cached = self._principal_cache.get(token)
|
||||||
|
if cached and now < cached[0]:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
header = jwt.get_unverified_header(token)
|
header = jwt.get_unverified_header(token)
|
||||||
algorithm = str(header.get("alg", "")).upper()
|
algorithm = str(header.get("alg", "")).upper()
|
||||||
@@ -247,7 +286,7 @@ class ProviderTokenVerifier:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
claims = self._introspect_token(token)
|
claims = self._introspect_token(token)
|
||||||
if claims:
|
if claims:
|
||||||
logger.warning("jwt verify failed, used introspection fallback: %s", exc)
|
logger.debug("jwt verify failed, used introspection fallback: %s", exc)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
|
||||||
|
|
||||||
@@ -261,8 +300,31 @@ class ProviderTokenVerifier:
|
|||||||
name=claims.get("name"),
|
name=claims.get("name"),
|
||||||
preferred_username=claims.get("preferred_username"),
|
preferred_username=claims.get("preferred_username"),
|
||||||
groups=[str(g) for g in claims.get("groups", []) if str(g)] if isinstance(claims.get("groups"), list) else [],
|
groups=[str(g) for g in claims.get("groups", []) if str(g)] if isinstance(claims.get("groups"), list) else [],
|
||||||
|
realm_roles=[
|
||||||
|
str(r)
|
||||||
|
for r in (
|
||||||
|
claims.get("realm_access", {}).get("roles", [])
|
||||||
|
if isinstance(claims.get("realm_access"), dict)
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
return self._enrich_from_userinfo(principal, token)
|
if str(r)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
enriched = self._enrich_from_userinfo(principal, token)
|
||||||
|
self._require_member_role(enriched)
|
||||||
|
|
||||||
|
exp = claims.get("exp")
|
||||||
|
if isinstance(exp, int):
|
||||||
|
cache_until = min(float(exp), now + 60)
|
||||||
|
else:
|
||||||
|
cache_until = now + 30
|
||||||
|
if cache_until > now:
|
||||||
|
self._principal_cache[token] = (cache_until, enriched)
|
||||||
|
if len(self._principal_cache) > 512:
|
||||||
|
# Simple bound to avoid unbounded memory growth.
|
||||||
|
self._principal_cache.clear()
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -281,6 +343,7 @@ def _get_verifier() -> ProviderTokenVerifier:
|
|||||||
admin_realm=settings.keycloak_admin_realm,
|
admin_realm=settings.keycloak_admin_realm,
|
||||||
admin_client_id=settings.keycloak_admin_client_id,
|
admin_client_id=settings.keycloak_admin_client_id,
|
||||||
admin_client_secret=settings.keycloak_admin_client_secret,
|
admin_client_secret=settings.keycloak_admin_client_secret,
|
||||||
|
member_required_realm_roles=settings.member_required_realm_roles,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -413,6 +413,65 @@ class ProviderAdminService:
|
|||||||
return client_uuid
|
return client_uuid
|
||||||
raise HTTPException(status_code=404, detail="provider_client_not_found")
|
raise HTTPException(status_code=404, detail="provider_client_not_found")
|
||||||
|
|
||||||
|
def _get_client_role_representation(self, client: httpx.Client, *, client_uuid: str, role_name: str) -> dict:
|
||||||
|
resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise HTTPException(status_code=404, detail=f"provider_role_not_found:{role_name}")
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
||||||
|
payload = resp.json() if resp.content else {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def set_group_client_roles(self, *, group_id: str, provider_client_id: str, role_names: list[str]) -> None:
|
||||||
|
if not group_id:
|
||||||
|
raise HTTPException(status_code=400, detail="provider_group_id_required")
|
||||||
|
|
||||||
|
desired_names = [name.strip() for name in role_names if isinstance(name, str) and name.strip()]
|
||||||
|
desired_name_set = set(desired_names)
|
||||||
|
|
||||||
|
with self._client() as client:
|
||||||
|
client_uuid = self._resolve_client_uuid(client, provider_client_id)
|
||||||
|
|
||||||
|
current_resp = client.get(f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}")
|
||||||
|
if current_resp.status_code >= 400:
|
||||||
|
raise HTTPException(status_code=502, detail="idp_group_role_mapping_lookup_failed")
|
||||||
|
current_payload = current_resp.json() if current_resp.content else []
|
||||||
|
current_rows = current_payload if isinstance(current_payload, list) else []
|
||||||
|
current_map: dict[str, dict] = {}
|
||||||
|
for row in current_rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
name = str(row.get("name", "")).strip()
|
||||||
|
if name:
|
||||||
|
current_map[name] = row
|
||||||
|
|
||||||
|
to_add_names = sorted(desired_name_set - set(current_map.keys()))
|
||||||
|
to_remove_names = sorted(set(current_map.keys()) - desired_name_set)
|
||||||
|
|
||||||
|
if to_add_names:
|
||||||
|
add_payload = [
|
||||||
|
self._get_client_role_representation(client, client_uuid=client_uuid, role_name=role_name)
|
||||||
|
for role_name in to_add_names
|
||||||
|
]
|
||||||
|
add_resp = client.post(
|
||||||
|
f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}",
|
||||||
|
json=add_payload,
|
||||||
|
)
|
||||||
|
if add_resp.status_code >= 400:
|
||||||
|
raise HTTPException(status_code=502, detail="idp_group_role_mapping_add_failed")
|
||||||
|
|
||||||
|
if to_remove_names:
|
||||||
|
remove_payload = [current_map[name] for name in to_remove_names]
|
||||||
|
remove_resp = client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}",
|
||||||
|
json=remove_payload,
|
||||||
|
)
|
||||||
|
if remove_resp.status_code >= 400:
|
||||||
|
raise HTTPException(status_code=502, detail="idp_group_role_mapping_remove_failed")
|
||||||
|
|
||||||
def ensure_client_role(
|
def ensure_client_role(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]:
|
|||||||
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
||||||
roles_repo.create(
|
roles_repo.create(
|
||||||
role_key=role_key,
|
role_key=role_key,
|
||||||
|
role_code=role_name,
|
||||||
system_id=system.id,
|
system_id=system.id,
|
||||||
name=role_name,
|
name=role_name,
|
||||||
description=role_desc,
|
description=role_desc,
|
||||||
@@ -259,6 +260,7 @@ def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]:
|
|||||||
else:
|
else:
|
||||||
roles_repo.update(
|
roles_repo.update(
|
||||||
role,
|
role,
|
||||||
|
role_code=role.role_code or role_name,
|
||||||
name=role_name,
|
name=role_name,
|
||||||
description=role_desc,
|
description=role_desc,
|
||||||
status=role_status,
|
status=role_status,
|
||||||
@@ -373,6 +375,7 @@ def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str,
|
|||||||
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
||||||
roles_repo.create(
|
roles_repo.create(
|
||||||
role_key=role_key,
|
role_key=role_key,
|
||||||
|
role_code=role_name,
|
||||||
system_id=system.id,
|
system_id=system.id,
|
||||||
name=role_name,
|
name=role_name,
|
||||||
description=role_desc,
|
description=role_desc,
|
||||||
@@ -382,6 +385,7 @@ def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str,
|
|||||||
else:
|
else:
|
||||||
roles_repo.update(
|
roles_repo.update(
|
||||||
role,
|
role,
|
||||||
|
role_code=role.role_code or role_name,
|
||||||
name=role_name,
|
name=role_name,
|
||||||
description=role_desc,
|
description=role_desc,
|
||||||
status=role_status,
|
status=role_status,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from app.schemas.permissions import RoleSnapshotItem, RoleSnapshotResponse
|
|||||||
|
|
||||||
class PermissionService:
|
class PermissionService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse:
|
def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse:
|
||||||
return RoleSnapshotResponse(
|
return RoleSnapshotResponse(
|
||||||
user_sub=user_sub,
|
user_sub=user_sub,
|
||||||
roles=[
|
roles=[
|
||||||
@@ -15,6 +15,7 @@ class PermissionService:
|
|||||||
system_key=system_key,
|
system_key=system_key,
|
||||||
system_name=system_name,
|
system_name=system_name,
|
||||||
role_key=role_key,
|
role_key=role_key,
|
||||||
|
role_code=role_code,
|
||||||
role_name=role_name,
|
role_name=role_name,
|
||||||
)
|
)
|
||||||
for (
|
for (
|
||||||
@@ -25,6 +26,7 @@ class PermissionService:
|
|||||||
system_key,
|
system_key,
|
||||||
system_name,
|
system_name,
|
||||||
role_key,
|
role_key,
|
||||||
|
role_code,
|
||||||
role_name,
|
role_name,
|
||||||
) in rows
|
) in rows
|
||||||
],
|
],
|
||||||
|
|||||||
175
app/services/runtime_cache.py
Normal file
175
app/services/runtime_cache.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
import logging
|
||||||
|
import pickle
|
||||||
|
from threading import RLock
|
||||||
|
import time
|
||||||
|
from typing import Callable, Protocol, TypeVar
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
except Exception: # pragma: no cover - optional dependency in local dev
|
||||||
|
redis = None
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(Protocol):
|
||||||
|
def get(self, key: str) -> object | None: ...
|
||||||
|
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object: ...
|
||||||
|
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T: ...
|
||||||
|
def bump_revision(self) -> int: ...
|
||||||
|
def revision(self) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CacheEntry:
|
||||||
|
value: object
|
||||||
|
expires_at: float
|
||||||
|
revision: int
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryRuntimeCache:
|
||||||
|
"""Simple in-memory cache for local/single-instance environments."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = RLock()
|
||||||
|
self._revision = 0
|
||||||
|
self._entries: dict[str, _CacheEntry] = {}
|
||||||
|
|
||||||
|
def get(self, key: str) -> object | None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
if entry.expires_at <= now or entry.revision != self._revision:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry.value
|
||||||
|
|
||||||
|
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
self._entries[key] = _CacheEntry(
|
||||||
|
value=value,
|
||||||
|
expires_at=now + max(ttl_seconds, 1),
|
||||||
|
revision=self._revision,
|
||||||
|
)
|
||||||
|
if len(self._entries) > 2000:
|
||||||
|
self._entries.clear()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T:
|
||||||
|
cached = self.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # type: ignore[return-value]
|
||||||
|
return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def bump_revision(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._revision += 1
|
||||||
|
if self._revision > 1_000_000_000:
|
||||||
|
self._revision = 1
|
||||||
|
self._entries.clear()
|
||||||
|
return self._revision
|
||||||
|
|
||||||
|
def revision(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return self._revision
|
||||||
|
|
||||||
|
|
||||||
|
class RedisRuntimeCache:
|
||||||
|
"""Redis-backed cache for multi-instance deployments."""
|
||||||
|
|
||||||
|
def __init__(self, *, redis_url: str, prefix: str, default_ttl_seconds: int = 30) -> None:
|
||||||
|
if redis is None:
|
||||||
|
raise RuntimeError("redis_package_not_installed")
|
||||||
|
self._redis = redis.Redis.from_url(redis_url, decode_responses=False)
|
||||||
|
self._prefix = prefix.strip() or "memberapi"
|
||||||
|
self._default_ttl_seconds = max(int(default_ttl_seconds), 1)
|
||||||
|
self._revision_key = f"{self._prefix}:cache:revision"
|
||||||
|
self._rev_cache_value = 0
|
||||||
|
self._rev_cache_expires_at = 0.0
|
||||||
|
|
||||||
|
def _cache_key(self, key: str, revision: int) -> str:
|
||||||
|
key_hash = sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
return f"{self._prefix}:cache:{revision}:{key_hash}"
|
||||||
|
|
||||||
|
def _get_revision_cached(self) -> int:
|
||||||
|
now = time.time()
|
||||||
|
if now < self._rev_cache_expires_at:
|
||||||
|
return self._rev_cache_value
|
||||||
|
try:
|
||||||
|
raw = self._redis.get(self._revision_key)
|
||||||
|
value = int(raw) if raw else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
self._rev_cache_value = value
|
||||||
|
self._rev_cache_expires_at = now + 1.0
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get(self, key: str) -> object | None:
|
||||||
|
try:
|
||||||
|
revision = self._get_revision_cached()
|
||||||
|
raw = self._redis.get(self._cache_key(key, revision))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return pickle.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object:
|
||||||
|
ttl = max(int(ttl_seconds), 1) if ttl_seconds else self._default_ttl_seconds
|
||||||
|
try:
|
||||||
|
revision = self._get_revision_cached()
|
||||||
|
self._redis.setex(self._cache_key(key, revision), ttl, pickle.dumps(value))
|
||||||
|
except Exception:
|
||||||
|
# Keep request path healthy even when Redis has issues.
|
||||||
|
pass
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T:
|
||||||
|
cached = self.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # type: ignore[return-value]
|
||||||
|
return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def bump_revision(self) -> int:
|
||||||
|
try:
|
||||||
|
value = int(self._redis.incr(self._revision_key))
|
||||||
|
self._rev_cache_value = value
|
||||||
|
self._rev_cache_expires_at = time.time() + 1.0
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
# Fail-open: keep app usable; caller still succeeds.
|
||||||
|
return self._get_revision_cached()
|
||||||
|
|
||||||
|
def revision(self) -> int:
|
||||||
|
return self._get_revision_cached()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_runtime_cache() -> CacheBackend:
|
||||||
|
settings = get_settings()
|
||||||
|
backend = (settings.cache_backend or "memory").strip().lower()
|
||||||
|
if backend == "redis":
|
||||||
|
try:
|
||||||
|
cache = RedisRuntimeCache(
|
||||||
|
redis_url=settings.cache_redis_url,
|
||||||
|
prefix=settings.cache_prefix,
|
||||||
|
default_ttl_seconds=settings.cache_default_ttl_seconds,
|
||||||
|
)
|
||||||
|
logger.info("runtime cache backend: redis")
|
||||||
|
return cache
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("redis cache unavailable, fallback to memory: %s", exc)
|
||||||
|
logger.info("runtime cache backend: memory")
|
||||||
|
return MemoryRuntimeCache()
|
||||||
|
|
||||||
|
|
||||||
|
runtime_cache: CacheBackend = _build_runtime_cache()
|
||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"passlib[bcrypt]>=1.7.4",
|
"passlib[bcrypt]>=1.7.4",
|
||||||
"pyjwt[crypto]>=2.10.1",
|
"pyjwt[crypto]>=2.10.1",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"redis>=5.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -68,13 +68,15 @@ CREATE TABLE systems (
|
|||||||
CREATE TABLE roles (
|
CREATE TABLE roles (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
role_key TEXT NOT NULL UNIQUE,
|
role_key TEXT NOT NULL UNIQUE,
|
||||||
|
role_code TEXT NOT NULL,
|
||||||
system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE,
|
system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
CONSTRAINT uq_roles_system_name UNIQUE (system_id, name)
|
CONSTRAINT uq_roles_system_name UNIQUE (system_id, name),
|
||||||
|
CONSTRAINT uq_roles_system_role_code UNIQUE (system_id, role_code)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE site_roles (
|
CREATE TABLE site_roles (
|
||||||
@@ -126,6 +128,7 @@ CREATE INDEX idx_users_username ON users(username);
|
|||||||
CREATE INDEX idx_users_email ON users(email);
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
CREATE INDEX idx_sites_company_id ON sites(company_id);
|
CREATE INDEX idx_sites_company_id ON sites(company_id);
|
||||||
CREATE INDEX idx_roles_system_id ON roles(system_id);
|
CREATE INDEX idx_roles_system_id ON roles(system_id);
|
||||||
|
CREATE INDEX idx_roles_role_code ON roles(role_code);
|
||||||
CREATE INDEX idx_site_roles_site_id ON site_roles(site_id);
|
CREATE INDEX idx_site_roles_site_id ON site_roles(site_id);
|
||||||
CREATE INDEX idx_site_roles_role_id ON site_roles(role_id);
|
CREATE INDEX idx_site_roles_role_id ON site_roles(role_id);
|
||||||
CREATE INDEX idx_user_sites_user_id ON user_sites(user_id);
|
CREATE INDEX idx_user_sites_user_id ON user_sites(user_id);
|
||||||
|
|||||||
27
scripts/migrate_add_role_code.sql
Normal file
27
scripts/migrate_add_role_code.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE roles
|
||||||
|
ADD COLUMN IF NOT EXISTS role_code TEXT;
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET role_code = name
|
||||||
|
WHERE role_code IS NULL OR btrim(role_code) = '';
|
||||||
|
|
||||||
|
ALTER TABLE roles
|
||||||
|
ALTER COLUMN role_code SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'uq_roles_system_role_code'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE roles
|
||||||
|
ADD CONSTRAINT uq_roles_system_role_code UNIQUE (system_id, role_code);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_roles_role_code ON roles(role_code);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
-- Rename legacy IdP column names to provider_* naming.
|
|
||||||
-- Safe to run multiple times.
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'idp_group_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'provider_group_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.companies RENAME COLUMN idp_group_id TO provider_group_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- companies.display_name -> companies.name
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'display_name'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.companies RENAME COLUMN display_name TO name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'legal_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.companies DROP COLUMN legal_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.systems DROP COLUMN provider_client_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles DROP COLUMN provider_role_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_provider_role_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles DROP CONSTRAINT uq_roles_system_provider_role_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles ADD CONSTRAINT uq_roles_system_name UNIQUE (system_id, name);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'idp_group_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'provider_group_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.sites RENAME COLUMN idp_group_id TO provider_group_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'idp_client_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.systems RENAME COLUMN idp_client_id TO provider_client_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'idp_role_name'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles RENAME COLUMN idp_role_name TO provider_role_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'idp_user_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'provider_user_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.users RENAME COLUMN idp_user_id TO provider_user_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
@@ -3,4 +3,11 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
exec uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 --reload
|
ENV_FILE=".env.development"
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "missing $ENV_FILE."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Loading environment from '$ENV_FILE'"
|
||||||
|
exec uvicorn app.main:app --env-file "$ENV_FILE" --host 127.0.0.1 --port 8000 --reload
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ def test_internal_idp_ensure_requires_config() -> None:
|
|||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
try:
|
try:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/internal/idp/users/ensure",
|
"/internal/provider/users/ensure",
|
||||||
json={
|
json={
|
||||||
"sub": "idp-sub-1",
|
"sub": "idp-sub-1",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user