Switch access control from groups to realm roles

This commit is contained in:
Chris
2026-04-03 03:03:43 +08:00
parent daa21e81a9
commit fc81696abf
10 changed files with 60 additions and 24 deletions

View File

@@ -19,7 +19,8 @@ KEYCLOAK_ADMIN_REALM=master
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
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
CACHE_REDIS_URL=redis://127.0.0.1:6379/0

View File

@@ -25,7 +25,8 @@ 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
MEMBER_REQUIRED_REALM_ROLES=admin,manager
ADMIN_REQUIRED_REALM_ROLES=admin,manager
# Cache backend: memory | redis
CACHE_BACKEND=memory

View File

@@ -25,6 +25,8 @@ KEYCLOAK_ADMIN_REALM=
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

View File

@@ -29,6 +29,8 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
- `KEYCLOAK_USERINFO_ENDPOINT`
- `KEYCLOAK_AUDIENCE`
- `KEYCLOAK_VERIFY_TLS`
- `MEMBER_REQUIRED_REALM_ROLES` (default: `admin,manager`)
- `ADMIN_REQUIRED_REALM_ROLES` (default: `admin,manager`)
## Main APIs
@@ -38,7 +40,7 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
- `GET /me` (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/sites`
- `GET/POST/PATCH/DELETE /admin/systems`

View File

@@ -35,6 +35,8 @@ class Settings(BaseSettings):
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
internal_shared_secret: str = ""
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"
@@ -58,6 +60,15 @@ class Settings(BaseSettings):
return []
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
def database_url(self) -> str:
return (

View File

@@ -7,6 +7,7 @@ class ProviderPrincipal(BaseModel):
name: str | None = None
preferred_username: str | None = None
groups: list[str] = Field(default_factory=list)
realm_roles: list[str] = Field(default_factory=list)
class MeSummaryResponse(BaseModel):

View File

@@ -5,33 +5,27 @@ from app.schemas.auth import ProviderPrincipal
from app.security.idp_jwt import require_authenticated_principal
def _expand_group_aliases(groups: set[str]) -> set[str]:
expanded: set[str] = set()
for group in groups:
value = group.strip().lower()
if not value:
continue
expanded.add(value)
stripped = value.lstrip("/")
if stripped:
expanded.add(stripped)
if "/" in stripped:
expanded.add(stripped.rsplit("/", 1)[-1])
return expanded
def _normalize_roles(values: set[str]) -> set[str]:
normalized: set[str] = set()
for value in values:
role = value.strip().lower()
if role:
normalized.add(role)
return normalized
def require_admin_principal(
principal: ProviderPrincipal = Depends(require_authenticated_principal),
) -> ProviderPrincipal:
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")
principal_groups = _expand_group_aliases(set(principal.groups))
group_ok = bool(required_groups.intersection(principal_groups))
principal_roles = _normalize_roles(set(principal.realm_roles))
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")
return principal

View File

@@ -31,6 +31,7 @@ class ProviderTokenVerifier:
admin_realm: str | None,
admin_client_id: str | None,
admin_client_secret: str | None,
member_required_realm_roles: list[str],
) -> 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)
@@ -63,6 +64,7 @@ class ProviderTokenVerifier:
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
def _infer_introspection_endpoint(issuer: str | None) -> str | None:
@@ -151,6 +153,7 @@ class ProviderTokenVerifier:
name=name,
preferred_username=preferred_username,
groups=groups,
realm_roles=principal.realm_roles,
)
return self._enrich_groups_from_admin(enriched)
@@ -233,8 +236,16 @@ class ProviderTokenVerifier:
name=principal.name,
preferred_username=principal.preferred_username,
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:
now = time.time()
cached = self._principal_cache.get(token)
@@ -289,8 +300,18 @@ class ProviderTokenVerifier:
name=claims.get("name"),
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 [],
realm_roles=[
str(r)
for r in (
claims.get("realm_access", {}).get("roles", [])
if isinstance(claims.get("realm_access"), dict)
else []
)
if str(r)
],
)
enriched = self._enrich_from_userinfo(principal, token)
self._require_member_role(enriched)
exp = claims.get("exp")
if isinstance(exp, int):
@@ -322,6 +343,7 @@ def _get_verifier() -> ProviderTokenVerifier:
admin_realm=settings.keycloak_admin_realm,
admin_client_id=settings.keycloak_admin_client_id,
admin_client_secret=settings.keycloak_admin_client_secret,
member_required_realm_roles=settings.member_required_realm_roles,
)

View File

@@ -35,8 +35,9 @@
## 後台安全線
- `/admin/*` 必須 Bearer token。
- 後端以 admin 群組白名單判定是否可進後台。
- 有 Keycloak 帳號但不在 admin 白名單者,後台 API 一律拒絕。
- 後端以 Keycloak realm role 判定是否可進站與後台。
- 未具備 `MEMBER_REQUIRED_REALM_ROLES` 的帳號,`/me``/admin/*`拒絕。
- 未具備 `ADMIN_REQUIRED_REALM_ROLES` 的帳號,`/admin/*` 拒絕。
## API 白名單
- 保留 `api_clients` 做系統對系統呼叫控管。

View File

@@ -51,7 +51,8 @@ npm run dev
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- `ADMIN_REQUIRED_GROUPS`
- `MEMBER_REQUIRED_REALM_ROLES`
- `ADMIN_REQUIRED_REALM_ROLES`
- `CACHE_BACKEND``memory``redis`
- `CACHE_REDIS_URL`
- `CACHE_PREFIX`