fix: commit semantic repair changes
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# [DEF:backend.src.services.profile_service:Module]
|
||||
# [DEF:profile_service:Module]
|
||||
#
|
||||
# @COMPLEXITY: 5
|
||||
# @SEMANTICS: profile, service, validation, ownership, filtering, superset, preferences
|
||||
@@ -51,36 +51,47 @@ SUPPORTED_DENSITIES = {"compact", "comfortable"}
|
||||
|
||||
|
||||
# [DEF:ProfileValidationError:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @RELATION: INHERITS -> Exception
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Domain validation error for profile preference update requests.
|
||||
class ProfileValidationError(Exception):
|
||||
def __init__(self, errors: Sequence[str]):
|
||||
self.errors = list(errors)
|
||||
super().__init__("Profile preference validation failed")
|
||||
|
||||
|
||||
# [/DEF:ProfileValidationError:Class]
|
||||
|
||||
|
||||
# [DEF:EnvironmentNotFoundError:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @RELATION: INHERITS -> Exception
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Raised when environment_id from lookup request is unknown in app configuration.
|
||||
class EnvironmentNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# [/DEF:EnvironmentNotFoundError:Class]
|
||||
|
||||
|
||||
# [DEF:ProfileAuthorizationError:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @RELATION: INHERITS -> Exception
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Raised when caller attempts cross-user preference mutation.
|
||||
class ProfileAuthorizationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# [/DEF:ProfileAuthorizationError:Class]
|
||||
|
||||
|
||||
# [DEF:ProfileService:Class]
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy.orm.Session
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Implements profile preference read/update flow and Superset account lookup degradation strategy.
|
||||
class ProfileService:
|
||||
# [DEF:__init__:Function]
|
||||
# [DEF:init:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Initialize service with DB session and config manager.
|
||||
# @PRE: db session is active and config_manager supports get_environments().
|
||||
# @POST: Service is ready for preference persistence and lookup operations.
|
||||
@@ -90,14 +101,18 @@ class ProfileService:
|
||||
self.plugin_loader = plugin_loader
|
||||
self.auth_repository = AuthRepository(db)
|
||||
self.encryption = EncryptionManager()
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [/DEF:init:Function]
|
||||
|
||||
# [DEF:get_my_preference:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return current user's persisted preference or default non-configured view.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returned payload belongs to current_user only.
|
||||
def get_my_preference(self, current_user: User) -> ProfilePreferenceResponse:
|
||||
with belief_scope("ProfileService.get_my_preference", f"user_id={current_user.id}"):
|
||||
with belief_scope(
|
||||
"ProfileService.get_my_preference", f"user_id={current_user.id}"
|
||||
):
|
||||
logger.reflect("[REFLECT] Loading current user's dashboard preference")
|
||||
preference = self._get_preference_row(current_user.id)
|
||||
security_summary = self._build_security_summary(current_user)
|
||||
@@ -112,17 +127,23 @@ class ProfileService:
|
||||
return ProfilePreferenceResponse(
|
||||
status="success",
|
||||
message="Preference loaded",
|
||||
preference=self._to_preference_payload(preference, str(current_user.id)),
|
||||
preference=self._to_preference_payload(
|
||||
preference, str(current_user.id)
|
||||
),
|
||||
security=security_summary,
|
||||
)
|
||||
|
||||
# [/DEF:get_my_preference:Function]
|
||||
|
||||
# [DEF:get_dashboard_filter_binding:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return only dashboard-filter fields required by dashboards listing hot path.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returns normalized username and profile-default filter toggles without security summary expansion.
|
||||
def get_dashboard_filter_binding(self, current_user: User) -> dict:
|
||||
with belief_scope("ProfileService.get_dashboard_filter_binding", f"user_id={current_user.id}"):
|
||||
with belief_scope(
|
||||
"ProfileService.get_dashboard_filter_binding", f"user_id={current_user.id}"
|
||||
):
|
||||
preference = self._get_preference_row(current_user.id)
|
||||
if preference is None:
|
||||
return {
|
||||
@@ -133,8 +154,12 @@ class ProfileService:
|
||||
}
|
||||
|
||||
return {
|
||||
"superset_username": self._sanitize_username(preference.superset_username),
|
||||
"superset_username_normalized": self._normalize_username(preference.superset_username),
|
||||
"superset_username": self._sanitize_username(
|
||||
preference.superset_username
|
||||
),
|
||||
"superset_username_normalized": self._normalize_username(
|
||||
preference.superset_username
|
||||
),
|
||||
"show_only_my_dashboards": bool(preference.show_only_my_dashboards),
|
||||
"show_only_slug_dashboards": bool(
|
||||
preference.show_only_slug_dashboards
|
||||
@@ -142,9 +167,11 @@ class ProfileService:
|
||||
else True
|
||||
),
|
||||
}
|
||||
|
||||
# [/DEF:get_dashboard_filter_binding:Function]
|
||||
|
||||
# [DEF:update_my_preference:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Validate and persist current user's profile preference in self-scoped mode.
|
||||
# @PRE: current_user is authenticated and payload is provided.
|
||||
# @POST: Preference row for current_user is created/updated when validation passes.
|
||||
@@ -154,19 +181,29 @@ class ProfileService:
|
||||
payload: ProfilePreferenceUpdateRequest,
|
||||
target_user_id: Optional[str] = None,
|
||||
) -> ProfilePreferenceResponse:
|
||||
with belief_scope("ProfileService.update_my_preference", f"user_id={current_user.id}"):
|
||||
logger.reason("[REASON] Evaluating self-scope guard before preference mutation")
|
||||
with belief_scope(
|
||||
"ProfileService.update_my_preference", f"user_id={current_user.id}"
|
||||
):
|
||||
logger.reason(
|
||||
"[REASON] Evaluating self-scope guard before preference mutation"
|
||||
)
|
||||
requested_user_id = str(target_user_id or current_user.id)
|
||||
if requested_user_id != str(current_user.id):
|
||||
logger.explore("[EXPLORE] Cross-user mutation attempt blocked")
|
||||
raise ProfileAuthorizationError("Cross-user preference mutation is forbidden")
|
||||
raise ProfileAuthorizationError(
|
||||
"Cross-user preference mutation is forbidden"
|
||||
)
|
||||
|
||||
preference = self._get_or_create_preference_row(current_user.id)
|
||||
provided_fields = set(getattr(payload, "model_fields_set", set()))
|
||||
|
||||
effective_superset_username = self._sanitize_username(preference.superset_username)
|
||||
effective_superset_username = self._sanitize_username(
|
||||
preference.superset_username
|
||||
)
|
||||
if "superset_username" in provided_fields:
|
||||
effective_superset_username = self._sanitize_username(payload.superset_username)
|
||||
effective_superset_username = self._sanitize_username(
|
||||
payload.superset_username
|
||||
)
|
||||
|
||||
effective_show_only = bool(preference.show_only_my_dashboards)
|
||||
if "show_only_my_dashboards" in provided_fields:
|
||||
@@ -247,12 +284,14 @@ class ProfileService:
|
||||
preference.git_email = effective_git_email
|
||||
|
||||
if "git_personal_access_token" in provided_fields:
|
||||
sanitized_token = self._sanitize_secret(payload.git_personal_access_token)
|
||||
sanitized_token = self._sanitize_secret(
|
||||
payload.git_personal_access_token
|
||||
)
|
||||
if sanitized_token is None:
|
||||
preference.git_personal_access_token_encrypted = None
|
||||
else:
|
||||
preference.git_personal_access_token_encrypted = self.encryption.encrypt(
|
||||
sanitized_token
|
||||
preference.git_personal_access_token_encrypted = (
|
||||
self.encryption.encrypt(sanitized_token)
|
||||
)
|
||||
|
||||
preference.start_page = effective_start_page
|
||||
@@ -263,7 +302,9 @@ class ProfileService:
|
||||
preference.notify_on_fail = effective_notify_on_fail
|
||||
preference.updated_at = datetime.utcnow()
|
||||
|
||||
persisted_preference = self.auth_repository.save_user_dashboard_preference(preference)
|
||||
persisted_preference = self.auth_repository.save_user_dashboard_preference(
|
||||
preference
|
||||
)
|
||||
|
||||
logger.reason("[REASON] Preference persisted successfully")
|
||||
return ProfilePreferenceResponse(
|
||||
@@ -275,9 +316,11 @@ class ProfileService:
|
||||
),
|
||||
security=self._build_security_summary(current_user),
|
||||
)
|
||||
|
||||
# [/DEF:update_my_preference:Function]
|
||||
|
||||
# [DEF:lookup_superset_accounts:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Query Superset users in selected environment and project canonical account candidates.
|
||||
# @PRE: current_user is authenticated and environment_id exists.
|
||||
# @POST: Returns success payload or degraded payload with warning while preserving manual fallback.
|
||||
@@ -293,7 +336,9 @@ class ProfileService:
|
||||
environment = self._resolve_environment(request.environment_id)
|
||||
if environment is None:
|
||||
logger.explore("[EXPLORE] Lookup aborted: environment not found")
|
||||
raise EnvironmentNotFoundError(f"Environment '{request.environment_id}' not found")
|
||||
raise EnvironmentNotFoundError(
|
||||
f"Environment '{request.environment_id}' not found"
|
||||
)
|
||||
|
||||
sort_column = str(request.sort_column or "username").strip().lower()
|
||||
sort_order = str(request.sort_order or "desc").strip().lower()
|
||||
@@ -338,7 +383,9 @@ class ProfileService:
|
||||
items=items,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.explore(f"[EXPLORE] Lookup degraded due to upstream error: {exc}")
|
||||
logger.explore(
|
||||
f"[EXPLORE] Lookup degraded due to upstream error: {exc}"
|
||||
)
|
||||
return SupersetAccountLookupResponse(
|
||||
status="degraded",
|
||||
environment_id=request.environment_id,
|
||||
@@ -351,9 +398,11 @@ class ProfileService:
|
||||
),
|
||||
items=[],
|
||||
)
|
||||
|
||||
# [/DEF:lookup_superset_accounts:Function]
|
||||
|
||||
# [DEF:matches_dashboard_actor:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Apply trim+case-insensitive actor match across owners OR modified_by.
|
||||
# @PRE: bound_username can be empty; owners may contain mixed payload.
|
||||
# @POST: Returns True when normalized username matches owners or modified_by.
|
||||
@@ -375,9 +424,11 @@ class ProfileService:
|
||||
if modified_token and normalized_actor == modified_token:
|
||||
return True
|
||||
return False
|
||||
|
||||
# [/DEF:matches_dashboard_actor:Function]
|
||||
|
||||
# [DEF:_build_security_summary:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Build read-only security snapshot with role and permission badges.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returns deterministic security projection for profile UI.
|
||||
@@ -402,7 +453,9 @@ class ProfileService:
|
||||
normalized_resource = self._sanitize_text(resource)
|
||||
normalized_action = str(action or "").strip().upper()
|
||||
if normalized_resource and normalized_action:
|
||||
declared_permission_pairs.add((normalized_resource, normalized_action))
|
||||
declared_permission_pairs.add(
|
||||
(normalized_resource, normalized_action)
|
||||
)
|
||||
except Exception as discovery_error:
|
||||
logger.warning(
|
||||
"[ProfileService][EXPLORE] Failed to build declared permission catalog: %s",
|
||||
@@ -435,13 +488,17 @@ class ProfileService:
|
||||
roles=role_names,
|
||||
permissions=permission_states,
|
||||
)
|
||||
|
||||
# [/DEF:_build_security_summary:Function]
|
||||
|
||||
# [DEF:_collect_user_permission_pairs:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Collect effective permission tuples from current user's roles.
|
||||
# @PRE: current_user can include role/permission graph.
|
||||
# @POST: Returns unique normalized (resource, ACTION) tuples.
|
||||
def _collect_user_permission_pairs(self, current_user: User) -> Set[Tuple[str, str]]:
|
||||
def _collect_user_permission_pairs(
|
||||
self, current_user: User
|
||||
) -> Set[Tuple[str, str]]:
|
||||
collected: Set[Tuple[str, str]] = set()
|
||||
roles = getattr(current_user, "roles", []) or []
|
||||
for role in roles:
|
||||
@@ -452,9 +509,11 @@ class ProfileService:
|
||||
if resource and action:
|
||||
collected.add((resource, action))
|
||||
return collected
|
||||
|
||||
# [/DEF:_collect_user_permission_pairs:Function]
|
||||
|
||||
# [DEF:_format_permission_key:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Convert normalized permission pair to compact UI key.
|
||||
# @PRE: resource and action are normalized.
|
||||
# @POST: Returns user-facing badge key.
|
||||
@@ -464,9 +523,11 @@ class ProfileService:
|
||||
if normalized_action == "READ":
|
||||
return normalized_resource
|
||||
return f"{normalized_resource}:{normalized_action.lower()}"
|
||||
|
||||
# [/DEF:_format_permission_key:Function]
|
||||
|
||||
# [DEF:_to_preference_payload:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Map ORM preference row to API DTO with token metadata.
|
||||
# @PRE: preference row can contain nullable optional fields.
|
||||
# @POST: Returns normalized ProfilePreference object.
|
||||
@@ -516,13 +577,17 @@ class ProfileService:
|
||||
),
|
||||
telegram_id=self._sanitize_text(preference.telegram_id),
|
||||
email_address=self._sanitize_text(preference.email_address),
|
||||
notify_on_fail=bool(preference.notify_on_fail) if preference.notify_on_fail is not None else True,
|
||||
notify_on_fail=bool(preference.notify_on_fail)
|
||||
if preference.notify_on_fail is not None
|
||||
else True,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
# [/DEF:_to_preference_payload:Function]
|
||||
|
||||
# [DEF:_mask_secret_value:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Build a safe display value for sensitive secrets.
|
||||
# @PRE: secret may be None or plaintext.
|
||||
# @POST: Returns masked representation or None.
|
||||
@@ -533,9 +598,11 @@ class ProfileService:
|
||||
if len(sanitized_secret) <= 4:
|
||||
return "***"
|
||||
return f"{sanitized_secret[:2]}***{sanitized_secret[-2:]}"
|
||||
|
||||
# [/DEF:_mask_secret_value:Function]
|
||||
|
||||
# [DEF:_sanitize_text:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize optional text into trimmed form or None.
|
||||
# @PRE: value may be empty or None.
|
||||
# @POST: Returns trimmed value or None.
|
||||
@@ -544,9 +611,11 @@ class ProfileService:
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
# [/DEF:_sanitize_text:Function]
|
||||
|
||||
# [DEF:_sanitize_secret:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize secret input into trimmed form or None.
|
||||
# @PRE: value may be None or blank.
|
||||
# @POST: Returns trimmed secret or None.
|
||||
@@ -557,9 +626,11 @@ class ProfileService:
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
# [/DEF:_sanitize_secret:Function]
|
||||
|
||||
# [DEF:_normalize_start_page:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize supported start page aliases to canonical values.
|
||||
# @PRE: value may be None or alias.
|
||||
# @POST: Returns one of SUPPORTED_START_PAGES.
|
||||
@@ -570,9 +641,11 @@ class ProfileService:
|
||||
if normalized in SUPPORTED_START_PAGES:
|
||||
return normalized
|
||||
return "dashboards"
|
||||
|
||||
# [/DEF:_normalize_start_page:Function]
|
||||
|
||||
# [DEF:_normalize_density:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize supported density aliases to canonical values.
|
||||
# @PRE: value may be None or alias.
|
||||
# @POST: Returns one of SUPPORTED_DENSITIES.
|
||||
@@ -583,9 +656,11 @@ class ProfileService:
|
||||
if normalized in SUPPORTED_DENSITIES:
|
||||
return normalized
|
||||
return "comfortable"
|
||||
|
||||
# [/DEF:_normalize_density:Function]
|
||||
|
||||
# [DEF:_resolve_environment:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Resolve environment model from configured environments by id.
|
||||
# @PRE: environment_id is provided.
|
||||
# @POST: Returns environment object when found else None.
|
||||
@@ -595,17 +670,21 @@ class ProfileService:
|
||||
if str(getattr(env, "id", "")) == str(environment_id):
|
||||
return env
|
||||
return None
|
||||
|
||||
# [/DEF:_resolve_environment:Function]
|
||||
|
||||
# [DEF:_get_preference_row:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return persisted preference row for user or None.
|
||||
# @PRE: user_id is provided.
|
||||
# @POST: Returns matching row or None.
|
||||
def _get_preference_row(self, user_id: str) -> Optional[UserDashboardPreference]:
|
||||
return self.auth_repository.get_user_dashboard_preference(str(user_id))
|
||||
|
||||
# [/DEF:_get_preference_row:Function]
|
||||
|
||||
# [DEF:_get_or_create_preference_row:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return existing preference row or create new unsaved row.
|
||||
# @PRE: user_id is provided.
|
||||
# @POST: Returned row always contains user_id.
|
||||
@@ -614,9 +693,11 @@ class ProfileService:
|
||||
if existing is not None:
|
||||
return existing
|
||||
return UserDashboardPreference(user_id=str(user_id))
|
||||
|
||||
# [/DEF:_get_or_create_preference_row:Function]
|
||||
|
||||
# [DEF:_build_default_preference:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Build non-persisted default preference DTO for unconfigured users.
|
||||
# @PRE: user_id is provided.
|
||||
# @POST: Returns ProfilePreference with disabled toggle and empty username.
|
||||
@@ -641,9 +722,11 @@ class ProfileService:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
# [/DEF:_build_default_preference:Function]
|
||||
|
||||
# [DEF:_validate_update_payload:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Validate username/toggle constraints for preference mutation.
|
||||
# @PRE: payload is provided.
|
||||
# @POST: Returns validation errors list; empty list means valid.
|
||||
@@ -664,7 +747,9 @@ class ProfileService:
|
||||
"Username should not contain spaces. Please enter a valid Apache Superset username."
|
||||
)
|
||||
if show_only_my_dashboards and not sanitized_username:
|
||||
errors.append("Superset username is required when default filter is enabled.")
|
||||
errors.append(
|
||||
"Superset username is required when default filter is enabled."
|
||||
)
|
||||
|
||||
sanitized_git_email = self._sanitize_text(git_email)
|
||||
if sanitized_git_email:
|
||||
@@ -693,17 +778,21 @@ class ProfileService:
|
||||
errors.append("Notification email should be a valid email address.")
|
||||
|
||||
return errors
|
||||
|
||||
# [/DEF:_validate_update_payload:Function]
|
||||
|
||||
# [DEF:_sanitize_username:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize raw username into trimmed form or None for empty input.
|
||||
# @PRE: value can be empty or None.
|
||||
# @POST: Returns trimmed username or None.
|
||||
def _sanitize_username(self, value: Optional[str]) -> Optional[str]:
|
||||
return self._sanitize_text(value)
|
||||
|
||||
# [/DEF:_sanitize_username:Function]
|
||||
|
||||
# [DEF:_normalize_username:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Apply deterministic trim+lower normalization for actor matching.
|
||||
# @PRE: value can be empty or None.
|
||||
# @POST: Returns lowercase normalized token or None.
|
||||
@@ -712,9 +801,11 @@ class ProfileService:
|
||||
if sanitized is None:
|
||||
return None
|
||||
return sanitized.lower()
|
||||
|
||||
# [/DEF:_normalize_username:Function]
|
||||
|
||||
# [DEF:_normalize_owner_tokens:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize owners payload into deduplicated lower-cased tokens.
|
||||
# @PRE: owners can be iterable of scalars or dict-like values.
|
||||
# @POST: Returns list of unique normalized owner tokens.
|
||||
@@ -727,8 +818,12 @@ class ProfileService:
|
||||
if isinstance(owner, dict):
|
||||
first_name = self._sanitize_username(str(owner.get("first_name") or ""))
|
||||
last_name = self._sanitize_username(str(owner.get("last_name") or ""))
|
||||
full_name = " ".join(part for part in [first_name, last_name] if part).strip()
|
||||
snake_name = "_".join(part for part in [first_name, last_name] if part).strip("_")
|
||||
full_name = " ".join(
|
||||
part for part in [first_name, last_name] if part
|
||||
).strip()
|
||||
snake_name = "_".join(
|
||||
part for part in [first_name, last_name] if part
|
||||
).strip("_")
|
||||
owner_candidates = [
|
||||
owner.get("username"),
|
||||
owner.get("user_name"),
|
||||
@@ -748,7 +843,10 @@ class ProfileService:
|
||||
if token and token not in normalized:
|
||||
normalized.append(token)
|
||||
return normalized
|
||||
|
||||
# [/DEF:_normalize_owner_tokens:Function]
|
||||
|
||||
|
||||
# [/DEF:ProfileService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.profile_service:Module]
|
||||
# [/DEF:profile_service:Module]
|
||||
|
||||
Reference in New Issue
Block a user