diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index 7ce24ce..d2b6353 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -16,6 +16,7 @@ from govoplan_core.api.v1.schemas import ( AuthShellResponse, AuthSessionResponse, AuthSessionUserInfo, + EffectiveAppearanceInfo, GroupInfo, LoginRequest, LoginResponse, @@ -31,6 +32,7 @@ from govoplan_core.api.v1.schemas import ( UserUiPreferences, ) from govoplan_core.core.access import AuthMethod, PrincipalRef +from govoplan_core.core.appearance import resolve_effective_appearance from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.principal_cache import invalidate_auth_principals @@ -255,12 +257,23 @@ def _user_ui_preferences(settings_payload: object) -> UserUiPreferences: return UserUiPreferences() +def _effective_appearance_info(session: Session, *, tenant: Tenant, user: User) -> EffectiveAppearanceInfo: + system_item = get_system_settings(session) + decision = resolve_effective_appearance( + system_settings=system_item.settings, + tenant_settings=tenant.settings, + user_settings=user.settings, + ) + return EffectiveAppearanceInfo.model_validate(decision.as_dict()) + + def _user_info( user: User, account: Account, *, preferred_language: str | None = None, enabled_language_codes: list[str] | None = None, + appearance: EffectiveAppearanceInfo | None = None, ) -> UserInfo: return UserInfo( id=user.id, @@ -273,6 +286,7 @@ def _user_info( preferred_language=preferred_language, enabled_language_codes=enabled_language_codes or [], ui_preferences=_user_ui_preferences(user.settings), + appearance=appearance or EffectiveAppearanceInfo(), ) @@ -593,7 +607,7 @@ def _shell_response( ] ) return AuthShellResponse( - user=_user_info(user, account), + user=_user_info(user, account, appearance=_effective_appearance_info(session, tenant=tenant, user=user)), tenant=active_tenant, active_tenant=active_tenant, tenants=memberships, @@ -669,6 +683,7 @@ def _profile_response(session: Session, context: AuthContext) -> AuthProfileResp context.account, preferred_language=preferred_language, enabled_language_codes=user_enabled, + appearance=_effective_appearance_info(session, tenant=context.tenant, user=context.user), ), tenant=active_tenant, active_tenant=active_tenant, @@ -783,7 +798,13 @@ def _me_response( ) ] return MeResponse( - user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled), + user=_user_info( + user, + account, + preferred_language=preferred_language, + enabled_language_codes=user_enabled, + appearance=_effective_appearance_info(session, tenant=tenant, user=user), + ), tenant=active_tenant, active_tenant=active_tenant, tenants=memberships, @@ -991,6 +1012,17 @@ def update_profile( if payload.ui_preferences is None: next_settings["ui"] = UserUiPreferences().model_dump() else: + appearance = _effective_appearance_info(session, tenant=context.tenant, user=context.user) + stored_palette = _user_ui_preferences(context.user.settings).palette + if ( + appearance.locked + and payload.ui_preferences.palette is not None + and payload.ui_preferences.palette != stored_palette + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="The effective appearance policy locks the palette.", + ) if ( payload.ui_preferences.navigation is not None and payload.ui_preferences.navigation.locked diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 17ce8b2..187a561 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -301,6 +301,25 @@ ADMIN_READ_SCOPES = ( ) ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( + DocumentationTopic( + id="access.reference.effective-appearance", + title="Understand the effective appearance source", + summary="The authenticated profile explains whether the active palette comes from the user, tenant, system, or a policy lock.", + body=( + "An explicit personal palette normally wins over tenant and system defaults. " + "Resetting it stores inheritance, not a copy of the current default. A tenant " + "policy lock suppresses personal choices; a system lock suppresses both tenant " + "and personal choices. Access enforces the lock during profile writes and returns " + "the effective palette, source, inherited value, and lock state on full profile responses." + ), + layer="always", + documentation_types=("admin", "user"), + audience=("user", "tenant_admin", "system_admin"), + order=8, + links=(DocumentationLink(label="Profile API", href="/api/v1/auth/profile", kind="api"),), + related_modules=("admin", "tenancy"), + metadata={"kind": "reference", "help_contexts": ["core.settings", "admin.system-settings", "tenancy.admin.tenant-settings"]}, + ), DocumentationTopic( id="access.reference.resource-explanation-subjects", title="Select a user for resource-access diagnostics",