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
151 lines
7.9 KiB
Python
151 lines
7.9 KiB
Python
import unittest
|
|
|
|
from weknora_access_etl import (ACCESS_TABLES, ADMIN_TARGET_ID, digest, make_plan, migration_sql,
|
|
role_permissions, stable, TARGET_DATABASE, TARGET_TENANT)
|
|
from apply_weknora_access import rollback_sql
|
|
|
|
|
|
class AccessMigrationTests(unittest.TestCase):
|
|
def test_editor_covers_grouped_operations_only(self):
|
|
permissions, _ = role_permissions({"permissions": [
|
|
"read", "feature:tasks", "tasks:read", "tasks:write", "tasks:review", "tasks:deliver",
|
|
"feature:agents", "agents:read", "agents:write", "memories:read", "memories:write", "memories:review",
|
|
"feature:flows", "flows:read", "feature:knowledge", "kb:read", "kb:write",
|
|
"feature:digital-humans", "digital_humans:read", "logs:read", "usage:read"]})
|
|
expected = [module + "." + action for module in
|
|
("content", "flows", "agents", "knowledge", "digital-humans") for action in ("read", "write")]
|
|
self.assertEqual(permissions, sorted(expected + ["content.review", "content.deliver"]))
|
|
|
|
def test_generic_read_does_not_grant_any_module(self):
|
|
self.assertEqual(role_permissions({"permissions": ["read"]})[0], [])
|
|
|
|
def test_legacy_reviewer_cannot_gain_export_or_agent_management(self):
|
|
permissions, notes = role_permissions({"permissions": ["audit:read", "read", "review"]})
|
|
self.assertEqual(permissions, ["content.read", "content.review"])
|
|
self.assertNotIn("content_read_withheld_because_native_read_also_exposes_export", notes)
|
|
|
|
def test_content_delivery_does_not_grant_generation(self):
|
|
self.assertEqual(role_permissions({"permissions": ["tasks:review", "tasks:deliver"]})[0],
|
|
["content.deliver", "content.read", "content.review"])
|
|
self.assertEqual(role_permissions({"permissions": ["tasks:review", "tasks:write"]})[0],
|
|
["content.read", "content.review", "content.write"])
|
|
self.assertEqual(role_permissions({"permissions": ["tasks:read", "tasks:deliver"]})[0], [])
|
|
|
|
def test_agent_write_does_not_infer_memory_review(self):
|
|
permissions, _ = role_permissions({"permissions": ["agents:read", "agents:write", "memories:write"]})
|
|
self.assertIn("agents.read", permissions)
|
|
self.assertNotIn("agents.write", permissions)
|
|
|
|
def test_memory_review_does_not_infer_agent_write(self):
|
|
self.assertEqual(role_permissions({"permissions": ["feature:memories", "memories:review"]})[0], [])
|
|
|
|
def test_old_config_management_does_not_grant_all_settings_or_access(self):
|
|
permissions, _ = role_permissions({"permissions": ["config:write", "admin:manage", "users:manage"]})
|
|
self.assertNotIn("settings.write", permissions)
|
|
self.assertNotIn("access.write", permissions)
|
|
|
|
def test_source_admin_does_not_create_global_admin(self):
|
|
self.assertEqual(role_permissions({"permissions": ["*"]})[0], [])
|
|
|
|
def test_stable_role_identity(self):
|
|
self.assertEqual(stable("editor"), stable("editor"))
|
|
self.assertNotEqual(stable("editor"), stable("viewer"))
|
|
|
|
def plan(self):
|
|
value = {"target_database": TARGET_DATABASE, "tenant_id": TARGET_TENANT,
|
|
"grants": [], "roles": [], "assignments": [], "publications": [], "source_role_definitions": []}
|
|
value["plan_sha256"] = digest(value)
|
|
return value
|
|
|
|
def test_sql_defaults_to_rollback_and_does_not_update_or_delete_native_rows(self):
|
|
sql = migration_sql(self.plan())
|
|
self.assertTrue(sql.rstrip().endswith("ROLLBACK;"))
|
|
self.assertNotIn("UPDATE users", sql)
|
|
self.assertNotIn("UPDATE tenant_members", sql)
|
|
self.assertNotIn("DELETE FROM", sql)
|
|
self.assertIn("m.status='suspended'", sql)
|
|
self.assertIn("Native resource authorization already exists", sql)
|
|
self.assertIn("change->'legacy_source_spec'=saved", sql)
|
|
self.assertIn("change->'mapped_permissions'=spec->'permissions'", sql)
|
|
|
|
def test_tampered_or_wrong_target_plan_is_rejected(self):
|
|
plan = self.plan()
|
|
plan["tenant_id"] = 2
|
|
with self.assertRaises(ValueError):
|
|
migration_sql(plan)
|
|
plan = self.plan()
|
|
plan["roles"] = [{"permissions": ["access.write"]}]
|
|
with self.assertRaises(ValueError):
|
|
migration_sql(plan)
|
|
|
|
def test_explicit_grants_are_not_inferred(self):
|
|
plan = self.plan()
|
|
plan["grants"] = [{"kind": "agents"}]
|
|
plan["plan_sha256"] = digest({k: v for k, v in plan.items() if k != "plan_sha256"})
|
|
with self.assertRaises(ValueError):
|
|
migration_sql(plan)
|
|
|
|
def source_and_target(self):
|
|
role = {"role": "viewer", "organization_id": 1, "label": "Viewer", "permissions": ["read"]}
|
|
source = {"roles": [role], "users": [{"id": 1, "enabled": 1},
|
|
{"id": 3, "enabled": 1, "organization_id": 1, "role": "viewer"}],
|
|
"grants": {}, "memory_count": 0,
|
|
"resources": {key: [] for key in ("agent", "agentskill", "canvas", "knowledgebase", "digitalhuman")}}
|
|
identity = {"source_user_id": 3, "target_user_id": "user3", "user_tenant": TARGET_TENANT,
|
|
"is_active": False, "is_system_admin": False, "can_access_all_tenants": False, "user_deleted": None,
|
|
"source_snapshot": {"role": "viewer"}, "memberships": [
|
|
{"tenant_id": TARGET_TENANT, "role": "contributor", "status": "suspended", "deleted_at": None}]}
|
|
target = {"database": TARGET_DATABASE, "organization": {"source_role_definitions": [role]},
|
|
"identities": [{"source_user_id": 1, "target_user_id": ADMIN_TARGET_ID}, identity],
|
|
"resources": [], "asset_mappings": [], "flow_mappings": [], "access_tables": list(ACCESS_TABLES)}
|
|
return source, target
|
|
|
|
def build(self, source, target):
|
|
return make_plan(source, target, {"tenant_id": TARGET_TENANT}, "snapshot")
|
|
|
|
def test_explicit_deny_all_assignment_is_created_for_viewer(self):
|
|
plan = self.build(*self.source_and_target())
|
|
self.assertEqual(len(plan["assignments"]), 1)
|
|
self.assertEqual(plan["roles"][0]["permissions"], [])
|
|
self.assertEqual(plan["assignments"][0]["role_id"], stable("viewer"))
|
|
|
|
def test_active_migrated_account_is_rejected(self):
|
|
source, target = self.source_and_target()
|
|
target["identities"][1]["is_active"] = True
|
|
with self.assertRaisesRegex(ValueError, "inactive and suspended"):
|
|
self.build(source, target)
|
|
|
|
def test_cross_tenant_membership_is_rejected(self):
|
|
source, target = self.source_and_target()
|
|
target["identities"][1]["memberships"].append(
|
|
{"tenant_id": 9, "role": "viewer", "status": "active", "deleted_at": None})
|
|
with self.assertRaisesRegex(ValueError, "inactive and suspended"):
|
|
self.build(source, target)
|
|
|
|
def test_missing_enabled_identity_is_rejected(self):
|
|
source, target = self.source_and_target()
|
|
target["identities"].pop()
|
|
with self.assertRaisesRegex(ValueError, "lacks a durable identity"):
|
|
self.build(source, target)
|
|
|
|
def test_source_permission_drift_is_rejected(self):
|
|
source, target = self.source_and_target()
|
|
target["organization"]["source_role_definitions"] = []
|
|
with self.assertRaisesRegex(ValueError, "role definitions differ"):
|
|
self.build(source, target)
|
|
|
|
def test_rollback_is_scoped_guarded_and_rehearsal_by_default(self):
|
|
from weknora_access_etl import LEDGER
|
|
state = {table: [] for table in (*ACCESS_TABLES, LEDGER)}
|
|
sql = rollback_sql(self.plan(), state, state)
|
|
self.assertTrue(sql.rstrip().endswith("ROLLBACK;"))
|
|
self.assertIn("Native edits or missing rows", sql)
|
|
self.assertIn("New references to migrated access rows", sql)
|
|
self.assertIn("Account activation changed", sql)
|
|
self.assertNotIn("DELETE FROM users", sql)
|
|
self.assertNotIn("DELETE FROM tenant_members", sql)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|