Files
bisheng/scripts/verify_f006_migration.py
T
LineWalker cc70a33086 Fix F006 Step 4 case sensitivity + add end-to-end verification script
Seeding the live 114 MySQL with realistic mock data and running the full
F006 migration surfaced a second latent bug: SCM_ROLE_MAPPING and
SCM_TYPE_MAPPING use lowercase keys (creator/admin/member, space/channel),
but production MySQL stores the columns as ENUM('SPACE','CHANNEL') and
ENUM('CREATOR','ADMIN','MEMBER') — uppercase. Step 4 was silently dropping
every real space/channel member row (19 rows on 114) because .get(role)
returned None and the code fell into the "skip unknown role" branch.

Test fixtures had masked the issue: the SQLite schema uses VARCHAR(16)
with lowercase test data, so .get(role.lower_value) happened to work in
tests but never in production.

Fix:
- migrate_rbac_to_rebac.py step4_space_channel_members: normalize role
  and biz_type to lower() before mapping lookup
- migrate_rbac_to_rebac.py verify_all: same normalization on scm_set
  (so old-system membership comparison stays case-insensitive)
- test_f006_permission_migration.py: new TestStep4.test_uppercase_enum_values
  guarding against regressions by inserting the exact values production's
  enum would store

Also adds scripts/verify_f006_migration.py — a reusable end-to-end
verification harness that seeds all 9 legacy tables with diverse mock
data (all 10 AccessType values, 6 SCM role/status combinations, 3-level
folder hierarchy, skip-boundary cases for type=99 / role_id=1 /
PENDING / REJECTED / is_delete=1 / flow_type=15 / non-numeric paths),
runs the migration, invokes --verify mode, reconciles 47 must_have and
17 must_not_have tuples against OpenFGA, and cleans up.

Validation on 114 after the fixes:
  Step 1 super_admin:  2   Step 4 SCM:      25  (mock 6 + prod 19)
  Step 2 user_group:  13   Step 5 owners:   96
  Step 3 role_access: 17   Step 6 folder:   14
  Total tuples: 167     --verify regression: 0
  must_have 47/47     must_not_have 17/17     OVERALL: PASS
2026-04-18 00:06:42 +08:00

677 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""F006 RBAC→ReBAC migration end-to-end verification.
Seeds the 9 legacy MySQL tables with diverse mock data covering every path
in the migration (all 10 AccessType values, super-admin, SCM roles, flow
types, is_delete filter, folder hierarchy). Runs the full migration, then
uses both --verify mode and direct OpenFGA reads to confirm that every
step produced the expected tuples and that boundary cases (WEB_MENU,
admin-role access, PENDING members, is_delete=1, non-numeric paths) were
correctly skipped.
Usage (on any host with the backend venv + live MySQL/OpenFGA):
cd src/backend
.venv/bin/python ../../scripts/verify_f006_migration.py
# Keep mock data after the run for UI inspection:
.venv/bin/python ../../scripts/verify_f006_migration.py --skip-cleanup
Exit code 0 = PASS, 1 = FAIL. Script is idempotent: it refuses to start if
the 90000-ID range already holds mock data, and on failure it preserves
both MySQL rows and OpenFGA tuples for inspection.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
# Allow running from repo root without setting PYTHONPATH.
_BACKEND = Path(__file__).resolve().parent.parent / 'src' / 'backend'
if _BACKEND.is_dir():
sys.path.insert(0, str(_BACKEND))
from sqlalchemy import text
# ── Mock data ID ranges (segregated to avoid collisions) ───────────
MOCK_ID_LO, MOCK_ID_HI = 90000, 99999
MOCK_USER_IDS = list(range(90001, 90011)) # 10 users
MOCK_ROLE_IDS = list(range(90001, 90005)) # 4 non-admin roles
MOCK_GROUP_IDS = [90001, 90002]
MOCK_KB_IDS = [90001, 90002, 90003, 90004]
MOCK_TOOL_IDS = [90001, 90002, 90003, 90004]
MOCK_FLOW_IDS = [f'mock-flow-{i}' for i in range(90001, 90007)]
# 36-char UUID-shaped IDs to satisfy CHAR(36) columns on channel /
# space_channel_member.
MOCK_CHANNEL_IDS = ['mock-channel-90001-00000000000000000', # 36 chars
'mock-channel-90002-00000000000000000']
MOCK_KF_IDS = list(range(90001, 90007))
MOCK_SCM_SPACE_IDS = ['mock-scm-sp-90001-000000000000000000', # 36 chars
'mock-scm-sp-90002-000000000000000000']
MOCK_SCM_CHANNEL_IDS = ['mock-scm-ch-90001-000000000000000000']
# Admin role id (matches bisheng default data)
ADMIN_ROLE_ID = 1
# ── Expected outcomes ─────────────────────────────────────────────
@dataclass
class ExpectedSet:
"""Tuples the migration MUST produce from the mock data."""
must_have: set[tuple[str, str, str]] = field(default_factory=set)
must_not_have: set[tuple[str, str, str]] = field(default_factory=set)
# ── Helpers ───────────────────────────────────────────────────────
def _exec(session, sql: str, params: dict | None = None):
return session.execute(text(sql), params or {})
def _print_banner(title: str):
print()
print('=' * 72)
print(f' {title}')
print('=' * 72)
def _print_row(label: str, value, ok: bool | None = None):
mark = '' if ok is None else (' ✓' if ok else ' ✗')
print(f' {label:<40s}{str(value):>20s}{mark}')
# ── Stage 1: Precheck ─────────────────────────────────────────────
def precheck(session) -> None:
"""Abort if mock ID range is already occupied."""
_print_banner('Stage 1 — Precheck')
checks = [
('user', f'user_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('role', f'id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('userrole', f'user_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('usergroup', f'user_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('roleaccess', f'role_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('space_channel_member', "business_id LIKE 'mock-scm-%'"),
('flow', "id LIKE 'mock-flow-%'"),
('knowledge', f'id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('t_gpts_tools', f'id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
('channel', "id LIKE 'mock-channel-%'"),
('knowledgefile', f'id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}'),
]
dirty = []
for table, where in checks:
cnt = _exec(session, f'SELECT COUNT(*) FROM `{table}` WHERE {where}').scalar()
_print_row(f'{table} mock rows', cnt, ok=(cnt == 0))
if cnt:
dirty.append((table, cnt))
if dirty:
raise SystemExit(f'Precheck failed: {dirty}. Run with --cleanup-only or clear manually.')
print(' → ID range clean, safe to seed.')
# ── Stage 2: Seed ─────────────────────────────────────────────────
def seed(session) -> ExpectedSet:
"""Insert mock rows and return expected tuple set."""
_print_banner('Stage 2 — Seed mock data')
# --- users + roles + groups ---
for uid in MOCK_USER_IDS:
_exec(session,
"INSERT INTO `user` (user_id, user_name, password, `delete`) "
"VALUES (:uid, :name, :pwd, 0)",
{'uid': uid, 'name': f'mock_user_{uid}', 'pwd': 'x'})
for rid in MOCK_ROLE_IDS:
_exec(session,
"INSERT INTO `role` (id, role_name, tenant_id, role_type) "
"VALUES (:rid, :name, 1, 'tenant')",
{'rid': rid, 'name': f'mock_role_{rid}'})
for gid in MOCK_GROUP_IDS:
_exec(session,
"INSERT INTO `group` (id, group_name, tenant_id, visibility) "
"VALUES (:gid, :name, 1, 'public')",
{'gid': gid, 'name': f'mock_group_{gid}'})
# --- userrole ---
# 90001-90003 → role 90001 (fan-out for Step 3)
# 90004 → role 90002
# 90005 → role 90003
# 90006 → role 90004
# 90007 → ADMIN (Step 1 super-admin)
# 90008, 90009, 90010 → no role (isolated for Step 4/5 tests)
user_role_pairs = [
(90001, 90001), (90002, 90001), (90003, 90001),
(90004, 90002),
(90005, 90003),
(90006, 90004),
(90007, ADMIN_ROLE_ID),
]
for uid, rid in user_role_pairs:
_exec(session,
"INSERT INTO userrole (user_id, role_id, tenant_id) "
"VALUES (:uid, :rid, 1)",
{'uid': uid, 'rid': rid})
# --- usergroup ---
# group 90001: 90002 admin + 90003 member
# group 90002: 90004 admin + 90005 member + 90006 member
user_group_rows = [
(90002, 90001, 1),
(90003, 90001, 0),
(90004, 90002, 1),
(90005, 90002, 0),
(90006, 90002, 0),
]
for uid, gid, is_admin in user_group_rows:
_exec(session,
"INSERT INTO usergroup (user_id, group_id, is_group_admin, tenant_id) "
"VALUES (:uid, :gid, :admin, 1)",
{'uid': uid, 'gid': gid, 'admin': is_admin})
# --- roleaccess ---
# Cover all 10 non-menu AccessType values × plus skip boundaries.
# Resources use MOCK ids that match type semantics.
role_access_rows = [
# role 90001 on kb 90001: both viewer(1) + editor(3) → dedup to editor
(90001, 'kb-90001', 1),
(90001, 'kb-90001', 3),
# AccessType 5/6: assistant
(90001, 'mock-flow-90001', 5),
(90002, 'mock-flow-90002', 6),
# AccessType 7/8: tool
(90002, 'tool-90001', 7),
(90003, 'tool-90002', 8),
# AccessType 9/10: workflow
(90001, 'mock-flow-90003', 9),
(90002, 'mock-flow-90004', 10),
# AccessType 11/12: dashboard (resources don't exist; still must write tuple)
(90003, 'dash-90001', 11),
(90004, 'dash-90002', 12),
# Cross-resource coverage for role 90004 — tool editor + kb viewer
(90004, 'tool-90003', 8),
(90004, 'kb-90002', 1),
# SKIP boundary: type=99 (WEB_MENU) — must NOT produce tuple
(90001, 'menu-settings', 99),
# SKIP boundary: role_id=1 (admin) — must NOT produce tuple from Step 3
(ADMIN_ROLE_ID, 'kb-admin-leak', 1),
]
for rid, tid, atype in role_access_rows:
_exec(session,
"INSERT INTO roleaccess (role_id, third_id, type, tenant_id) "
"VALUES (:rid, :tid, :atype, 1)",
{'rid': rid, 'tid': tid, 'atype': atype})
# --- space_channel_member ---
# Enum values are uppercase in DB. Migration uses lowercase keys in its
# mapping; if it doesn't lower() before lookup this stage will silently
# skip. The script captures the discrepancy in the final report.
scm_rows = [
# space 90001: creator + admin + member, all ACTIVE
(MOCK_SCM_SPACE_IDS[0], 'SPACE', 90001, 'CREATOR', 'ACTIVE'),
(MOCK_SCM_SPACE_IDS[0], 'SPACE', 90002, 'ADMIN', 'ACTIVE'),
(MOCK_SCM_SPACE_IDS[0], 'SPACE', 90003, 'MEMBER', 'ACTIVE'),
# channel 90001: creator + member
(MOCK_SCM_CHANNEL_IDS[0], 'CHANNEL', 90004, 'CREATOR', 'ACTIVE'),
(MOCK_SCM_CHANNEL_IDS[0], 'CHANNEL', 90005, 'MEMBER', 'ACTIVE'),
# space 90002: PENDING + REJECTED (skip) + ACTIVE member
(MOCK_SCM_SPACE_IDS[1], 'SPACE', 90006, 'CREATOR', 'PENDING'),
(MOCK_SCM_SPACE_IDS[1], 'SPACE', 90007, 'ADMIN', 'REJECTED'),
(MOCK_SCM_SPACE_IDS[1], 'SPACE', 90008, 'MEMBER', 'ACTIVE'),
]
for biz_id, biz_type, uid, role, status in scm_rows:
_exec(session,
"INSERT INTO space_channel_member "
"(business_id, business_type, user_id, user_role, status, is_pinned) "
"VALUES (:bid, :btype, :uid, :role, :status, 0)",
{'bid': biz_id, 'btype': biz_type, 'uid': uid,
'role': role, 'status': status})
# --- flow ---
# flow_type 5 = assistant, 10 = workflow (migrate); 15 = workstation (skip)
flow_rows = [
(MOCK_FLOW_IDS[0], 90001, 5), # assistant owner 90001
(MOCK_FLOW_IDS[1], 90002, 5), # assistant owner 90002
(MOCK_FLOW_IDS[2], 90001, 10), # workflow owner 90001
(MOCK_FLOW_IDS[3], 90003, 10), # workflow owner 90003
(MOCK_FLOW_IDS[4], 90004, 15), # SKIP: workstation
(MOCK_FLOW_IDS[5], 90005, 15), # SKIP: workstation
]
for fid, uid, ft in flow_rows:
_exec(session,
"INSERT INTO flow (id, name, user_id, flow_type, tenant_id) "
"VALUES (:fid, :name, :uid, :ft, 1)",
{'fid': fid, 'name': f'mock_flow_{fid}', 'uid': uid, 'ft': ft})
# --- knowledge ---
knowledge_rows = [(i, MOCK_USER_IDS[idx])
for idx, i in enumerate(MOCK_KB_IDS)]
for kid, uid in knowledge_rows:
_exec(session,
"INSERT INTO knowledge (id, user_id, name, type, is_released, "
"auth_type, tenant_id) VALUES (:kid, :uid, :name, 0, 1, "
"'PRIVATE', 1)",
{'kid': kid, 'uid': uid, 'name': f'mock_kb_{kid}'})
# --- t_gpts_tools ---
tool_rows = [
(MOCK_TOOL_IDS[0], 90001, 0), # active
(MOCK_TOOL_IDS[1], 90002, 0), # active
(MOCK_TOOL_IDS[2], 90003, 0), # active
(MOCK_TOOL_IDS[3], 90004, 1), # SKIP: deleted
]
for tid, uid, is_del in tool_rows:
_exec(session,
"INSERT INTO t_gpts_tools (id, user_id, type, is_preset, "
"is_delete, tenant_id) VALUES (:tid, :uid, 1, 0, :d, 1)",
{'tid': tid, 'uid': uid, 'd': is_del})
# --- channel ---
for idx, cid in enumerate(MOCK_CHANNEL_IDS):
_exec(session,
"INSERT INTO channel (id, name, source_list, filter_rules, "
"user_id, is_pinned, is_released, tenant_id) "
"VALUES (:cid, :name, '[]', '{}', :uid, 0, 1, 1)",
{'cid': cid, 'name': f'mock_channel_{idx}',
'uid': MOCK_USER_IDS[idx]})
# --- knowledgefile — folder hierarchy under kb 90003 ---
# knowledge_space:90003
# └─ folder:90001 (file_level_path="")
# └─ folder:90002 (file_level_path="90001")
# └─ file:90003 (file_level_path="90001/90002")
# └─ file:90004 (file_level_path="") # root-level file
# └─ folder:90005 (file_level_path="")
# └─ file:90006 (file_level_path="abc") # SKIP: non-numeric
kf_rows = [
(90001, 90003, 0, ''), # folder at root
(90002, 90003, 0, '90001'), # folder under 90001
(90003, 90003, 1, '90001/90002'), # file under 90002
(90004, 90003, 1, ''), # file at root
(90005, 90003, 0, ''), # isolated folder
(90006, 90003, 1, 'abc'), # SKIP: non-numeric path
]
for kid, kbid, ftype, path in kf_rows:
_exec(session,
"INSERT INTO knowledgefile (id, knowledge_id, file_name, "
"file_type, file_level_path, tenant_id) "
"VALUES (:id, :kb, :name, :ft, :path, 1)",
{'id': kid, 'kb': kbid, 'name': f'mock_kf_{kid}',
'ft': ftype, 'path': path})
session.commit()
print(' → All mock rows inserted (commit OK).')
# Build expected tuple set from the seed above.
expected = ExpectedSet()
# Step 1: super_admin for user 90007
expected.must_have.add(('user:90007', 'super_admin', 'system:global'))
# Step 2: usergroup
expected.must_have.update({
('user:90002', 'admin', 'user_group:90001'),
('user:90003', 'member', 'user_group:90001'),
('user:90004', 'admin', 'user_group:90002'),
('user:90005', 'member', 'user_group:90002'),
('user:90006', 'member', 'user_group:90002'),
})
# Step 3: role_access expansion × userrole fan-out
# role 90001 holders: 90001, 90002, 90003
# role 90002 holders: 90004
# role 90003 holders: 90005
# role 90004 holders: 90006
# Expanded (before dedup): 10 role_access rows × holders
for uid in (90001, 90002, 90003):
# kb-90001: viewer+editor → editor (dedup)
expected.must_have.add((f'user:{uid}', 'editor', 'knowledge_space:kb-90001'))
expected.must_not_have.add((f'user:{uid}', 'viewer', 'knowledge_space:kb-90001'))
# assistant viewer
expected.must_have.add((f'user:{uid}', 'viewer', 'assistant:mock-flow-90001'))
# workflow viewer
expected.must_have.add((f'user:{uid}', 'viewer', 'workflow:mock-flow-90003'))
for uid in (90002, 90003):
pass # handled above
# role 90002 holder: 90004
expected.must_have.update({
('user:90004', 'editor', 'assistant:mock-flow-90002'), # type 6
('user:90004', 'viewer', 'tool:tool-90001'), # type 7
('user:90004', 'editor', 'workflow:mock-flow-90004'), # type 10
})
for uid in (90001, 90002, 90003):
# role 90001 also held tool-90001 (type 7) via... no, tool-90001 is role 90002
pass
# role 90003 holder: 90005
expected.must_have.update({
('user:90005', 'editor', 'tool:tool-90002'), # type 8
('user:90005', 'viewer', 'dashboard:dash-90001'), # type 11
})
# role 90004 holder: 90006
expected.must_have.update({
('user:90006', 'editor', 'dashboard:dash-90002'), # type 12
('user:90006', 'editor', 'tool:tool-90003'), # type 8
('user:90006', 'viewer', 'knowledge_space:kb-90002'), # type 1
})
# Role 90001 viewer on mock-flow-90001 as assistant (type 5)
# already captured above for uids 90001/90002/90003
# Boundaries:
# type 99 (menu-settings) → no tuple for any user
expected.must_not_have.update({
(f'user:{uid}', r, f'knowledge_space:menu-settings')
for uid in (90001, 90002, 90003)
for r in ('viewer', 'editor')
})
# role_id=1 row (admin) → Step 3 skips, no tuple on kb-admin-leak
expected.must_not_have.update({
(f'user:{uid}', r, 'knowledge_space:kb-admin-leak')
for uid in (90007,) # user bound to admin role
for r in ('viewer', 'editor')
})
# Step 4: space_channel_member ACTIVE only
# NOTE: DB enum is UPPERCASE; migration mapping is lowercase.
# These "must_have" are the intent if mapping handles casing;
# if it doesn't, report will show them as FAIL → new bug discovered.
space_id = MOCK_SCM_SPACE_IDS[0]
channel_id = MOCK_SCM_CHANNEL_IDS[0]
expected.must_have.update({
('user:90001', 'owner', f'knowledge_space:{space_id}'),
('user:90002', 'manager', f'knowledge_space:{space_id}'),
('user:90003', 'viewer', f'knowledge_space:{space_id}'),
('user:90004', 'owner', f'channel:{channel_id}'),
('user:90005', 'viewer', f'channel:{channel_id}'),
# space-90002 ACTIVE member 90008
('user:90008', 'viewer', f'knowledge_space:{MOCK_SCM_SPACE_IDS[1]}'),
})
# PENDING/REJECTED must not produce tuples
expected.must_not_have.update({
('user:90006', 'owner', f'knowledge_space:{MOCK_SCM_SPACE_IDS[1]}'),
('user:90007', 'manager', f'knowledge_space:{MOCK_SCM_SPACE_IDS[1]}'),
})
# Step 5: resource owners
for idx, kid in enumerate(MOCK_KB_IDS):
expected.must_have.add((f'user:{MOCK_USER_IDS[idx]}', 'owner',
f'knowledge_space:{kid}'))
expected.must_have.update({
('user:90001', 'owner', f'assistant:{MOCK_FLOW_IDS[0]}'),
('user:90002', 'owner', f'assistant:{MOCK_FLOW_IDS[1]}'),
('user:90001', 'owner', f'workflow:{MOCK_FLOW_IDS[2]}'),
('user:90003', 'owner', f'workflow:{MOCK_FLOW_IDS[3]}'),
# Tools (is_delete=0 only)
('user:90001', 'owner', f'tool:{MOCK_TOOL_IDS[0]}'),
('user:90002', 'owner', f'tool:{MOCK_TOOL_IDS[1]}'),
('user:90003', 'owner', f'tool:{MOCK_TOOL_IDS[2]}'),
# Channels
('user:90001', 'owner', f'channel:{MOCK_CHANNEL_IDS[0]}'),
('user:90002', 'owner', f'channel:{MOCK_CHANNEL_IDS[1]}'),
})
# is_delete=1 tool + flow_type=15 must not have owner tuples
expected.must_not_have.update({
('user:90004', 'owner', f'tool:{MOCK_TOOL_IDS[3]}'),
('user:90004', 'owner', f'assistant:{MOCK_FLOW_IDS[4]}'),
('user:90005', 'owner', f'workflow:{MOCK_FLOW_IDS[5]}'),
})
# Step 6: folder hierarchy
expected.must_have.update({
# root-level folder + file + isolated folder
('knowledge_space:90003', 'parent', 'folder:90001'),
('knowledge_space:90003', 'parent', 'knowledge_file:90004'),
('knowledge_space:90003', 'parent', 'folder:90005'),
# nested
('folder:90001', 'parent', 'folder:90002'),
('folder:90002', 'parent', 'knowledge_file:90003'),
})
# Non-numeric path (kf 90006) must not produce any parent tuple
expected.must_not_have.add(('knowledge_space:90003', 'parent',
'knowledge_file:90006'))
return expected
# ── Stage 3: Reset checkpoint + invoke migration ──────────────────
async def reset_and_migrate() -> 'MigrationStats':
"""Delete checkpoint file then run the migrator in-process."""
from bisheng.permission.migration.migrate_rbac_to_rebac import (
RBACToReBACMigrator, _CHECKPOINT_FILENAME,
)
_print_banner('Stage 3 — Reset checkpoint + execute migration')
# Locate checkpoint file next to the migration module.
module_dir = Path(sys.modules[
'bisheng.permission.migration.migrate_rbac_to_rebac'
].__file__).parent
ckpt = module_dir / _CHECKPOINT_FILENAME
if ckpt.exists():
ckpt.unlink()
print(f' → Removed stale checkpoint: {ckpt}')
else:
print(' → No existing checkpoint.')
migrator = RBACToReBACMigrator(dry_run=False)
stats = await migrator.run()
print(f' → Migration completed; MigrationStats.total = {stats.total}')
return stats
# ── Stage 4: --verify mode ────────────────────────────────────────
async def run_verify() -> 'VerifyReport':
from bisheng.permission.migration.migrate_rbac_to_rebac import (
RBACToReBACMigrator,
)
_print_banner('Stage 4 — Built-in --verify (old vs new permission parity)')
migrator = RBACToReBACMigrator(dry_run=True, verify_only=True)
report = await migrator.run()
_print_row('checks total', report.total)
_print_row('match', report.match, ok=(report.match == report.total))
_print_row('regression (critical)', report.regression,
ok=(report.regression == 0))
_print_row('expansion', report.expansion)
return report
# ── Stage 5: Reconcile FGA tuples against expected set ────────────
async def reconcile(expected: ExpectedSet) -> tuple[int, int]:
"""Query OpenFGA for each expected tuple; return (missing, leaked)."""
from bisheng.core.openfga.manager import get_fga_client
_print_banner('Stage 5 — Reconcile expected vs actual OpenFGA tuples')
fga = get_fga_client()
if fga is None:
raise SystemExit('OpenFGA client not initialized.')
missing = 0
for user, relation, obj in sorted(expected.must_have):
# read_tuples with all three fields is effectively a precise existence check
tuples = await fga.read_tuples(user=user, relation=relation, object=obj)
if tuples:
continue
missing += 1
print(f' ✗ MISSING: {user} {relation} {obj}')
leaked = 0
for user, relation, obj in sorted(expected.must_not_have):
tuples = await fga.read_tuples(user=user, relation=relation, object=obj)
if not tuples:
continue
leaked += 1
print(f' ✗ LEAKED: {user} {relation} {obj}')
_print_row('must_have present',
f'{len(expected.must_have) - missing}/{len(expected.must_have)}',
ok=(missing == 0))
_print_row('must_not_have absent',
f'{len(expected.must_not_have) - leaked}/{len(expected.must_not_have)}',
ok=(leaked == 0))
return missing, leaked
# ── Stage 6: Cleanup ──────────────────────────────────────────────
async def cleanup(session) -> None:
from bisheng.core.openfga.manager import get_fga_client
_print_banner('Stage 6 — Cleanup mock data')
# MySQL rows (order matters only if there are FKs; these tables don't enforce)
sql_deletes = [
("knowledgefile", f"id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("channel", "id LIKE 'mock-channel-%'"),
("t_gpts_tools", f"id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("knowledge", f"id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("flow", "id LIKE 'mock-flow-%'"),
("space_channel_member", "business_id LIKE 'mock-scm-%'"),
("roleaccess", f"(role_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}) "
f"OR (role_id = {ADMIN_ROLE_ID} AND third_id = 'kb-admin-leak') "
f"OR third_id LIKE 'kb-9000%' OR third_id LIKE 'mock-flow-%' "
f"OR third_id LIKE 'tool-9000%' OR third_id LIKE 'dash-9000%' "
f"OR third_id = 'menu-settings'"),
("usergroup", f"user_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("userrole", f"user_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("role", f"id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("group", f"id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
("user", f"user_id BETWEEN {MOCK_ID_LO} AND {MOCK_ID_HI}"),
]
total_rows = 0
for table, where in sql_deletes:
res = _exec(session, f"DELETE FROM `{table}` WHERE {where}")
total_rows += res.rowcount
_print_row(f'{table} deleted', res.rowcount)
session.commit()
# OpenFGA tuples: delete every tuple that references a mock entity.
# OpenFGA rejects read_tuples with only `user` (no object type), so we
# enumerate all candidate objects — mock resources + system:global for
# Step 1 super_admin.
fga = get_fga_client()
deleted = 0
for obj in _collect_mock_object_refs():
existing = await fga.read_tuples(object=obj)
if not existing:
continue
await fga.write_tuples(deletes=[
{'user': t['user'], 'relation': t['relation'], 'object': t['object']}
for t in existing
])
deleted += len(existing)
# Step 1 super_admin lives on system:global; only delete mock-user entries.
super_admin_tuples = await fga.read_tuples(
relation='super_admin', object='system:global',
)
mock_super_admins = [
t for t in super_admin_tuples
if t['user'] in {f'user:{uid}' for uid in MOCK_USER_IDS}
]
if mock_super_admins:
await fga.write_tuples(deletes=mock_super_admins)
deleted += len(mock_super_admins)
_print_row('OpenFGA tuples deleted', deleted)
_print_row('MySQL rows deleted total', total_rows)
def _collect_mock_object_refs() -> list[str]:
"""All OpenFGA object IDs that mock data could produce tuples for."""
objs: list[str] = []
# knowledge_space
for kid in MOCK_KB_IDS:
objs.append(f'knowledge_space:{kid}')
# knowledge_space (from Step 4 space ids + Step 6 parent link)
objs.extend(f'knowledge_space:{s}' for s in MOCK_SCM_SPACE_IDS)
# roleaccess third_ids referenced by Step 3
for tid in ('kb-90001', 'kb-90002', 'kb-admin-leak', 'menu-settings'):
objs.append(f'knowledge_space:{tid}')
for fid in MOCK_FLOW_IDS:
objs.append(f'assistant:{fid}')
objs.append(f'workflow:{fid}')
for tid in MOCK_TOOL_IDS:
objs.append(f'tool:{tid}')
objs.extend(['tool:tool-90001', 'tool:tool-90002', 'tool:tool-90003'])
objs.extend(['dashboard:dash-90001', 'dashboard:dash-90002'])
for cid in MOCK_CHANNEL_IDS:
objs.append(f'channel:{cid}')
objs.extend(f'channel:{s}' for s in MOCK_SCM_CHANNEL_IDS)
# folders & files
for kf in MOCK_KF_IDS:
objs.append(f'folder:{kf}')
objs.append(f'knowledge_file:{kf}')
# user_group (Step 2)
for gid in MOCK_GROUP_IDS:
objs.append(f'user_group:{gid}')
return objs
# ── Orchestrator ──────────────────────────────────────────────────
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument('--skip-cleanup', action='store_true',
help='Keep mock data in MySQL + FGA after the run')
parser.add_argument('--cleanup-only', action='store_true',
help='Only run Stage 6 and exit (recover from failed run)')
args = parser.parse_args()
# Initialize app context: DB, Redis, OpenFGA, etc.
from bisheng.common.services.config_service import settings
from bisheng.core.context import initialize_app_context
await initialize_app_context(config=settings)
from bisheng.core.database import get_sync_db_session
from bisheng.core.context.tenant import bypass_tenant_filter
if args.cleanup_only:
with get_sync_db_session() as session, bypass_tenant_filter():
await cleanup(session)
return 0
expected: ExpectedSet | None = None
try:
with get_sync_db_session() as session, bypass_tenant_filter():
precheck(session)
expected = seed(session)
stats = await reset_and_migrate()
verify_report = await run_verify()
missing, leaked = await reconcile(expected)
_print_banner('Summary')
_print_row('Migration tuples written', stats.total)
_print_row(' Step 1 super_admin', stats.step1_super_admin)
_print_row(' Step 2 user_group', stats.step2_user_group)
_print_row(' Step 3 role_access', stats.step3_role_access)
_print_row(' Step 4 SCM', stats.step4_space_channel)
_print_row(' Step 5 owners', stats.step5_resource_owners)
_print_row(' Step 6 folder', stats.step6_folder_hierarchy)
_print_row('--verify regression', verify_report.regression,
ok=(verify_report.regression == 0))
_print_row('must_have missing', missing, ok=(missing == 0))
_print_row('must_not_have leaked', leaked, ok=(leaked == 0))
passed = (verify_report.regression == 0 and missing == 0 and leaked == 0)
print()
print('=== OVERALL: {} ==='.format('PASS' if passed else 'FAIL'))
if passed and not args.skip_cleanup:
with get_sync_db_session() as session, bypass_tenant_filter():
await cleanup(session)
elif not passed:
print(' → Mock data preserved for inspection. Run with '
'--cleanup-only when done.')
return 0 if passed else 1
except Exception:
print(' → Exception raised; mock data left in place for inspection.')
raise
if __name__ == '__main__':
sys.exit(asyncio.run(main()))