修复: 收紧授权用途与导入任务匹配
阻断允许用途与禁止用途冲突,并让授权快照外层与内层保持一致。 导入任务按参考作品原文件唯一匹配,避免无关成功任务误阻断回放。
This commit is contained in:
parent
40c1fa4864
commit
2666d50a8e
@ -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))
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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(
|
||||
"""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"}])
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user