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:
Macky
2026-08-25 08:59:22 +07:00
parent 3c22d88bcd
commit 6a2e6a326f
7 changed files with 222 additions and 12 deletions

View File

@@ -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)
)

View File

@@ -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":

View File

@@ -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"),

View File

@@ -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 = [