From a187acf58ccc327d99fd7159ca3847a58edfab6c Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 3 Apr 2026 03:03:43 +0800 Subject: [PATCH] Switch access control from groups to realm roles --- .env.development | 3 ++- .env.example | 3 ++- .env.production.example | 2 ++ README.md | 4 +++- app/core/config.py | 11 +++++++++++ app/schemas/auth.py | 1 + app/security/admin_guard.py | 30 ++++++++++++------------------ app/security/idp_jwt.py | 22 ++++++++++++++++++++++ 8 files changed, 55 insertions(+), 21 deletions(-) diff --git a/.env.development b/.env.development index d6804f9..971dc18 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.example b/.env.example index 13e5b77..f0cb859 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example index ce0aac9..9535f53 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/README.md b/README.md index 4c34151..b61dee6 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/core/config.py b/app/core/config.py index 043ccbb..d01fb81 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 ( diff --git a/app/schemas/auth.py b/app/schemas/auth.py index a3c523c..31a4897 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -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): diff --git a/app/security/admin_guard.py b/app/security/admin_guard.py index 428995a..6f31b16 100644 --- a/app/security/admin_guard.py +++ b/app/security/admin_guard.py @@ -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 diff --git a/app/security/idp_jwt.py b/app/security/idp_jwt.py index 5953c9e..8821539 100644 --- a/app/security/idp_jwt.py +++ b/app/security/idp_jwt.py @@ -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, )