fix: restore legacy training data hidden by new visibility schema (migrate-on-read)
Records written before the visibility field existed carry none; the new fail-closed authorization treated missing visibility as invalid, making every legacy group unlistable and unreadable. Add resolved_visibility(group) that derives effective visibility for legacy records only (owner present => private, absent => public), leaves explicit-malformed visibility fail-closed (None), and never derives demo/hidden. Apply it at every list, authorization, chat, and analytics boundary while keeping demo and hidden-preview paths raw and owner_user_id-based private isolation intact. No persisted data is rewritten. Backend full suite passes 517; frontend 26/26; production build passes.
This commit is contained in:
@@ -9,7 +9,7 @@ from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError, normalize_identifier
|
||||
from ..config import Config
|
||||
from ..services.groups import is_ready_group, is_valid_owner_visibility, validated_personas
|
||||
from ..services.groups import is_ready_group, is_valid_owner_visibility, resolved_visibility, validated_personas
|
||||
from ..storage.store import StoreError
|
||||
from .helpers import ApiError, current_user, is_valid_tenant_id, require_auth, require_roles
|
||||
|
||||
@@ -34,7 +34,7 @@ def _is_shared_group(group: object) -> bool:
|
||||
and is_valid_tenant_id(group.get("org_id"))
|
||||
and "owner_user_id" not in group
|
||||
and is_valid_owner_visibility(group)
|
||||
and group.get("visibility") in {"public", "hidden"}
|
||||
and resolved_visibility(group) in {"public", "hidden"}
|
||||
and is_ready_group(group)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..services.groups import (
|
||||
is_canonical_private_owner,
|
||||
is_ready_group,
|
||||
is_valid_owner_visibility,
|
||||
resolved_visibility,
|
||||
)
|
||||
from ..services.simulator import Simulator, _safe_roleplay_internal
|
||||
from ..services.store import PERSONA_CHANNELS
|
||||
@@ -400,7 +401,11 @@ def _get_ready_group(s, gid: str) -> dict:
|
||||
# demo tenant and demo-visible shared groups. Tenant admins retain the
|
||||
# historical hidden-group preview path, while ordinary users can access
|
||||
# only public shared groups (or their own private group).
|
||||
visibility = group_visibility(group.get("visibility"))
|
||||
visibility = (
|
||||
group_visibility(group.get("visibility"))
|
||||
if actor.get("role") == "demo"
|
||||
else resolved_visibility(group)
|
||||
)
|
||||
if actor.get("role") != "super_admin" and visibility is None:
|
||||
raise ApiError("permission denied", 403)
|
||||
if actor.get("role") == "demo":
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..services.groups import (
|
||||
is_canonical_private_owner,
|
||||
is_ready_group,
|
||||
is_valid_owner_visibility,
|
||||
resolved_visibility,
|
||||
safe_group_input,
|
||||
)
|
||||
from ..services.store import (
|
||||
@@ -217,7 +218,7 @@ def _authorize_group(group: dict) -> None:
|
||||
if not isinstance(group, dict):
|
||||
raise ApiError("group not found", 404)
|
||||
actor = current_user()
|
||||
if group_visibility(group.get("visibility")) is None:
|
||||
if resolved_visibility(group) is None:
|
||||
raise ApiError("group not found", 404)
|
||||
owner_marker_present = "owner_user_id" in group
|
||||
owner = group.get("owner_user_id")
|
||||
@@ -248,7 +249,7 @@ def _authorize_group(group: dict) -> None:
|
||||
if not is_valid_owner_visibility(group):
|
||||
raise ApiError("permission denied", 403)
|
||||
if actor.get("role") == "admin" and (
|
||||
owner_marker_present or group_visibility(group.get("visibility")) not in {"public", "hidden"}
|
||||
owner_marker_present or resolved_visibility(group) not in {"public", "hidden"}
|
||||
):
|
||||
raise ApiError("permission denied", 403)
|
||||
if owner_marker_present and (
|
||||
@@ -272,8 +273,9 @@ def _get_owned_group(s, gid: str, *, require_ready: bool = True) -> dict:
|
||||
if current_user().get("role") in {"user", "demo"}:
|
||||
# Trainees may read only ready groups. Normal trainees see public shared
|
||||
# groups and their own private groups; demos see only demo-visible groups.
|
||||
allowed_visibility = "demo" if current_user().get("role") == "demo" else "public"
|
||||
visibility = group_visibility(group.get("visibility"))
|
||||
is_demo = current_user().get("role") == "demo"
|
||||
allowed_visibility = "demo" if is_demo else "public"
|
||||
visibility = resolved_visibility(group) if not is_demo else group_visibility(group.get("visibility"))
|
||||
if visibility is None:
|
||||
raise ApiError("permission denied", 403)
|
||||
if "owner_user_id" not in group and visibility != allowed_visibility:
|
||||
@@ -592,7 +594,7 @@ def list_groups():
|
||||
"id": g.get("id"),
|
||||
"title": g.get("title", ""),
|
||||
"status": g.get("status", "draft"),
|
||||
"visibility": g.get("visibility", "public"),
|
||||
"visibility": resolved_visibility(g) or "public",
|
||||
"is_owned": g.get("is_owned") is True,
|
||||
"channel": group_input.get("channel"),
|
||||
"org_id": g.get("org_id"),
|
||||
|
||||
@@ -34,6 +34,29 @@ def group_visibility(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value in GROUP_VISIBILITIES else None
|
||||
|
||||
|
||||
def resolved_visibility(group: object) -> str | None:
|
||||
"""Return the effective visibility of a persisted group for authorization.
|
||||
|
||||
Records written before the visibility field existed carry no ``visibility``.
|
||||
The old schema encoded sharing entirely through ``owner_user_id``: a record
|
||||
with an owner was the owner's private group, and a record without one was a
|
||||
shared public group. For such legacy records (and only them) we derive the
|
||||
effective visibility from that historical contract so existing training data
|
||||
remains listable and accessible without rewriting any persisted data.
|
||||
|
||||
A record that carries an explicit but malformed ``visibility`` stays
|
||||
fail-closed (returns None) like the raw ``group_visibility``. demo/hidden
|
||||
are never derived from legacy records.
|
||||
"""
|
||||
if not isinstance(group, dict):
|
||||
return None
|
||||
if "visibility" in group:
|
||||
return group_visibility(group.get("visibility"))
|
||||
if "owner_user_id" in group:
|
||||
return "private"
|
||||
return "public"
|
||||
|
||||
|
||||
def is_valid_owner_visibility(group: object) -> bool:
|
||||
"""Return whether the persisted owner/visibility pair is fail-closed.
|
||||
|
||||
@@ -43,7 +66,7 @@ def is_valid_owner_visibility(group: object) -> bool:
|
||||
"""
|
||||
if not isinstance(group, dict):
|
||||
return False
|
||||
visibility = group_visibility(group.get("visibility"))
|
||||
visibility = resolved_visibility(group)
|
||||
if "owner_user_id" in group:
|
||||
owner = group.get("owner_user_id")
|
||||
return (
|
||||
@@ -411,13 +434,13 @@ class GroupStore:
|
||||
is_valid_owner_visibility(g)
|
||||
and "owner_user_id" in g
|
||||
and g.get("owner_user_id") == user_id
|
||||
and group_visibility(g.get("visibility")) == "private"
|
||||
and resolved_visibility(g) == "private"
|
||||
)
|
||||
or (
|
||||
is_valid_owner_visibility(g)
|
||||
and "owner_user_id" not in g
|
||||
and is_ready_group(g)
|
||||
and group_visibility(g.get("visibility")) == "public"
|
||||
and resolved_visibility(g) == "public"
|
||||
)
|
||||
]
|
||||
return [
|
||||
@@ -450,7 +473,7 @@ class GroupStore:
|
||||
if (
|
||||
is_valid_owner_visibility(g)
|
||||
and "owner_user_id" not in g
|
||||
and group_visibility(g.get("visibility")) in {"public", "hidden"}
|
||||
and resolved_visibility(g) in {"public", "hidden"}
|
||||
)
|
||||
]
|
||||
owned = [
|
||||
|
||||
Reference in New Issue
Block a user