feat: enforce effective appearance policy
This commit is contained in:
@@ -16,6 +16,7 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
AuthShellResponse,
|
AuthShellResponse,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuthSessionUserInfo,
|
AuthSessionUserInfo,
|
||||||
|
EffectiveAppearanceInfo,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -31,6 +32,7 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
UserUiPreferences,
|
UserUiPreferences,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
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.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
@@ -255,12 +257,23 @@ def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
|
|||||||
return 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(
|
def _user_info(
|
||||||
user: User,
|
user: User,
|
||||||
account: Account,
|
account: Account,
|
||||||
*,
|
*,
|
||||||
preferred_language: str | None = None,
|
preferred_language: str | None = None,
|
||||||
enabled_language_codes: list[str] | None = None,
|
enabled_language_codes: list[str] | None = None,
|
||||||
|
appearance: EffectiveAppearanceInfo | None = None,
|
||||||
) -> UserInfo:
|
) -> UserInfo:
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -273,6 +286,7 @@ def _user_info(
|
|||||||
preferred_language=preferred_language,
|
preferred_language=preferred_language,
|
||||||
enabled_language_codes=enabled_language_codes or [],
|
enabled_language_codes=enabled_language_codes or [],
|
||||||
ui_preferences=_user_ui_preferences(user.settings),
|
ui_preferences=_user_ui_preferences(user.settings),
|
||||||
|
appearance=appearance or EffectiveAppearanceInfo(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -593,7 +607,7 @@ def _shell_response(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
return AuthShellResponse(
|
return AuthShellResponse(
|
||||||
user=_user_info(user, account),
|
user=_user_info(user, account, appearance=_effective_appearance_info(session, tenant=tenant, user=user)),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
tenants=memberships,
|
tenants=memberships,
|
||||||
@@ -669,6 +683,7 @@ def _profile_response(session: Session, context: AuthContext) -> AuthProfileResp
|
|||||||
context.account,
|
context.account,
|
||||||
preferred_language=preferred_language,
|
preferred_language=preferred_language,
|
||||||
enabled_language_codes=user_enabled,
|
enabled_language_codes=user_enabled,
|
||||||
|
appearance=_effective_appearance_info(session, tenant=context.tenant, user=context.user),
|
||||||
),
|
),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
@@ -783,7 +798,13 @@ def _me_response(
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
return MeResponse(
|
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,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
tenants=memberships,
|
tenants=memberships,
|
||||||
@@ -991,6 +1012,17 @@ def update_profile(
|
|||||||
if payload.ui_preferences is None:
|
if payload.ui_preferences is None:
|
||||||
next_settings["ui"] = UserUiPreferences().model_dump()
|
next_settings["ui"] = UserUiPreferences().model_dump()
|
||||||
else:
|
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 (
|
if (
|
||||||
payload.ui_preferences.navigation is not None
|
payload.ui_preferences.navigation is not None
|
||||||
and payload.ui_preferences.navigation.locked
|
and payload.ui_preferences.navigation.locked
|
||||||
|
|||||||
@@ -301,6 +301,25 @@ ADMIN_READ_SCOPES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
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(
|
DocumentationTopic(
|
||||||
id="access.reference.resource-explanation-subjects",
|
id="access.reference.resource-explanation-subjects",
|
||||||
title="Select a user for resource-access diagnostics",
|
title="Select a user for resource-access diagnostics",
|
||||||
|
|||||||
Reference in New Issue
Block a user