378 lines
25 KiB
Python
378 lines
25 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Read-only access migration planner; emitted SQL rolls back unless requested.
|
||
|
|
|
||
|
|
No database writes are executed by this script. Apply the reviewed SQL separately
|
||
|
|
after migrations 000196 and 000207. Repeats use a durable ledger and preserve native edits.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import uuid
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from apply_weknora_identities import sql_run
|
||
|
|
from weknora_etl import consistent_snapshot
|
||
|
|
from weknora_identity_etl import ADMIN_TARGET_ID, TARGET_DATABASE, TARGET_TENANT
|
||
|
|
|
||
|
|
SOURCE = "yawan-workbench"
|
||
|
|
LEDGER = "yawan_legacy_access_mappings"
|
||
|
|
NAMESPACE = uuid.UUID("93bda6b1-9b55-41a6-ae07-07f844762fab")
|
||
|
|
ACCESS_TABLES = tuple("workbench_access_" + s for s in ("roles", "assignments", "publications", "grants"))
|
||
|
|
GRANT_TABLES = ("knowledgebasegrant", "canvasgrant", "memorygrant", "digitalhumangrant", "agentskillgrant", "agentgrant")
|
||
|
|
EXPANSIONS = {
|
||
|
|
"read": {"read"}, "review": {"tasks:review", "memories:review"},
|
||
|
|
"audit:read": {"logs:read", "audit:read"},
|
||
|
|
"feature:memories": {"feature:agents"}, "feature:skills": {"feature:agents"},
|
||
|
|
"feature:tags": {"feature:knowledge"}, "tags:read": {"kb:read"},
|
||
|
|
"content:write": {"tasks:read", "tasks:write", "tasks:deliver", "kb:read", "kb:write",
|
||
|
|
"agents:read", "agents:write", "digital_humans:read", "flows:read", "memories:read", "memories:write"},
|
||
|
|
"config:read": {"logs:read", "runs:read", "audit:read", "usage:read", "system_logs:read", "ops:read", "ops:manage"},
|
||
|
|
"config:write": {"providers:write", "ops:manage"},
|
||
|
|
"admin:manage": {"roles:manage", "audit:manage", "users:manage", "providers:write", "ops:manage", "billing:manage"},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def canonical(value):
|
||
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False)
|
||
|
|
|
||
|
|
|
||
|
|
def digest(value):
|
||
|
|
return hashlib.sha256(canonical(value).encode()).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def stable(role):
|
||
|
|
return str(uuid.uuid5(NAMESPACE, f"{SOURCE}:tenant:{TARGET_TENANT}:role:{role}"))
|
||
|
|
|
||
|
|
|
||
|
|
def read_rows(db, table, columns):
|
||
|
|
query = db.execute(f"SELECT {columns} FROM {table} ORDER BY id")
|
||
|
|
names = [column[0] for column in query.description]
|
||
|
|
return [dict(zip(names, row)) for row in query]
|
||
|
|
|
||
|
|
|
||
|
|
def read_source(db):
|
||
|
|
if db.execute("PRAGMA query_only").fetchone()[0] != 1:
|
||
|
|
raise ValueError("A read-only consistent snapshot is required")
|
||
|
|
roles = read_rows(db, "roledefinition", "id,role,label,description,permissions,builtin,organization_id,updated_at")
|
||
|
|
for role in roles:
|
||
|
|
role["permissions"] = json.loads(role["permissions"])
|
||
|
|
resources = {}
|
||
|
|
for table in ("agent", "agentskill", "canvas", "knowledgebase", "digitalhuman", "memory"):
|
||
|
|
extra = ",status" if table == "agent" else ",enabled" if table == "agentskill" else ""
|
||
|
|
resources[table] = read_rows(db, table, "id,name,organization_id,owner_user_id,visibility" + extra) if table != "memory" else []
|
||
|
|
return {"roles": roles, "users": read_rows(db, "appuser", "id,username,role,organization_id,enabled"),
|
||
|
|
"resources": resources, "memory_count": db.execute("SELECT count(*) FROM memory").fetchone()[0],
|
||
|
|
"grants": {table: read_rows(db, table, "*") for table in GRANT_TABLES}}
|
||
|
|
|
||
|
|
|
||
|
|
def role_permissions(source):
|
||
|
|
"""Intersect grouped native actions with actual legacy endpoint checks.
|
||
|
|
|
||
|
|
Content delivery is separate; native Agent management still includes memories.
|
||
|
|
Every old action represented by the grouped permission must be present.
|
||
|
|
"""
|
||
|
|
raw = source["permissions"]
|
||
|
|
if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
|
||
|
|
raise ValueError("Malformed legacy role permission list")
|
||
|
|
if "*" in raw:
|
||
|
|
return [], ["administrator_mapping_retained_without_new_custom_role"]
|
||
|
|
permissions = set().union(*(EXPANSIONS.get(p, {p}) for p in raw))
|
||
|
|
result = []
|
||
|
|
requirements = {
|
||
|
|
"content.read": {"tasks:review"},
|
||
|
|
"content.write": {"tasks:write"},
|
||
|
|
"content.review": {"tasks:review"},
|
||
|
|
"content.deliver": {"tasks:deliver"},
|
||
|
|
"flows.read": {"agents:read"}, "flows.write": {"agents:write"},
|
||
|
|
"digital-humans.read": {"read"}, "digital-humans.write": {"agents:write"},
|
||
|
|
"knowledge.read": {"kb:read"}, "knowledge.write": {"kb:write"},
|
||
|
|
"agents.read": {"agents:read", "memories:write"},
|
||
|
|
"agents.write": {"agents:write", "memories:write", "memories:review"},
|
||
|
|
}
|
||
|
|
gates = {
|
||
|
|
"content": {"feature:tasks", "tasks:read", "tasks:write", "tasks:review", "tasks:deliver"},
|
||
|
|
"flows": {"feature:flows", "flows:read", "agents:write"},
|
||
|
|
"digital-humans": {"feature:digital-humans", "digital_humans:read", "agents:write"},
|
||
|
|
"knowledge": {"feature:knowledge", "kb:read"},
|
||
|
|
"agents": {"feature:agents", "agents:read", "agents:write"},
|
||
|
|
}
|
||
|
|
for permission, required in requirements.items():
|
||
|
|
module, action = permission.split(".")
|
||
|
|
if required <= permissions and permissions & gates[module]:
|
||
|
|
if action == "read" or module + ".read" in result:
|
||
|
|
result.append(permission)
|
||
|
|
notes = ["new_modules_chat_sharing_settings_access_not_implicitly_granted"]
|
||
|
|
if permissions & {"agents:read", "memories:review"} and "agents.read" not in result:
|
||
|
|
notes.append("agents_read_withheld_because_native_group_includes_memory_access")
|
||
|
|
return sorted(result), notes
|
||
|
|
|
||
|
|
|
||
|
|
def read_target():
|
||
|
|
# Only authentication-independent metadata is selected, never password/token/model bodies.
|
||
|
|
return json.loads(sql_run(f"""BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
|
||
|
|
SELECT json_build_object(
|
||
|
|
'database',current_database(),
|
||
|
|
'access_tables',(SELECT coalesce(json_agg(tablename ORDER BY tablename),'[]') FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'workbench_access_%'),
|
||
|
|
'content_permissions_ready',to_regclass('workbench_content_permission_migrations') IS NOT NULL,
|
||
|
|
'organization',(SELECT to_jsonb(o) FROM yawan_legacy_organization_mappings o WHERE target_tenant_id={TARGET_TENANT}),
|
||
|
|
'identities',(SELECT json_agg(json_build_object('source_user_id',l.source_user_id,'target_user_id',l.target_user_id,
|
||
|
|
'source_snapshot',l.source_snapshot,'user_tenant',u.tenant_id,'is_active',u.is_active,'is_system_admin',u.is_system_admin,
|
||
|
|
'can_access_all_tenants',u.can_access_all_tenants,'user_deleted',u.deleted_at,
|
||
|
|
'memberships',(SELECT json_agg(json_build_object('tenant_id',m.tenant_id,'role',m.role,'status',m.status,'deleted_at',m.deleted_at)) FROM tenant_members m WHERE m.user_id=u.id)))
|
||
|
|
FROM yawan_legacy_identity_mappings l LEFT JOIN users u ON u.id=l.target_user_id WHERE l.target_tenant_id={TARGET_TENANT}),
|
||
|
|
'asset_mappings',(SELECT json_agg(json_build_object('source_kind',source_kind,'source_id',source_id,'target_id',target_id,
|
||
|
|
'source_visibility',source_snapshot->>'visibility')) FROM yawan_legacy_asset_mappings WHERE target_tenant_id={TARGET_TENANT} AND source_kind IN ('agent','skill')),
|
||
|
|
'flow_mappings',(SELECT json_agg(json_build_object('source_id',source_id,'target_id',target_id,'source_visibility',source_snapshot->>'visibility'))
|
||
|
|
FROM yawan_legacy_business_mappings WHERE target_tenant_id={TARGET_TENANT} AND source_kind='canvas'),
|
||
|
|
'resources',(SELECT json_agg(x) FROM (
|
||
|
|
SELECT 'agents' AS kind,id,tenant_id,created_by AS owner_id,visibility,enabled FROM custom_agents WHERE tenant_id={TARGET_TENANT} AND deleted_at IS NULL
|
||
|
|
UNION ALL SELECT 'skills',id,tenant_id,created_by,visibility,enabled FROM tenant_skill_catalog WHERE tenant_id={TARGET_TENANT} AND instruction_only=true AND deleted_at IS NULL
|
||
|
|
UNION ALL SELECT 'knowledge',id,tenant_id,creator_id,'global',true FROM knowledge_bases WHERE tenant_id={TARGET_TENANT} AND deleted_at IS NULL AND is_temporary=false
|
||
|
|
UNION ALL SELECT 'flows',id,tenant_id,owner_id,'private',true FROM workbench_resources WHERE tenant_id={TARGET_TENANT} AND kind='flows') x));
|
||
|
|
COMMIT;"""))
|
||
|
|
|
||
|
|
|
||
|
|
def make_plan(source, target, kb_mapping, snapshot_sha256):
|
||
|
|
if target["database"] != TARGET_DATABASE or kb_mapping.get("tenant_id") != TARGET_TENANT:
|
||
|
|
raise ValueError("Unexpected acceptance database or knowledge tenant")
|
||
|
|
if any(source["grants"].values()):
|
||
|
|
raise ValueError("Explicit legacy grants changed from reviewed empty baseline; manual mapping required")
|
||
|
|
if source["resources"]["digitalhuman"] or source["memory_count"]:
|
||
|
|
raise ValueError("Digital human or memory source resources require additional mappings")
|
||
|
|
if source["roles"] != target["organization"]["source_role_definitions"]:
|
||
|
|
raise ValueError("Source role definitions differ from the durable identity snapshot")
|
||
|
|
roles = []
|
||
|
|
for role in source["roles"]:
|
||
|
|
if role["organization_id"] != 1:
|
||
|
|
raise ValueError("Cross-organization role")
|
||
|
|
if role["role"] == "admin":
|
||
|
|
if role["permissions"] != ["*"]:
|
||
|
|
raise ValueError("Administrator definition changed")
|
||
|
|
continue
|
||
|
|
permissions, notes = role_permissions(role)
|
||
|
|
roles.append({"source_role": role["role"], "id": stable(role["role"]), "name": "迁移 / " + role["label"],
|
||
|
|
"permissions": permissions, "source_permissions": role["permissions"], "review_notes": notes})
|
||
|
|
roles.append({"source_role": "historical-owner", "id": stable("historical-owner"), "name": "迁移 / 历史停用账户",
|
||
|
|
"permissions": [], "source_permissions": [], "review_notes": ["missing_source_identity_denied_all"]})
|
||
|
|
role_by_name = {r["source_role"]: r for r in roles}
|
||
|
|
source_users = {u["id"]: u for u in source["users"]}
|
||
|
|
assignments, identity_map = [], {}
|
||
|
|
for identity in target["identities"]:
|
||
|
|
uid, sid = identity["target_user_id"], identity["source_user_id"]
|
||
|
|
identity_map[sid] = uid
|
||
|
|
if sid == 1:
|
||
|
|
if uid != ADMIN_TARGET_ID:
|
||
|
|
raise ValueError("Pinned administrator mapping changed")
|
||
|
|
continue
|
||
|
|
memberships = [m for m in identity["memberships"] if m["deleted_at"] is None]
|
||
|
|
if (identity["user_tenant"] != TARGET_TENANT or identity["is_active"] is not False
|
||
|
|
or identity["is_system_admin"] is not False or identity["can_access_all_tenants"] is not False
|
||
|
|
or identity["user_deleted"] is not None or memberships != [{"tenant_id": TARGET_TENANT, "role": "contributor", "status": "suspended", "deleted_at": None}]):
|
||
|
|
raise ValueError("Legacy non-admin account must remain isolated, inactive and suspended")
|
||
|
|
original = source_users.get(sid)
|
||
|
|
if original:
|
||
|
|
if original["organization_id"] != 1 or original["enabled"] != 1:
|
||
|
|
raise ValueError("Disabled or cross-organization source user cannot acquire a role")
|
||
|
|
source_role = original["role"]
|
||
|
|
if identity["source_snapshot"].get("role") != source_role:
|
||
|
|
raise ValueError("Legacy identity role differs from durable mapping")
|
||
|
|
elif identity["source_snapshot"].get("tombstone") is True:
|
||
|
|
source_role = "historical-owner"
|
||
|
|
else:
|
||
|
|
raise ValueError("Missing legacy account without a reviewed tombstone")
|
||
|
|
assignments.append({"source_user_id": sid, "user_id": uid, "role_id": role_by_name[source_role]["id"]})
|
||
|
|
if {u["id"] for u in source["users"] if u["enabled"] == 1} - set(identity_map):
|
||
|
|
raise ValueError("An enabled legacy user lacks a durable identity mapping")
|
||
|
|
native = {(r["kind"], r["id"]): r for r in target["resources"]}
|
||
|
|
mappings = {(r["source_kind"], r["source_id"]): r for r in target["asset_mappings"]}
|
||
|
|
mappings.update({("canvas", r["source_id"]): r for r in target["flow_mappings"]})
|
||
|
|
publications = []
|
||
|
|
for table, source_kind, kind in (("agent", "agent", "agents"), ("agentskill", "skill", "skills"),
|
||
|
|
("canvas", "canvas", "flows"), ("knowledgebase", "knowledgebase", "knowledge")):
|
||
|
|
for row in source["resources"][table]:
|
||
|
|
if row["organization_id"] != 1 or row["visibility"] not in ("private", "global"):
|
||
|
|
raise ValueError("Unsupported resource organization or visibility")
|
||
|
|
if kind == "knowledge":
|
||
|
|
rid = kb_mapping["knowledge_bases"][str(row["id"])]["id"]
|
||
|
|
else:
|
||
|
|
mapping = mappings[(source_kind, row["id"])]
|
||
|
|
if mapping["source_visibility"] != row["visibility"]:
|
||
|
|
raise ValueError("Resource visibility differs from durable source mapping")
|
||
|
|
rid = mapping["target_id"]
|
||
|
|
actual = native[(kind, rid)]
|
||
|
|
if kind != "knowledge" and actual["owner_id"] != identity_map[row["owner_user_id"] or 1]:
|
||
|
|
raise ValueError("Resource owner differs from identity mapping")
|
||
|
|
enabled = row.get("status", "active") == "active" and bool(row.get("enabled", True))
|
||
|
|
if kind in ("agents", "skills") and (actual["visibility"] != row["visibility"] or actual["enabled"] != enabled):
|
||
|
|
raise ValueError("Native resource visibility or enablement has changed; review before import")
|
||
|
|
publications.append({"kind": kind, "resource_id": rid, "global": row["visibility"] == "global" and enabled,
|
||
|
|
"source_kind": source_kind, "source_id": row["id"], "expected_native": actual})
|
||
|
|
plan = {"schema_version": 1, "source": SOURCE, "target_database": TARGET_DATABASE, "tenant_id": TARGET_TENANT,
|
||
|
|
"source_snapshot_sha256": snapshot_sha256, "roles": roles, "assignments": assignments,
|
||
|
|
"publications": publications, "grants": [], "source_grant_counts": {t: len(rows) for t, rows in source["grants"].items()},
|
||
|
|
"source_role_definitions": source["roles"],
|
||
|
|
"prerequisites": (["apply_migration_000196"] if set(ACCESS_TABLES) - set(target["access_tables"]) else [])
|
||
|
|
+ ([] if target.get("content_permissions_ready") else ["apply_migration_000207"]),
|
||
|
|
"activation_blockers": ["verify_shared_knowledge_write_isolation_and_export_denial", "review_unmapped_legacy_logs_runs_usage_permissions", "reset_credentials_and_review_placeholder_email"],
|
||
|
|
"policy": "insert_only_no_activation_no_admin_changes_no_inferred_grants_preserve_native_edits"}
|
||
|
|
plan["plan_sha256"] = digest(plan)
|
||
|
|
return plan
|
||
|
|
|
||
|
|
|
||
|
|
def migration_sql(plan, commit=False):
|
||
|
|
if (plan["target_database"] != TARGET_DATABASE or plan["tenant_id"] != TARGET_TENANT
|
||
|
|
or plan["grants"] or plan["plan_sha256"] != digest({k: v for k, v in plan.items() if k != "plan_sha256"})):
|
||
|
|
raise ValueError("Plan scope or integrity mismatch")
|
||
|
|
payload = "'" + canonical(plan).replace("'", "''") + "'::jsonb"
|
||
|
|
return f"""-- Review plan SHA256: {plan['plan_sha256']}
|
||
|
|
-- This file is a rehearsal by default. No source database is accessed.
|
||
|
|
BEGIN ISOLATION LEVEL SERIALIZABLE;
|
||
|
|
SET LOCAL statement_timeout='30s';
|
||
|
|
SET LOCAL lock_timeout='5s';
|
||
|
|
SET LOCAL standard_conforming_strings=on;
|
||
|
|
DO $scope$ BEGIN
|
||
|
|
IF current_database() <> '{TARGET_DATABASE}' THEN RAISE EXCEPTION 'Wrong migration database'; END IF;
|
||
|
|
IF NOT EXISTS(SELECT 1 FROM tenants WHERE id={TARGET_TENANT} AND status='active' AND deleted_at IS NULL)
|
||
|
|
THEN RAISE EXCEPTION 'Missing active acceptance tenant'; END IF;
|
||
|
|
IF to_regclass('workbench_access_grants') IS NULL THEN RAISE EXCEPTION 'Apply schema migration 000196 first'; END IF;
|
||
|
|
IF to_regclass('workbench_content_permission_migrations') IS NULL THEN RAISE EXCEPTION 'Apply schema migration 000207 first'; END IF;
|
||
|
|
END $scope$;
|
||
|
|
SELECT pg_advisory_xact_lock({TARGET_TENANT},11905);
|
||
|
|
LOCK TABLE users,tenant_members,workbench_access_roles,workbench_access_assignments,
|
||
|
|
workbench_access_publications,workbench_access_grants IN SHARE ROW EXCLUSIVE MODE;
|
||
|
|
LOCK TABLE custom_agents,tenant_skill_catalog,knowledge_bases,workbench_resources IN SHARE MODE;
|
||
|
|
CREATE TEMP TABLE _access_users_before ON COMMIT DROP AS SELECT * FROM users;
|
||
|
|
CREATE TEMP TABLE _access_members_before ON COMMIT DROP AS SELECT * FROM tenant_members;
|
||
|
|
CREATE TEMP TABLE _access_roles_before ON COMMIT DROP AS SELECT * FROM workbench_access_roles;
|
||
|
|
CREATE TEMP TABLE _access_assignments_before ON COMMIT DROP AS SELECT * FROM workbench_access_assignments;
|
||
|
|
CREATE TEMP TABLE _access_publications_before ON COMMIT DROP AS SELECT * FROM workbench_access_publications;
|
||
|
|
CREATE TEMP TABLE _access_grants_before ON COMMIT DROP AS SELECT * FROM workbench_access_grants;
|
||
|
|
CREATE TABLE IF NOT EXISTS {LEDGER} (
|
||
|
|
source_system text NOT NULL,target_tenant_id bigint NOT NULL,entity_key text NOT NULL,
|
||
|
|
source_spec jsonb NOT NULL,plan_sha256 varchar(64) NOT NULL,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(source_system,target_tenant_id,entity_key)
|
||
|
|
);
|
||
|
|
DO $migrate$
|
||
|
|
DECLARE plan jsonb := {payload}; item jsonb; spec jsonb; saved jsonb; key text;
|
||
|
|
BEGIN
|
||
|
|
IF NOT EXISTS(SELECT 1 FROM yawan_legacy_organization_mappings WHERE source_system='{SOURCE}'
|
||
|
|
AND source_organization_id=1 AND target_tenant_id={TARGET_TENANT}
|
||
|
|
AND source_role_definitions=plan->'source_role_definitions')
|
||
|
|
THEN RAISE EXCEPTION 'Source role provenance changed'; END IF;
|
||
|
|
IF NOT EXISTS(SELECT 1 FROM yawan_legacy_identity_mappings WHERE source_system='{SOURCE}'
|
||
|
|
AND source_user_id=1 AND target_tenant_id={TARGET_TENANT} AND target_user_id='{ADMIN_TARGET_ID}')
|
||
|
|
THEN RAISE EXCEPTION 'Pinned administrator mapping changed'; END IF;
|
||
|
|
FOR item IN SELECT value FROM jsonb_array_elements(plan->'assignments') LOOP
|
||
|
|
IF NOT EXISTS(SELECT 1 FROM users u JOIN tenant_members m ON m.user_id=u.id
|
||
|
|
JOIN yawan_legacy_identity_mappings l ON l.target_user_id=u.id AND l.target_tenant_id={TARGET_TENANT}
|
||
|
|
WHERE u.id=item->>'user_id' AND u.tenant_id={TARGET_TENANT} AND NOT u.is_active AND NOT u.is_system_admin
|
||
|
|
AND NOT u.can_access_all_tenants AND u.deleted_at IS NULL AND m.tenant_id={TARGET_TENANT}
|
||
|
|
AND m.role='contributor' AND m.status='suspended' AND m.deleted_at IS NULL
|
||
|
|
AND l.source_user_id=(item->>'source_user_id')::bigint
|
||
|
|
AND NOT EXISTS(SELECT 1 FROM tenant_members other WHERE other.user_id=u.id AND other.tenant_id<>{TARGET_TENANT} AND other.deleted_at IS NULL))
|
||
|
|
THEN RAISE EXCEPTION 'Migrated account must remain suspended and isolated'; END IF;
|
||
|
|
END LOOP;
|
||
|
|
FOR item IN SELECT value FROM jsonb_array_elements(plan->'roles') LOOP
|
||
|
|
key := 'role/' || (item->>'source_role'); spec := item;
|
||
|
|
SELECT source_spec INTO saved FROM {LEDGER} WHERE source_system='{SOURCE}' AND target_tenant_id={TARGET_TENANT} AND entity_key=key;
|
||
|
|
IF FOUND THEN
|
||
|
|
IF saved IS DISTINCT FROM spec AND NOT EXISTS(
|
||
|
|
SELECT 1 FROM workbench_content_permission_migrations m,jsonb_array_elements(m.changes) change
|
||
|
|
WHERE m.version=207 AND change->>'role_id'=item->>'id'
|
||
|
|
AND (change->>'tenant_id')::bigint={TARGET_TENANT}
|
||
|
|
AND change->'legacy_source_spec'=saved
|
||
|
|
AND (saved-'permissions'-'review_notes')=(spec-'permissions'-'review_notes')
|
||
|
|
AND change->'mapped_permissions'=spec->'permissions')
|
||
|
|
THEN RAISE EXCEPTION 'Role migration provenance conflict'; END IF;
|
||
|
|
CONTINUE;
|
||
|
|
END IF;
|
||
|
|
IF EXISTS(SELECT 1 FROM workbench_access_roles WHERE id=item->>'id' OR (tenant_id={TARGET_TENANT} AND name=item->>'name'))
|
||
|
|
THEN RAISE EXCEPTION 'Native role collision'; END IF;
|
||
|
|
INSERT INTO workbench_access_roles(id,tenant_id,name,description,permissions,version)
|
||
|
|
VALUES(item->>'id',{TARGET_TENANT},item->>'name','旧系统权限保守迁移;原账户保持停用,启用前仍需审核。',item->'permissions',1);
|
||
|
|
INSERT INTO {LEDGER}(source_system,target_tenant_id,entity_key,source_spec,plan_sha256)
|
||
|
|
VALUES('{SOURCE}',{TARGET_TENANT},key,spec,plan->>'plan_sha256');
|
||
|
|
END LOOP;
|
||
|
|
FOR item IN SELECT value FROM jsonb_array_elements(plan->'assignments') LOOP
|
||
|
|
key := 'assignment/' || (item->>'user_id'); spec := item;
|
||
|
|
SELECT source_spec INTO saved FROM {LEDGER} WHERE source_system='{SOURCE}' AND target_tenant_id={TARGET_TENANT} AND entity_key=key;
|
||
|
|
IF FOUND THEN
|
||
|
|
IF saved IS DISTINCT FROM spec THEN RAISE EXCEPTION 'Assignment migration provenance conflict'; END IF;
|
||
|
|
CONTINUE;
|
||
|
|
END IF;
|
||
|
|
IF EXISTS(SELECT 1 FROM workbench_access_assignments WHERE tenant_id={TARGET_TENANT} AND user_id=item->>'user_id')
|
||
|
|
THEN RAISE EXCEPTION 'Native assignment collision'; END IF;
|
||
|
|
INSERT INTO workbench_access_assignments(tenant_id,user_id,role_id) VALUES({TARGET_TENANT},item->>'user_id',item->>'role_id');
|
||
|
|
INSERT INTO {LEDGER}(source_system,target_tenant_id,entity_key,source_spec,plan_sha256)
|
||
|
|
VALUES('{SOURCE}',{TARGET_TENANT},key,spec,plan->>'plan_sha256');
|
||
|
|
END LOOP;
|
||
|
|
FOR item IN SELECT value FROM jsonb_array_elements(plan->'publications') LOOP
|
||
|
|
key := 'publication/' || (item->>'kind') || '/' || (item->>'resource_id'); spec := item;
|
||
|
|
SELECT source_spec INTO saved FROM {LEDGER} WHERE source_system='{SOURCE}' AND target_tenant_id={TARGET_TENANT} AND entity_key=key;
|
||
|
|
IF FOUND THEN
|
||
|
|
IF saved IS DISTINCT FROM spec THEN RAISE EXCEPTION 'Publication migration provenance conflict'; END IF;
|
||
|
|
CONTINUE;
|
||
|
|
END IF;
|
||
|
|
IF EXISTS(SELECT 1 FROM workbench_access_publications WHERE tenant_id={TARGET_TENANT} AND kind=item->>'kind' AND resource_id=item->>'resource_id')
|
||
|
|
OR EXISTS(SELECT 1 FROM workbench_access_grants WHERE tenant_id={TARGET_TENANT} AND kind=item->>'kind' AND resource_id=item->>'resource_id')
|
||
|
|
THEN RAISE EXCEPTION 'Native resource authorization already exists'; END IF;
|
||
|
|
IF NOT EXISTS(SELECT 1 FROM (
|
||
|
|
SELECT 'agents' AS kind,id,tenant_id,created_by AS owner_id,visibility,enabled FROM custom_agents WHERE deleted_at IS NULL
|
||
|
|
UNION ALL SELECT 'skills',id,tenant_id,created_by,visibility,enabled FROM tenant_skill_catalog WHERE instruction_only=true AND deleted_at IS NULL
|
||
|
|
UNION ALL SELECT 'knowledge',id,tenant_id,creator_id,'global',true FROM knowledge_bases WHERE deleted_at IS NULL AND is_temporary=false
|
||
|
|
UNION ALL SELECT 'flows',id,tenant_id,owner_id,'private',true FROM workbench_resources WHERE kind='flows') r
|
||
|
|
WHERE to_jsonb(r)=item->'expected_native')
|
||
|
|
THEN RAISE EXCEPTION 'Native resource changed since plan'; END IF;
|
||
|
|
INSERT INTO workbench_access_publications(tenant_id,kind,resource_id,global)
|
||
|
|
VALUES({TARGET_TENANT},item->>'kind',item->>'resource_id',(item->>'global')::boolean);
|
||
|
|
INSERT INTO {LEDGER}(source_system,target_tenant_id,entity_key,source_spec,plan_sha256)
|
||
|
|
VALUES('{SOURCE}',{TARGET_TENANT},key,spec,plan->>'plan_sha256');
|
||
|
|
END LOOP;
|
||
|
|
END $migrate$;
|
||
|
|
DO $preservation$ BEGIN
|
||
|
|
IF EXISTS(SELECT 1 FROM _access_users_before b FULL JOIN users a ON a.id=b.id WHERE to_jsonb(a) IS DISTINCT FROM to_jsonb(b))
|
||
|
|
OR EXISTS(SELECT 1 FROM _access_members_before b FULL JOIN tenant_members a ON a.id=b.id WHERE to_jsonb(a) IS DISTINCT FROM to_jsonb(b))
|
||
|
|
OR EXISTS(SELECT 1 FROM _access_grants_before b FULL JOIN workbench_access_grants a USING(tenant_id,kind,resource_id,user_id) WHERE to_jsonb(a) IS DISTINCT FROM to_jsonb(b))
|
||
|
|
THEN RAISE EXCEPTION 'Existing users, memberships or grants changed'; END IF;
|
||
|
|
IF EXISTS(SELECT 1 FROM _access_roles_before b LEFT JOIN workbench_access_roles a ON a.id=b.id WHERE to_jsonb(a) IS DISTINCT FROM to_jsonb(b))
|
||
|
|
OR EXISTS(SELECT 1 FROM _access_assignments_before b LEFT JOIN workbench_access_assignments a USING(tenant_id,user_id) WHERE to_jsonb(a) IS DISTINCT FROM to_jsonb(b))
|
||
|
|
OR EXISTS(SELECT 1 FROM _access_publications_before b LEFT JOIN workbench_access_publications a USING(tenant_id,kind,resource_id) WHERE to_jsonb(a) IS DISTINCT FROM to_jsonb(b))
|
||
|
|
THEN RAISE EXCEPTION 'Existing native access configuration changed'; END IF;
|
||
|
|
END $preservation$;
|
||
|
|
SELECT json_build_object('tenant_id',{TARGET_TENANT},'mapping_rows',count(*),'plan_sha256','{plan['plan_sha256']}')
|
||
|
|
FROM {LEDGER} WHERE source_system='{SOURCE}' AND target_tenant_id={TARGET_TENANT};
|
||
|
|
{'COMMIT' if commit else 'ROLLBACK'};
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--sqlite", type=Path, required=True)
|
||
|
|
parser.add_argument("--kb-mapping", type=Path, required=True)
|
||
|
|
parser.add_argument("--out", type=Path, required=True)
|
||
|
|
parser.add_argument("--commit-sql", action="store_true", help="Emit COMMIT SQL only; still never execute it")
|
||
|
|
args = parser.parse_args()
|
||
|
|
sql_path = args.out.with_suffix(".sql")
|
||
|
|
if args.out.exists() or sql_path.exists():
|
||
|
|
parser.error("Output exists; choose a new review artifact name")
|
||
|
|
with consistent_snapshot(args.sqlite) as (db, info):
|
||
|
|
source = read_source(db)
|
||
|
|
if info["source_mutated"]:
|
||
|
|
raise ValueError("Source changed during snapshot")
|
||
|
|
target = read_target()
|
||
|
|
plan = make_plan(source, target, json.loads(args.kb_mapping.read_text()), info["sha256"])
|
||
|
|
with args.out.open("x", encoding="utf-8") as output:
|
||
|
|
json.dump(plan, output, ensure_ascii=False, indent=2)
|
||
|
|
with sql_path.open("x", encoding="utf-8") as output:
|
||
|
|
output.write(migration_sql(plan, args.commit_sql))
|
||
|
|
print(json.dumps({"roles": len(plan["roles"]), "assignments": len(plan["assignments"]),
|
||
|
|
"publications": len(plan["publications"]), "global": sum(p["global"] for p in plan["publications"]),
|
||
|
|
"grants": 0, "writes_performed": False, "source_mutated": info["source_mutated"],
|
||
|
|
"prerequisites": plan["prerequisites"], "plan_sha256": plan["plan_sha256"]}))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|