WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
"""Plan legacy identities from a snapshot and emit guarded PostgreSQL SQL.
|
||||
|
||||
This module never opens the production SQLite database or executes target SQL.
|
||||
The caller must execute the returned SQL inside its migration transaction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
|
||||
SOURCE = "yawan-workbench"
|
||||
TARGET_DATABASE = "yawan_native_acceptance_20260915"
|
||||
TARGET_TENANT = 10059
|
||||
SOURCE_ORGANIZATION = 1
|
||||
ADMIN_TARGET_ID = "bad2d456-be74-458e-9037-661a371285b8"
|
||||
ADMIN_TARGET_EMAIL = "admin@yawan.local"
|
||||
ENABLED_SOURCE_IDS = {1, 3, 8}
|
||||
NAMESPACE = uuid.UUID("6ac76fbe-e61f-4a44-9597-893884aa5c63")
|
||||
PREFERENCE_KEY = "yawan_identity_migration"
|
||||
ORGANIZATION_LEDGER = "yawan_legacy_organization_mappings"
|
||||
IDENTITY_LEDGER = "yawan_legacy_identity_mappings"
|
||||
PENDING = ["permission_review_required", "password_reset_required", "placeholder_email_requires_review"]
|
||||
|
||||
|
||||
def _stable_id(source_id: int, tenant_id: int) -> str:
|
||||
return str(uuid.uuid5(NAMESPACE, f"{SOURCE}:tenant:{tenant_id}:organization:{SOURCE_ORGANIZATION}:user:{source_id}"))
|
||||
|
||||
|
||||
def _source_user(row: tuple, permissions: list[str]) -> dict:
|
||||
return dict(zip(("id", "username", "display_name", "role", "organization_id", "enabled"), row), permissions=permissions)
|
||||
|
||||
|
||||
def identity_plan(db: sqlite3.Connection, tenant_id: int) -> dict:
|
||||
"""Read the caller's consistent SQLite snapshot without reading passwords."""
|
||||
if type(tenant_id) is not int or tenant_id != TARGET_TENANT:
|
||||
raise ValueError(f"Identity migration is restricted to tenant {TARGET_TENANT}")
|
||||
organizations = db.execute("SELECT id, name, slug, enabled FROM organization ORDER BY id").fetchall()
|
||||
if len(organizations) != 1 or organizations[0][0] != SOURCE_ORGANIZATION or organizations[0][3] != 1:
|
||||
raise ValueError("Expected exactly one enabled source organization with id 1")
|
||||
organization = dict(zip(("id", "name", "slug", "enabled"), organizations[0]))
|
||||
permissions_by_role = {}
|
||||
role_definitions = []
|
||||
for role_row in db.execute("SELECT id, role, label, description, permissions, builtin, organization_id, updated_at FROM roledefinition ORDER BY id"):
|
||||
role_id, role, label, description, raw_permissions, builtin, organization_id, updated_at = role_row
|
||||
if organization_id != SOURCE_ORGANIZATION:
|
||||
raise ValueError("Role belongs to a different source organization")
|
||||
if role in permissions_by_role:
|
||||
raise ValueError(f"Duplicate source role: {role}")
|
||||
permissions = json.loads(raw_permissions)
|
||||
if not isinstance(permissions, list) or not all(isinstance(p, str) for p in permissions):
|
||||
raise ValueError(f"Invalid source permission list for role {role}")
|
||||
permissions_by_role[role] = sorted(set(permissions))
|
||||
role_definitions.append({"id": role_id, "role": role, "label": label, "description": description,
|
||||
"permissions": permissions, "builtin": builtin, "organization_id": organization_id,
|
||||
"updated_at": updated_at})
|
||||
|
||||
users, skipped = [], []
|
||||
seen_names = set()
|
||||
for row in db.execute("SELECT id, username, display_name, role, organization_id, enabled FROM appuser ORDER BY id"):
|
||||
source_id, username, _, role, organization_id, enabled = row
|
||||
if organization_id != SOURCE_ORGANIZATION:
|
||||
raise ValueError(f"User {source_id} belongs to a different source organization")
|
||||
if role not in permissions_by_role:
|
||||
raise ValueError(f"Missing permission definition for role {role}")
|
||||
source = _source_user(row, permissions_by_role[role])
|
||||
if enabled == 0:
|
||||
skipped.append({"source_user": source, "reason": "source_account_disabled"})
|
||||
continue
|
||||
if enabled != 1 or type(source_id) is not int or source_id <= 0:
|
||||
raise ValueError("Invalid source user identity or enabled flag")
|
||||
if not username or username != username.strip() or len(username) > 100:
|
||||
raise ValueError(f"Invalid source username for user {source_id}")
|
||||
folded = username.casefold()
|
||||
if folded in seen_names:
|
||||
raise ValueError("Conflicting source usernames after normalization")
|
||||
seen_names.add(folded)
|
||||
if role == "admin":
|
||||
if source_id != 1 or username != "yawan_admin":
|
||||
raise ValueError("Only the explicitly mapped legacy administrator is supported")
|
||||
users.append({"source_user": source, "target_id": ADMIN_TARGET_ID,
|
||||
"action": "map_existing_admin", "target_email": ADMIN_TARGET_EMAIL,
|
||||
"target_membership_role": "admin", "pending_activation": [],
|
||||
"permission_differences": ["existing_admin_identity_mapped_without_modification", "legacy_admin_permissions_not_copied"]})
|
||||
continue
|
||||
if role != "editor":
|
||||
raise ValueError(f"Unsupported enabled source role: {role}")
|
||||
email = f"legacy-{SOURCE}-t{tenant_id}-o{organization_id}-u{source_id}@migration.invalid"
|
||||
provenance = {"schema_version": 1, "source": SOURCE, "target_tenant_id": tenant_id,
|
||||
"source_organization": organization, "source_user": source,
|
||||
"email_is_placeholder": True, "pending_activation": PENDING,
|
||||
"permission_mapping": "editor_to_contributor_requires_review"}
|
||||
users.append({"source_user": source, "target_id": _stable_id(source_id, tenant_id),
|
||||
"action": "create_suspended", "target_username": username, "target_email": email,
|
||||
"email_is_placeholder": True, "target_membership_role": "contributor",
|
||||
"is_active": False, "membership_status": "suspended",
|
||||
"is_system_admin": False, "can_access_all_tenants": False,
|
||||
"preferences": {PREFERENCE_KEY: provenance}, "pending_activation": list(PENDING),
|
||||
"permission_differences": ["legacy_feature_allowlist_has_no_equivalent", "legacy_resource_grants_not_inferred", "contributor_scope_requires_review"]})
|
||||
if not any(user["action"] == "map_existing_admin" for user in users):
|
||||
raise ValueError("The explicitly mapped enabled legacy administrator is missing")
|
||||
result = {"schema_version": 1, "source": SOURCE, "target_database": TARGET_DATABASE,
|
||||
"target_tenant_id": tenant_id, "source_organization": organization,
|
||||
"source_role_definitions": role_definitions,
|
||||
"users": users, "skipped": skipped,
|
||||
"password_policy": "no_source_password_read_or_copied; random_non_bcrypt_placeholder_in_sql",
|
||||
"repeat_policy": "durable_ledger_identity; preserve_password_activation_and_contact_state",
|
||||
"mapping_ledger": {"organizations": ORGANIZATION_LEDGER, "identities": IDENTITY_LEDGER},
|
||||
"preferences_warning": "preferences_are_informational; durable_ledger_retains_source_identity"}
|
||||
_validate_plan(result)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_plan(plan: dict) -> None:
|
||||
if (plan.get("schema_version") != 1 or plan.get("source") != SOURCE
|
||||
or plan.get("target_database") != TARGET_DATABASE or plan.get("target_tenant_id") != TARGET_TENANT
|
||||
or plan.get("source_organization", {}).get("id") != SOURCE_ORGANIZATION):
|
||||
raise ValueError("Identity plan has an unsupported source or target scope")
|
||||
definitions = plan.get("source_role_definitions", [])
|
||||
roles = {definition.get("role") for definition in definitions}
|
||||
if roles != {"admin", "editor", "reviewer", "viewer"} or len(definitions) != len(roles):
|
||||
raise ValueError("Identity plan must preserve all four reviewed source role definitions")
|
||||
for definition in definitions:
|
||||
if definition.get("organization_id") != SOURCE_ORGANIZATION:
|
||||
raise ValueError("Identity role definition has a different source organization")
|
||||
if not isinstance(definition.get("permissions"), list) or not all(isinstance(p, str) for p in definition["permissions"]):
|
||||
raise ValueError("Identity role definition has invalid source permissions")
|
||||
role_permissions = {definition["role"]: sorted(set(definition["permissions"])) for definition in definitions}
|
||||
seen_ids, seen_names, seen_emails = set(), set(), set()
|
||||
admin_count = 0
|
||||
for user in plan.get("users", []):
|
||||
source = user["source_user"]
|
||||
if source["organization_id"] != SOURCE_ORGANIZATION or source["enabled"] != 1:
|
||||
raise ValueError("Identity plan includes a disabled or cross-organization user")
|
||||
if source.get("permissions") != role_permissions.get(source["role"]):
|
||||
raise ValueError("Identity user permissions differ from the preserved source role")
|
||||
if user["action"] == "map_existing_admin":
|
||||
admin_count += 1
|
||||
if (source["id"] != 1 or source["username"] != "yawan_admin" or source["role"] != "admin"
|
||||
or user["target_id"] != ADMIN_TARGET_ID or user["target_email"] != ADMIN_TARGET_EMAIL
|
||||
or user["target_membership_role"] != "admin"):
|
||||
raise ValueError("Identity plan changes the pinned administrator mapping")
|
||||
elif user["action"] == "create_suspended":
|
||||
expected_email = f"legacy-{SOURCE}-t{TARGET_TENANT}-o{SOURCE_ORGANIZATION}-u{source['id']}@migration.invalid"
|
||||
if (source["role"] != "editor" or user["target_id"] != _stable_id(source["id"], TARGET_TENANT)
|
||||
or user["target_username"] != source["username"] or user["target_email"] != expected_email
|
||||
or user["is_active"] is not False or user["is_system_admin"] is not False
|
||||
or user["can_access_all_tenants"] is not False or user["membership_status"] != "suspended"
|
||||
or user["target_membership_role"] != "contributor" or user["pending_activation"] != PENDING):
|
||||
raise ValueError("Identity plan changes the suspended account policy")
|
||||
metadata = user["preferences"].get(PREFERENCE_KEY, {})
|
||||
if (metadata.get("source") != SOURCE or metadata.get("target_tenant_id") != TARGET_TENANT
|
||||
or metadata.get("source_user") != source or metadata.get("source_organization") != plan["source_organization"]
|
||||
or metadata.get("pending_activation") != PENDING or metadata.get("email_is_placeholder") is not True):
|
||||
raise ValueError("Identity plan has inconsistent source provenance")
|
||||
name = user["target_username"].strip().casefold()
|
||||
email = user["target_email"].strip().casefold()
|
||||
if name in seen_names or email in seen_emails:
|
||||
raise ValueError("Identity plan has duplicate usernames or emails")
|
||||
seen_names.add(name)
|
||||
seen_emails.add(email)
|
||||
else:
|
||||
raise ValueError("Unknown identity plan action")
|
||||
if user["target_id"] in seen_ids:
|
||||
raise ValueError("Identity plan has duplicate target user IDs")
|
||||
seen_ids.add(user["target_id"])
|
||||
if admin_count != 1:
|
||||
raise ValueError("Identity plan must map exactly one existing administrator")
|
||||
if {user["source_user"]["id"] for user in plan["users"]} != ENABLED_SOURCE_IDS:
|
||||
raise ValueError("Enabled source identities differ from the reviewed migration batch")
|
||||
|
||||
|
||||
def identity_sql(plan: dict) -> str:
|
||||
"""Emit SQL with fail-closed identity checks and no transaction boundaries."""
|
||||
_validate_plan(plan)
|
||||
digest = plan.get("source_snapshot_sha256")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||
raise ValueError("Identity SQL requires the caller's source_snapshot_sha256")
|
||||
payload = json.dumps(plan, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
literal = "'" + payload.replace("'", "''") + "'::jsonb"
|
||||
tag = "identity_" + hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
return f"""SET LOCAL standard_conforming_strings = on;
|
||||
DO ${tag}$
|
||||
DECLARE
|
||||
migration jsonb := {literal};
|
||||
item jsonb;
|
||||
existing_user users%ROWTYPE;
|
||||
existing_member tenant_members%ROWTYPE;
|
||||
mapped_organization record;
|
||||
mapped_user record;
|
||||
has_mapping boolean;
|
||||
has_user boolean;
|
||||
collision_count integer;
|
||||
BEGIN
|
||||
IF current_database() <> '{TARGET_DATABASE}' THEN
|
||||
RAISE EXCEPTION 'Identity migration target database mismatch';
|
||||
END IF;
|
||||
PERFORM pg_advisory_xact_lock(10059, 11901);
|
||||
PERFORM 1 FROM tenants WHERE id = {TARGET_TENANT} AND status = 'active' AND deleted_at IS NULL FOR SHARE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Identity migration requires active tenant {TARGET_TENANT}';
|
||||
END IF;
|
||||
-- Lock identities so concurrent signups cannot race normalized-name checks.
|
||||
LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE;
|
||||
LOCK TABLE tenant_members IN SHARE ROW EXCLUSIVE MODE;
|
||||
-- Independent records survive native preference saves and target deletion.
|
||||
-- No user foreign key: the historical mapping must not block native deletes.
|
||||
CREATE TABLE IF NOT EXISTS {ORGANIZATION_LEDGER} (
|
||||
source_system text NOT NULL,
|
||||
source_organization_id bigint NOT NULL,
|
||||
target_tenant_id integer NOT NULL,
|
||||
source_snapshot jsonb NOT NULL,
|
||||
source_role_definitions jsonb NOT NULL,
|
||||
source_snapshot_sha256 varchar(64) NOT NULL CHECK (source_snapshot_sha256 ~ '^[a-f0-9]{{64}}$'),
|
||||
schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1),
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (source_system, source_organization_id),
|
||||
UNIQUE (source_system, target_tenant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS {IDENTITY_LEDGER} (
|
||||
source_system text NOT NULL,
|
||||
source_organization_id bigint NOT NULL,
|
||||
source_user_id bigint NOT NULL,
|
||||
target_tenant_id integer NOT NULL,
|
||||
target_user_id varchar(36) NOT NULL,
|
||||
migration_action text NOT NULL CHECK (migration_action IN ('map_existing_admin', 'create_suspended')),
|
||||
source_snapshot jsonb NOT NULL,
|
||||
source_snapshot_sha256 varchar(64) NOT NULL CHECK (source_snapshot_sha256 ~ '^[a-f0-9]{{64}}$'),
|
||||
initial_requested_username text NOT NULL,
|
||||
initial_requested_email text NOT NULL,
|
||||
schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1),
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (source_system, source_organization_id, source_user_id),
|
||||
UNIQUE (target_tenant_id, target_user_id)
|
||||
);
|
||||
LOCK TABLE {ORGANIZATION_LEDGER}, {IDENTITY_LEDGER} IN SHARE ROW EXCLUSIVE MODE;
|
||||
SELECT * INTO mapped_organization FROM {ORGANIZATION_LEDGER}
|
||||
WHERE source_system = '{SOURCE}' AND source_organization_id = {SOURCE_ORGANIZATION};
|
||||
IF FOUND THEN
|
||||
IF mapped_organization.target_tenant_id IS DISTINCT FROM {TARGET_TENANT}
|
||||
OR mapped_organization.schema_version IS DISTINCT FROM 1
|
||||
OR mapped_organization.source_snapshot IS DISTINCT FROM migration->'source_organization'
|
||||
OR mapped_organization.source_role_definitions IS DISTINCT FROM migration->'source_role_definitions' THEN
|
||||
RAISE EXCEPTION 'Identity migration organization mapping conflict';
|
||||
END IF;
|
||||
ELSE
|
||||
INSERT INTO {ORGANIZATION_LEDGER} (source_system, source_organization_id, target_tenant_id,
|
||||
source_snapshot, source_role_definitions, source_snapshot_sha256)
|
||||
VALUES ('{SOURCE}', {SOURCE_ORGANIZATION}, {TARGET_TENANT}, migration->'source_organization',
|
||||
migration->'source_role_definitions', migration->>'source_snapshot_sha256');
|
||||
END IF;
|
||||
FOR item IN SELECT value FROM jsonb_array_elements(migration->'users') LOOP
|
||||
SELECT * INTO mapped_user FROM {IDENTITY_LEDGER}
|
||||
WHERE source_system = '{SOURCE}' AND source_organization_id = {SOURCE_ORGANIZATION}
|
||||
AND source_user_id = (item->'source_user'->>'id')::bigint;
|
||||
has_mapping := FOUND;
|
||||
IF has_mapping THEN
|
||||
IF mapped_user.target_tenant_id IS DISTINCT FROM {TARGET_TENANT}
|
||||
OR mapped_user.target_user_id IS DISTINCT FROM item->>'target_id'
|
||||
OR mapped_user.schema_version IS DISTINCT FROM 1
|
||||
OR mapped_user.migration_action IS DISTINCT FROM item->>'action'
|
||||
OR mapped_user.source_snapshot IS DISTINCT FROM item->'source_user'
|
||||
OR mapped_user.initial_requested_username IS DISTINCT FROM COALESCE(item->>'target_username', item->'source_user'->>'username')
|
||||
OR mapped_user.initial_requested_email IS DISTINCT FROM item->>'target_email' THEN
|
||||
RAISE EXCEPTION 'Durable identity mapping conflict for source user %', item->'source_user'->>'id';
|
||||
END IF;
|
||||
ELSE
|
||||
IF EXISTS (SELECT 1 FROM {IDENTITY_LEDGER} WHERE target_tenant_id = {TARGET_TENANT}
|
||||
AND target_user_id = item->>'target_id') THEN
|
||||
RAISE EXCEPTION 'Target identity is already mapped to another source user';
|
||||
END IF;
|
||||
SELECT count(*) INTO collision_count FROM users
|
||||
WHERE id <> item->>'target_id'
|
||||
AND (lower(btrim(username)) = lower(btrim(COALESCE(item->>'target_username', item->'source_user'->>'username')))
|
||||
OR lower(btrim(email)) = lower(btrim(item->>'target_email')));
|
||||
IF collision_count <> 0 THEN
|
||||
RAISE EXCEPTION 'Identity migration username/email collision for source user %', item->'source_user'->>'id';
|
||||
END IF;
|
||||
END IF;
|
||||
SELECT * INTO existing_user FROM users WHERE id = item->>'target_id' FOR UPDATE;
|
||||
has_user := FOUND;
|
||||
IF item->>'action' = 'map_existing_admin' THEN
|
||||
IF NOT has_user OR existing_user.email IS DISTINCT FROM '{ADMIN_TARGET_EMAIL}'
|
||||
OR existing_user.is_active IS DISTINCT FROM true
|
||||
OR existing_user.is_system_admin IS DISTINCT FROM true
|
||||
OR existing_user.can_access_all_tenants IS DISTINCT FROM false
|
||||
OR existing_user.deleted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Pinned existing administrator failed identity checks';
|
||||
END IF;
|
||||
PERFORM 1 FROM tenant_members WHERE user_id = '{ADMIN_TARGET_ID}' AND tenant_id = {TARGET_TENANT}
|
||||
AND role = 'admin' AND status = 'active' AND deleted_at IS NULL;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pinned administrator is not active in migration tenant';
|
||||
END IF;
|
||||
ELSIF has_user THEN
|
||||
IF existing_user.tenant_id IS DISTINCT FROM {TARGET_TENANT}
|
||||
OR existing_user.is_system_admin IS DISTINCT FROM false
|
||||
OR existing_user.can_access_all_tenants IS DISTINCT FROM false
|
||||
OR existing_user.deleted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Identity migration ID/provenance collision for source user %', item->'source_user'->>'id';
|
||||
END IF;
|
||||
-- Upgrade an exact pre-ledger import; never adopt an unmarked user.
|
||||
IF NOT has_mapping AND (
|
||||
existing_user.username IS DISTINCT FROM item->>'target_username'
|
||||
OR existing_user.email IS DISTINCT FROM item->>'target_email'
|
||||
OR existing_user.preferences->'{PREFERENCE_KEY}' IS DISTINCT FROM item->'preferences'->'{PREFERENCE_KEY}') THEN
|
||||
RAISE EXCEPTION 'Unmapped existing identity lacks exact migration provenance';
|
||||
END IF;
|
||||
SELECT * INTO existing_member FROM tenant_members
|
||||
WHERE user_id = existing_user.id AND tenant_id = {TARGET_TENANT} AND deleted_at IS NULL;
|
||||
IF NOT FOUND OR existing_member.role <> 'contributor'
|
||||
OR existing_member.status NOT IN ('suspended', 'active') THEN
|
||||
RAISE EXCEPTION 'Existing migrated membership does not match this batch';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM tenant_members WHERE user_id = existing_user.id
|
||||
AND tenant_id <> {TARGET_TENANT} AND deleted_at IS NULL) THEN
|
||||
RAISE EXCEPTION 'Existing migrated identity has unexpected cross-tenant membership';
|
||||
END IF;
|
||||
ELSE
|
||||
IF has_mapping THEN
|
||||
RAISE EXCEPTION 'Mapped target user is missing; refusing to recreate a deleted account';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM tenant_members WHERE user_id = item->>'target_id') THEN
|
||||
RAISE EXCEPTION 'Orphan or deleted membership conflicts with new migrated identity';
|
||||
END IF;
|
||||
INSERT INTO users (id, username, email, password_hash, tenant_id, is_active,
|
||||
is_system_admin, can_access_all_tenants, preferences, created_at, updated_at)
|
||||
VALUES (item->>'target_id', item->>'target_username', item->>'target_email',
|
||||
'!migration-login-disabled:' || gen_random_uuid()::text,
|
||||
{TARGET_TENANT}, false, false, false, item->'preferences', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
INSERT INTO tenant_members (user_id, tenant_id, role, status, joined_at, created_at, updated_at)
|
||||
VALUES (item->>'target_id', {TARGET_TENANT}, 'contributor', 'suspended',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
END IF;
|
||||
IF NOT has_mapping THEN
|
||||
INSERT INTO {IDENTITY_LEDGER} (source_system, source_organization_id, source_user_id,
|
||||
target_tenant_id, target_user_id, migration_action, source_snapshot,
|
||||
source_snapshot_sha256, initial_requested_username, initial_requested_email)
|
||||
VALUES ('{SOURCE}', {SOURCE_ORGANIZATION}, (item->'source_user'->>'id')::bigint,
|
||||
{TARGET_TENANT}, item->>'target_id', item->>'action', item->'source_user', migration->>'source_snapshot_sha256',
|
||||
COALESCE(item->>'target_username', item->'source_user'->>'username'), item->>'target_email');
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
${tag}$;
|
||||
"""
|
||||
|
||||
|
||||
def identity_verification_sql(plan: dict) -> str:
|
||||
"""Return a read-only query, one row per mapped user and no password fields."""
|
||||
_validate_plan(plan)
|
||||
payload = json.dumps(plan, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
literal = "'" + payload.replace("'", "''") + "'::jsonb"
|
||||
return f"""WITH migration AS (SELECT {literal} AS body),
|
||||
planned AS (SELECT value AS item FROM migration, jsonb_array_elements(body->'users'))
|
||||
SELECT (item->'source_user'->>'id')::bigint AS source_user_id,
|
||||
item->>'target_id' AS expected_target_user_id,
|
||||
current_database() = '{TARGET_DATABASE}' AS database_matches,
|
||||
COALESCE(t.status = 'active' AND t.deleted_at IS NULL, false) AS tenant_matches,
|
||||
COALESCE(o.target_tenant_id = {TARGET_TENANT} AND o.schema_version = 1
|
||||
AND o.source_snapshot = body->'source_organization'
|
||||
AND o.source_role_definitions = body->'source_role_definitions', false) AS organization_mapping_matches,
|
||||
COALESCE(l.target_tenant_id = {TARGET_TENANT} AND l.target_user_id = item->>'target_id'
|
||||
AND l.schema_version = 1 AND l.source_snapshot = item->'source_user'
|
||||
AND l.migration_action = item->>'action'
|
||||
AND l.initial_requested_username = COALESCE(item->>'target_username', item->'source_user'->>'username')
|
||||
AND l.initial_requested_email = item->>'target_email', false) AS identity_mapping_matches,
|
||||
COALESCE(u.deleted_at IS NULL AND u.id IS NOT NULL
|
||||
AND CASE WHEN item->>'action' = 'map_existing_admin'
|
||||
THEN u.email = '{ADMIN_TARGET_EMAIL}' AND u.is_system_admin AND u.is_active
|
||||
ELSE u.tenant_id = {TARGET_TENANT} AND NOT u.is_system_admin
|
||||
AND NOT EXISTS (SELECT 1 FROM tenant_members other_member WHERE other_member.user_id = u.id
|
||||
AND other_member.tenant_id <> {TARGET_TENANT} AND other_member.deleted_at IS NULL)
|
||||
END AND NOT u.can_access_all_tenants, false) AS account_scope_matches,
|
||||
COALESCE(m.role = item->>'target_membership_role'
|
||||
AND CASE WHEN item->>'action' = 'map_existing_admin' THEN m.status = 'active'
|
||||
ELSE m.status IN ('suspended', 'active') END, false) AS membership_matches,
|
||||
u.username, u.email, u.is_active, m.status AS membership_status,
|
||||
l.source_snapshot_sha256 AS original_source_snapshot_sha256
|
||||
FROM planned CROSS JOIN migration
|
||||
LEFT JOIN tenants t ON t.id = {TARGET_TENANT}
|
||||
LEFT JOIN {ORGANIZATION_LEDGER} o ON o.source_system = '{SOURCE}' AND o.source_organization_id = {SOURCE_ORGANIZATION}
|
||||
LEFT JOIN {IDENTITY_LEDGER} l ON l.source_system = '{SOURCE}' AND l.source_organization_id = {SOURCE_ORGANIZATION}
|
||||
AND l.source_user_id = (item->'source_user'->>'id')::bigint
|
||||
LEFT JOIN users u ON u.id = item->>'target_id'
|
||||
LEFT JOIN tenant_members m ON m.user_id = u.id AND m.tenant_id = {TARGET_TENANT} AND m.deleted_at IS NULL
|
||||
ORDER BY source_user_id;
|
||||
"""
|
||||
Reference in New Issue
Block a user