修复: 收紧授权用途与导入任务匹配

阻断允许用途与禁止用途冲突,并让授权快照外层与内层保持一致。
导入任务按参考作品原文件唯一匹配,避免无关成功任务误阻断回放。
This commit is contained in:
zizi 2026-07-19 22:19:47 +08:00
parent 40c1fa4864
commit 2666d50a8e
7 changed files with 181 additions and 1 deletions

View File

@ -20,6 +20,11 @@ class AuthorizationSnapshotDdlTest(unittest.TestCase):
self.assertIn("source_version", ddl)
self.assertRegex(ddl, r"research_only.*public_domain.*licensed.*unauthorized")
self.assertRegex(ddl, r"jsonb_typeof\(allowed_purpose\)\s*=\s*'array'")
self.assertRegex(ddl, r"jsonb_typeof\(forbidden_purpose\)\s*=\s*'array'")
self.assertRegex(
ddl,
r"CHECK\s*\(example_jsonb_text_arrays_disjoint\(allowed_purpose,\s*forbidden_purpose\)\)",
)
self.assertIn("authorization_basis <> 'user_authorization' OR copyright_status = 'research_only'", ddl)
self.assertIn("allowed_purpose = '[\"offline_evaluation\"]'::jsonb", ddl)
self.assertIsNotNone(re.search(r"RAISE\s+EXCEPTION", ddl, re.IGNORECASE))

View File

@ -213,6 +213,18 @@ def check_authorization(authorization: Mapping[str, Any] | None) -> dict[str, An
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照用途不包含 offline_evaluation"])
if set(snapshot_allowed) != set(allowed):
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照用途与运行用途不一致"])
forbidden = _field(authorization, "forbiddenPurpose", "forbidden_purpose")
if not isinstance(forbidden, list):
return _result(STATUS_BLOCKED_AUTHORIZATION, ["forbiddenPurpose 不是用途列表"])
snapshot_forbidden = snapshot.get("forbiddenPurpose", snapshot.get("forbidden_purpose"))
if not isinstance(snapshot_forbidden, list):
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 forbiddenPurpose 不是用途列表"])
if set(snapshot_forbidden) != set(forbidden):
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照禁止用途与运行禁止用途不一致"])
if "offline_evaluation" in forbidden:
return _result(STATUS_BLOCKED_AUTHORIZATION, ["forbiddenPurpose 禁止 offline_evaluation"])
if set(allowed) & set(forbidden):
return _result(STATUS_BLOCKED_AUTHORIZATION, ["allowedPurpose 与 forbiddenPurpose 存在冲突"])
authorization_basis = str(snapshot.get("authorizationBasis") or "")
if authorization_basis == "user_authorization":
if copyright_status != "research_only":

View File

@ -134,6 +134,7 @@ def project_authorization(
"sourceHash": source_hash,
"sourceVersion": source_version,
"allowedPurpose": [],
"forbiddenPurpose": [],
"authorizationSnapshot": {},
}
if not isinstance(row, Mapping):
@ -172,6 +173,7 @@ def project_authorization(
"sourceHash": source_hash,
"sourceVersion": source_version,
"allowedPurpose": copy.deepcopy(allowed),
"forbiddenPurpose": copy.deepcopy(forbidden),
"authorizationSnapshot": snapshot,
}
@ -583,9 +585,10 @@ def load_reference_rows(
SELECT id,status,command_id,source_snapshot,deleted
FROM muse_content_import_task
WHERE tenant_id=%s AND work_id=%s AND status='succeeded' AND deleted=FALSE
AND source_snapshot->>'file' = %s
ORDER BY id
""",
(tenant_id, work_id),
(tenant_id, work_id, reference.get("source_file")),
).fetchall()
document_rows = conn.execute(
"""

View File

@ -26,6 +26,7 @@ AUTH = {
"sourceHash": "sha256:02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4",
"sourceVersion": "raw-file-v1:sha256:02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4",
"allowedPurpose": ["offline_evaluation"],
"forbiddenPurpose": ["external_distribution"],
"authorizationSnapshot": {
"id": "auth-1",
"version": "v1",
@ -36,6 +37,7 @@ AUTH = {
"copyrightStatus": "research_only",
"authorizationBasis": "user_authorization",
"allowedPurpose": ["offline_evaluation"],
"forbiddenPurpose": ["external_distribution"],
"checkedAt": "2026-07-19T00:00:00Z",
"revalidationAt": "2099-07-20T00:00:00Z",
},
@ -88,6 +90,54 @@ class CheckSnapshotTest(unittest.TestCase):
bad_version = {**AUTH, "sourceVersion": "raw-file-v1:sha256:" + "0" * 64}
self.assertEqual(check_authorization(bad_version)["status"], STATUS_BLOCKED_AUTHORIZATION)
def test_forbidden_purpose_must_be_arrays_and_match_snapshot(self):
outer_not_array = {**AUTH, "forbiddenPurpose": "external_distribution"}
self.assertEqual(check_authorization(outer_not_array)["status"], STATUS_BLOCKED_AUTHORIZATION)
snapshot_not_array = {
**AUTH,
"authorizationSnapshot": {
**AUTH["authorizationSnapshot"],
"forbiddenPurpose": "external_distribution",
},
}
self.assertEqual(check_authorization(snapshot_not_array)["status"], STATUS_BLOCKED_AUTHORIZATION)
mismatch = {
**AUTH,
"authorizationSnapshot": {
**AUTH["authorizationSnapshot"],
"forbiddenPurpose": ["training"],
},
}
self.assertEqual(check_authorization(mismatch)["status"], STATUS_BLOCKED_AUTHORIZATION)
def test_forbidden_purpose_blocks_overlap_and_offline_evaluation(self):
overlapping = {
**AUTH,
"forbiddenPurpose": ["offline_evaluation"],
"authorizationSnapshot": {
**AUTH["authorizationSnapshot"],
"forbiddenPurpose": ["offline_evaluation"],
},
}
self.assertEqual(check_authorization(overlapping)["status"], STATUS_BLOCKED_AUTHORIZATION)
public_domain_overlap = {
**AUTH,
"copyrightStatus": "public_domain",
"allowedPurpose": ["offline_evaluation", "research"],
"forbiddenPurpose": ["research"],
"authorizationSnapshot": {
**AUTH["authorizationSnapshot"],
"copyrightStatus": "public_domain",
"authorizationBasis": "public_domain_record",
"allowedPurpose": ["offline_evaluation", "research"],
"forbiddenPurpose": ["research"],
},
}
self.assertEqual(check_authorization(public_domain_overlap)["status"], STATUS_BLOCKED_AUTHORIZATION)
def test_expired_or_due_revalidation_is_blocked(self):
expired = {
**AUTH,

View File

@ -8,6 +8,7 @@ import inspect
import pathlib
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import load_reference_work as loader # noqa: E402
@ -94,6 +95,66 @@ def card_row(card_id=11126, name="苏铭"):
}
class FakeQueryResult:
"""提供 psycopg 查询结果所需的最小 fetch 接口。"""
def __init__(self, rows):
self.rows = rows if isinstance(rows, list) else [rows]
def fetchone(self):
return self.rows[0] if self.rows else None
def fetchall(self):
return self.rows
class FakeReadOnlyConnection:
"""模拟只读查询,并按 SQL 中的原文件条件过滤导入任务。"""
def __init__(self, import_tasks):
self.import_tasks = import_tasks
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def execute(self, query, params=None):
sql = " ".join(query.split())
if sql.startswith("SET TRANSACTION"):
return FakeQueryResult([])
if "FROM muse_content_work" in sql:
return FakeQueryResult(WORK)
if "FROM example_reference_work" in sql:
return FakeQueryResult([REFERENCE])
if "FROM muse_content_import_task" in sql:
rows = self.import_tasks
if "source_snapshot->>'file' = %s" in sql:
source_file = params[-1]
rows = [
row
for row in rows
if row.get("source_snapshot", {}).get("file") == source_file
]
return FakeQueryResult(rows)
if "FROM muse_knowledge_document" in sql:
return FakeQueryResult([DOCUMENT])
if "FROM example_reference_authorization_snapshot" in sql:
return FakeQueryResult(AUTHORIZATION_ROW)
if "FROM example_parse_outline" in sql:
return FakeQueryResult([])
if "FROM example_parse_scaffold" in sql and "ch.order_no<=%s" in sql:
return FakeQueryResult([])
if "FROM example_parse_scaffold" in sql and "ch.order_no=%s" in sql:
return FakeQueryResult(
{"id": 11, "chapter": 489, "title": "目标", "outline_text": "目标事实"}
)
if "FROM muse_knowledge_draft" in sql:
return FakeQueryResult([card_row(), card_row(11127, "赵宁")])
raise AssertionError(f"未处理 SQL: {sql}")
class LoadReferenceWorkTest(unittest.TestCase):
def setUp(self):
self.source = loader.validate_source_records([REFERENCE], [IMPORT_TASK], [DOCUMENT])
@ -197,6 +258,38 @@ class LoadReferenceWorkTest(unittest.TestCase):
with self.assertRaises(AdapterError):
loader.validate_source_records(references, tasks, documents)
def test_import_task_query_ignores_successful_task_for_other_source_file(self):
unrelated = {
**IMPORT_TASK,
"id": 20,
"source_snapshot": {"file": "无关作品.txt"},
}
connection = FakeReadOnlyConnection([IMPORT_TASK, unrelated])
with patch.object(loader.psycopg, "connect", return_value=connection):
rows = loader.load_reference_rows(
dsn="postgresql://unused",
tenant_id=1,
work_id=8,
as_of=488,
target=489,
card_selection={"correctCardIds": [11126], "placeboCardIds": [11127]},
)
self.assertEqual(rows["source"]["importTaskId"], "19")
def test_import_task_query_blocks_two_tasks_for_same_source_file(self):
duplicate = {**IMPORT_TASK, "id": 20}
connection = FakeReadOnlyConnection([IMPORT_TASK, duplicate])
with patch.object(loader.psycopg, "connect", return_value=connection):
with self.assertRaises(AdapterError):
loader.load_reference_rows(
dsn="postgresql://unused",
tenant_id=1,
work_id=8,
as_of=488,
target=489,
card_selection={"correctCardIds": [11126], "placeboCardIds": [11127]},
)
def test_invalid_hash_fails_closed(self):
with self.assertRaises(AdapterError):
loader.validate_source_records([REFERENCE], [IMPORT_TASK], [{**DOCUMENT, "file_hash": "xyz"}])

View File

@ -24,6 +24,7 @@ AUTH = {
"sourceHash": SOURCE_HASH,
"sourceVersion": SOURCE_VERSION,
"allowedPurpose": ["offline_evaluation"],
"forbiddenPurpose": ["external_distribution"],
"authorizationSnapshot": {
"id": "auth-1",
"version": "v1",
@ -34,6 +35,7 @@ AUTH = {
"copyrightStatus": "research_only",
"authorizationBasis": "user_authorization",
"allowedPurpose": ["offline_evaluation"],
"forbiddenPurpose": ["external_distribution"],
"checkedAt": "2026-07-19T00:00:00Z",
"revalidationAt": "2099-07-20T00:00:00Z",
},

View File

@ -1,5 +1,18 @@
-- example_reference_authorization_snapshot:参考作品用途授权的不可变快照。
-- 本表只允许 INSERT;授权变化通过新增版本表达,禁止覆盖或删除历史证据。
CREATE FUNCTION example_jsonb_text_arrays_disjoint(left_values JSONB, right_values JSONB)
RETURNS BOOLEAN
LANGUAGE sql
IMMUTABLE
STRICT
AS $$
SELECT NOT EXISTS (
SELECT 1
FROM jsonb_array_elements_text(left_values) AS left_value(value)
JOIN jsonb_array_elements_text(right_values) AS right_value(value) USING (value)
);
$$;
CREATE TABLE example_reference_authorization_snapshot (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
reference_work_id BIGINT NOT NULL, -- → example_reference_work.id(软引用)
@ -37,6 +50,8 @@ CREATE TABLE example_reference_authorization_snapshot (
CHECK (jsonb_typeof(allowed_purpose) = 'array'),
CONSTRAINT chk_example_reference_auth_forbidden_purpose
CHECK (jsonb_typeof(forbidden_purpose) = 'array'),
CONSTRAINT chk_example_reference_auth_purpose_disjoint
CHECK (example_jsonb_text_arrays_disjoint(allowed_purpose, forbidden_purpose)),
CONSTRAINT chk_example_reference_auth_basis
CHECK (authorization_basis IN ('user_authorization','public_domain_record','license_contract')),
CONSTRAINT chk_example_reference_auth_basis_copyright