feat(ip): protect persona 'formula' — secret fields only super_admin sees/edits

IP protection so casual copying yields inferior results:
- SECRET_PERSONA_FIELDS (pains/objections/negotiation_levers/opener/tolerance +
  pain rootCause/resolutionConditions): only super_admin can view/edit them.
- list_personas/get_persona/update_persona/get_group strip these for role=admin (and
  hide sales_kit + pain-fit report from admins too).
- update_persona rejects admin attempts to set secret fields (403).
- PersonaForm hides the 'การขาย' recipe section for non-super-admin (shows locked note);
  auth.isSuperAdmin getter added.
Rebuilt dist. Added test_ip_protection.
This commit is contained in:
Macky
2026-08-09 07:22:50 +07:00
parent a04bc2add8
commit e1d61e1e1e
29 changed files with 156 additions and 39 deletions

View File

@@ -14,6 +14,29 @@ from .helpers import ApiError, current_user, require_auth, require_roles
groups_bp = Blueprint("groups", __name__)
# Fields that encode the "formula"/process of a persona. Only super_admin may see/edit
# them; admins get the persona but NOT these — so a casual copy yields inferior results.
SECRET_PERSONA_FIELDS = {
"pains", "objections", "negotiation_levers", "opener",
"rootCause", "resolutionConditions", "tolerance",
}
def strip_secret_fields(persona: dict) -> dict:
"""Return a copy of a persona with secret/process fields removed."""
out = dict(persona)
for f in SECRET_PERSONA_FIELDS:
out.pop(f, None)
pains = out.get("pains")
if isinstance(pains, list):
cleaned = []
for p in pains:
if isinstance(p, dict):
p = {k: v for k, v in p.items() if k not in ("rootCause", "resolutionConditions")}
cleaned.append(p)
out["pains"] = cleaned
return out
_ANALYZE_LOCKS: dict[str, threading.Lock] = {}
_ANALYZE_GUARD = threading.Lock()
@@ -203,6 +226,12 @@ def get_group(gid: str):
view["personas"] = [revealable_view(p) for p in group.get("personas", [])]
view["sales_kit"] = None
view["report"] = None
elif actor.get("role") != "super_admin":
# admin: see group, but not secret/process persona fields nor the sales-kit/report
# (pain-fit analysis is the IP to protect).
view["personas"] = [strip_secret_fields(p) for p in group.get("personas", [])]
view["sales_kit"] = None
view["report"] = None
return jsonify({"group": view})
@@ -216,8 +245,11 @@ def list_personas(gid: str):
if group.get("status") != "ready":
raise ApiError("group not ready", 403)
personas = [revealable_view(p) for p in group.get("personas", [])]
else:
elif actor.get("role") == "super_admin":
personas = group.get("personas", [])
else:
# admin: see persona but not the secret/process fields (IP protection)
personas = [strip_secret_fields(p) for p in group.get("personas", [])]
# attach per-user status (won/lost/not-tried) for trainees
if actor.get("role") == "user":
sess = _stores().get("session_store")
@@ -244,7 +276,10 @@ def get_persona(gid: str, pid: str):
ensure = ensure_persona_shape(p)
if actor.get("role") == "user":
return jsonify({"persona": revealable_view(ensure)})
return jsonify({"persona": ensure})
if actor.get("role") == "super_admin":
return jsonify({"persona": ensure})
# admin: hidden secret/process fields (IP protection)
return jsonify({"persona": strip_secret_fields(ensure)})
@groups_bp.put("/<gid>/personas/<pid>")
@@ -254,13 +289,22 @@ def update_persona(gid: str, pid: str):
s = _stores()
_get_owned_group(s, gid)
data = request.get_json(silent=True) or {}
actor = current_user()
# IP protection: only super_admin may set/alter secret formula fields.
if actor.get("role") != "super_admin":
for f in SECRET_PERSONA_FIELDS:
if f in data:
raise ApiError(f"field '{f}' is locked (super_admin only)", 403)
try:
updated = s["groups"].update_persona(gid, pid, data)
except ValueError as exc:
raise ApiError(str(exc), 404)
return jsonify({"persona": ensure_persona_shape(updated["personas"][
full = ensure_persona_shape(updated["personas"][
next(i for i, p in enumerate(updated["personas"]) if p["id"] == pid)
])})
])
if actor.get("role") == "super_admin":
return jsonify({"persona": full})
return jsonify({"persona": strip_secret_fields(full)})
@groups_bp.post("/<gid>/reanalyze")