|
|
@@ -0,0 +1,585 @@
|
|
|
+"""M3-M0 ontology and governance domain service.
|
|
|
+
|
|
|
+The graph is stored in SQLite for zero-infrastructure deployment. Important
|
|
|
+relations are reified as assertions carrying evidence, time, confidence,
|
|
|
+generator and human-review state, so the storage contract can later be mapped
|
|
|
+to RDF or a graph database without changing the API.
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from collections import defaultdict, deque
|
|
|
+from typing import Any, Iterable, Optional
|
|
|
+
|
|
|
+from .db import gen_id, now_utc
|
|
|
+
|
|
|
+
|
|
|
+LAYERS = ("M3", "M2", "M1", "M0")
|
|
|
+PROFILES = ("meta", "upper", "business", "software", "governance", "cross")
|
|
|
+ELEMENT_STATUSES = ("candidate", "published", "deprecated", "retired")
|
|
|
+ASSERTION_TYPES = ("declared", "extracted", "observed", "inferred")
|
|
|
+REVIEW_STATUSES = ("pending", "accepted", "rejected", "expired")
|
|
|
+SEVERITIES = ("low", "medium", "high", "critical")
|
|
|
+_LAYER_RANK = {"M0": 0, "M1": 1, "M2": 2, "M3": 3}
|
|
|
+_JSON_FIELDS = {
|
|
|
+ "ontology_elements": {"attributes": {}},
|
|
|
+ "governance_evidence": {"metadata": {}},
|
|
|
+ "governance_assertions": {"evidence_ids": []},
|
|
|
+ "governance_rules": {"expression": {}},
|
|
|
+ "governance_issues": {"evidence_ids": []},
|
|
|
+ "governance_agent_runs": {"result_json": {}, "trace_json": []},
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+class GovernanceError(ValueError):
|
|
|
+ def __init__(self, message: str, *, code: str = "INVALID_GOVERNANCE_DATA", status: int = 400):
|
|
|
+ super().__init__(message)
|
|
|
+ self.code = code
|
|
|
+ self.status = status
|
|
|
+
|
|
|
+
|
|
|
+def _dump(value: Any) -> str:
|
|
|
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
|
+
|
|
|
+
|
|
|
+def _loads(value: Any, default: Any) -> Any:
|
|
|
+ if value in (None, ""):
|
|
|
+ return default
|
|
|
+ if isinstance(value, (list, dict)):
|
|
|
+ return value
|
|
|
+ try:
|
|
|
+ return json.loads(value)
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ return default
|
|
|
+
|
|
|
+
|
|
|
+def row(table: str, value: Optional[dict]) -> Optional[dict]:
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
+ result = dict(value)
|
|
|
+ for field, default in _JSON_FIELDS.get(table, {}).items():
|
|
|
+ result[field] = _loads(result.get(field), default.copy())
|
|
|
+ if table == "governance_rules":
|
|
|
+ result["enabled"] = bool(result.get("enabled"))
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def rows(table: str, values: Iterable[dict]) -> list[dict]:
|
|
|
+ return [row(table, value) for value in values] # type: ignore[list-item]
|
|
|
+
|
|
|
+
|
|
|
+def require_project(db, tenant_id: str, project_id: str) -> dict:
|
|
|
+ project = db.fetchone(
|
|
|
+ "SELECT * FROM governance_projects WHERE id=? AND tenant_id=?", (project_id, tenant_id)
|
|
|
+ )
|
|
|
+ if not project:
|
|
|
+ raise GovernanceError("本体项目不存在", code="PROJECT_NOT_FOUND", status=404)
|
|
|
+ return project
|
|
|
+
|
|
|
+
|
|
|
+def create_project(db, tenant_id: str, data: dict) -> dict:
|
|
|
+ name = str(data.get("name", "")).strip()
|
|
|
+ namespace = str(data.get("namespace", "")).strip().rstrip("#/")
|
|
|
+ if not name or not namespace:
|
|
|
+ raise GovernanceError("name 和 namespace 不能为空")
|
|
|
+ existing = db.fetchone(
|
|
|
+ "SELECT * FROM governance_projects WHERE tenant_id=? AND namespace=?", (tenant_id, namespace)
|
|
|
+ )
|
|
|
+ if existing:
|
|
|
+ return existing
|
|
|
+ project_id, now = gen_id("gp_"), now_utc()
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO governance_projects "
|
|
|
+ "(id,tenant_id,name,description,namespace,version,status,created_at,updated_at) "
|
|
|
+ "VALUES (?,?,?,?,?,?,?,?,?)",
|
|
|
+ (project_id, tenant_id, name, str(data.get("description", "")), namespace,
|
|
|
+ str(data.get("version", "1.0.0")), str(data.get("status", "active")), now, now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return require_project(db, tenant_id, project_id)
|
|
|
+
|
|
|
+
|
|
|
+def list_projects(db, tenant_id: str) -> list[dict]:
|
|
|
+ return db.fetchall(
|
|
|
+ "SELECT * FROM governance_projects WHERE tenant_id=? ORDER BY updated_at DESC", (tenant_id,)
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def get_element(db, tenant_id: str, element_id: str) -> dict:
|
|
|
+ value = db.fetchone("SELECT * FROM ontology_elements WHERE id=? AND tenant_id=?", (element_id, tenant_id))
|
|
|
+ if not value:
|
|
|
+ raise GovernanceError("本体元素不存在", code="ELEMENT_NOT_FOUND", status=404)
|
|
|
+ return row("ontology_elements", value) # type: ignore[return-value]
|
|
|
+
|
|
|
+
|
|
|
+def upsert_element(db, tenant_id: str, project_id: str, data: dict) -> dict:
|
|
|
+ project = require_project(db, tenant_id, project_id)
|
|
|
+ layer = str(data.get("layer", "")).upper()
|
|
|
+ profile = str(data.get("profile", "")).lower()
|
|
|
+ status = str(data.get("status", "candidate")).lower()
|
|
|
+ name, kind = str(data.get("name", "")).strip(), str(data.get("kind", "")).strip()
|
|
|
+ if layer not in LAYERS:
|
|
|
+ raise GovernanceError(f"未知元建模层级: {layer}")
|
|
|
+ if profile not in PROFILES:
|
|
|
+ raise GovernanceError(f"未知 Profile: {profile}")
|
|
|
+ if status not in ELEMENT_STATUSES:
|
|
|
+ raise GovernanceError(f"未知元素状态: {status}")
|
|
|
+ if not name or not kind:
|
|
|
+ raise GovernanceError("元素 name 和 kind 不能为空")
|
|
|
+ if layer == "M3" and profile != "meta":
|
|
|
+ raise GovernanceError("M3 元素必须属于 meta Profile")
|
|
|
+ if layer == "M2" and profile == "meta":
|
|
|
+ raise GovernanceError("M2 元素应属于 upper 或领域 Profile")
|
|
|
+ uri = str(data.get("uri", "")).strip() or f"{project['namespace']}/{layer.lower()}/{profile}/{name}"
|
|
|
+ existing = db.fetchone(
|
|
|
+ "SELECT id FROM ontology_elements WHERE tenant_id=? AND project_id=? AND uri=?",
|
|
|
+ (tenant_id, project_id, uri),
|
|
|
+ )
|
|
|
+ element_id, now = (existing["id"] if existing else str(data.get("id") or gen_id("oe_"))), now_utc()
|
|
|
+ values = (
|
|
|
+ layer, profile, kind, name, str(data.get("description", "")), status,
|
|
|
+ str(data.get("version", "1.0.0")), _dump(data.get("attributes") or {}),
|
|
|
+ str(data.get("source", "")), now,
|
|
|
+ )
|
|
|
+ if existing:
|
|
|
+ db.execute(
|
|
|
+ "UPDATE ontology_elements SET layer=?,profile=?,kind=?,name=?,description=?,status=?,"
|
|
|
+ "version=?,attributes=?,source=?,updated_at=? WHERE id=? AND tenant_id=?",
|
|
|
+ (*values, element_id, tenant_id),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO ontology_elements "
|
|
|
+ "(id,tenant_id,project_id,uri,layer,profile,kind,name,description,status,version,attributes,source,created_at,updated_at) "
|
|
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
|
+ (element_id, tenant_id, project_id, uri, *values[:-1], now, now),
|
|
|
+ )
|
|
|
+ db.execute("UPDATE governance_projects SET updated_at=? WHERE id=?", (now, project_id))
|
|
|
+ db.commit()
|
|
|
+ return get_element(db, tenant_id, element_id)
|
|
|
+
|
|
|
+
|
|
|
+def list_elements(db, tenant_id: str, project_id: str, *, layer: str = "", profile: str = "", query: str = "", limit: int = 500) -> dict:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ where, params = ["tenant_id=?", "project_id=?"], [tenant_id, project_id]
|
|
|
+ if layer:
|
|
|
+ where.append("layer=?"); params.append(layer.upper())
|
|
|
+ if profile:
|
|
|
+ where.append("profile=?"); params.append(profile.lower())
|
|
|
+ if query:
|
|
|
+ where.append("(name LIKE ? OR uri LIKE ? OR description LIKE ?)")
|
|
|
+ params.extend([f"%{query}%"] * 3)
|
|
|
+ clause = " AND ".join(where)
|
|
|
+ total = db.fetchone(f"SELECT COUNT(*) AS n FROM ontology_elements WHERE {clause}", params)["n"]
|
|
|
+ values = db.fetchall(
|
|
|
+ f"SELECT * FROM ontology_elements WHERE {clause} ORDER BY "
|
|
|
+ "CASE layer WHEN 'M3' THEN 0 WHEN 'M2' THEN 1 WHEN 'M1' THEN 2 ELSE 3 END,profile,name LIMIT ?",
|
|
|
+ [*params, min(max(int(limit), 1), 1000)],
|
|
|
+ )
|
|
|
+ return {"items": rows("ontology_elements", values), "total": total}
|
|
|
+
|
|
|
+
|
|
|
+def create_evidence(db, tenant_id: str, project_id: str, data: dict) -> dict:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ title, uri = str(data.get("title", "")).strip(), str(data.get("uri", "")).strip()
|
|
|
+ if not title:
|
|
|
+ raise GovernanceError("证据 title 不能为空")
|
|
|
+ if uri:
|
|
|
+ existing = db.fetchone(
|
|
|
+ "SELECT * FROM governance_evidence WHERE tenant_id=? AND project_id=? AND uri=?",
|
|
|
+ (tenant_id, project_id, uri),
|
|
|
+ )
|
|
|
+ if existing:
|
|
|
+ return row("governance_evidence", existing) # type: ignore[return-value]
|
|
|
+ evidence_id, now, content = gen_id("ev_"), now_utc(), str(data.get("content", ""))
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO governance_evidence "
|
|
|
+ "(id,tenant_id,project_id,kind,uri,title,content,content_hash,source,observed_at,valid_from,valid_to,metadata,created_at) "
|
|
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
|
+ (evidence_id, tenant_id, project_id, str(data.get("kind", "SourceArtifact")), uri, title,
|
|
|
+ content, hashlib.sha256(content.encode()).hexdigest(), str(data.get("source", "")),
|
|
|
+ str(data.get("observed_at") or now), data.get("valid_from") or now, data.get("valid_to"),
|
|
|
+ _dump(data.get("metadata") or {}), now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return row("governance_evidence", db.fetchone("SELECT * FROM governance_evidence WHERE id=?", (evidence_id,))) # type: ignore[return-value]
|
|
|
+
|
|
|
+
|
|
|
+def list_evidence(db, tenant_id: str, project_id: str, limit: int = 500) -> list[dict]:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ return rows("governance_evidence", db.fetchall(
|
|
|
+ "SELECT * FROM governance_evidence WHERE tenant_id=? AND project_id=? ORDER BY observed_at DESC LIMIT ?",
|
|
|
+ (tenant_id, project_id, min(max(limit, 1), 1000)),
|
|
|
+ ))
|
|
|
+
|
|
|
+
|
|
|
+def get_assertion(db, tenant_id: str, assertion_id: str) -> dict:
|
|
|
+ value = db.fetchone(
|
|
|
+ "SELECT * FROM governance_assertions WHERE id=? AND tenant_id=?", (assertion_id, tenant_id)
|
|
|
+ )
|
|
|
+ if not value:
|
|
|
+ raise GovernanceError("断言不存在", code="ASSERTION_NOT_FOUND", status=404)
|
|
|
+ return row("governance_assertions", value) # type: ignore[return-value]
|
|
|
+
|
|
|
+
|
|
|
+def create_assertion(db, tenant_id: str, project_id: str, data: dict) -> dict:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ subject = get_element(db, tenant_id, str(data.get("subject_id", "")))
|
|
|
+ if subject["project_id"] != project_id:
|
|
|
+ raise GovernanceError("断言 subject 不属于当前项目")
|
|
|
+ object_id = str(data.get("object_id") or "")
|
|
|
+ if object_id and get_element(db, tenant_id, object_id)["project_id"] != project_id:
|
|
|
+ raise GovernanceError("断言 object 不属于当前项目")
|
|
|
+ predicate = str(data.get("predicate", "")).strip().upper()
|
|
|
+ assertion_type = str(data.get("assertion_type", "inferred")).lower()
|
|
|
+ review_status = str(data.get("review_status", "pending")).lower()
|
|
|
+ confidence = float(data.get("confidence", 0))
|
|
|
+ if not predicate or assertion_type not in ASSERTION_TYPES or review_status not in REVIEW_STATUSES:
|
|
|
+ raise GovernanceError("断言 predicate、类型或审核状态不合法")
|
|
|
+ if not 0 <= confidence <= 1:
|
|
|
+ raise GovernanceError("confidence 必须在 0 到 1 之间")
|
|
|
+ evidence_ids = list(dict.fromkeys(data.get("evidence_ids") or []))
|
|
|
+ for evidence_id in evidence_ids:
|
|
|
+ if not db.fetchone(
|
|
|
+ "SELECT id FROM governance_evidence WHERE id=? AND tenant_id=? AND project_id=?",
|
|
|
+ (evidence_id, tenant_id, project_id),
|
|
|
+ ):
|
|
|
+ raise GovernanceError(f"证据不存在: {evidence_id}")
|
|
|
+ object_value = str(data.get("object_value", ""))
|
|
|
+ existing = db.fetchone(
|
|
|
+ "SELECT * FROM governance_assertions WHERE tenant_id=? AND project_id=? AND subject_id=? "
|
|
|
+ "AND predicate=? AND COALESCE(object_id,'')=? AND COALESCE(object_value,'')=?",
|
|
|
+ (tenant_id, project_id, subject["id"], predicate, object_id, object_value),
|
|
|
+ )
|
|
|
+ if existing:
|
|
|
+ return row("governance_assertions", existing) # type: ignore[return-value]
|
|
|
+ assertion_id, now = gen_id("as_"), now_utc()
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO governance_assertions "
|
|
|
+ "(id,tenant_id,project_id,subject_id,predicate,object_id,object_value,assertion_type,confidence,"
|
|
|
+ "evidence_ids,generated_by,valid_from,valid_to,review_status,reviewer,review_note,created_at,updated_at) "
|
|
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
|
+ (assertion_id, tenant_id, project_id, subject["id"], predicate, object_id or None, object_value,
|
|
|
+ assertion_type, confidence, _dump(evidence_ids), str(data.get("generated_by", "")),
|
|
|
+ data.get("valid_from") or now, data.get("valid_to"), review_status,
|
|
|
+ str(data.get("reviewer", "")), str(data.get("review_note", "")), now, now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return get_assertion(db, tenant_id, assertion_id)
|
|
|
+
|
|
|
+
|
|
|
+def list_assertions(db, tenant_id: str, project_id: str, *, review_status: str = "", limit: int = 500) -> list[dict]:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ where, params = ["a.tenant_id=?", "a.project_id=?"], [tenant_id, project_id]
|
|
|
+ if review_status:
|
|
|
+ where.append("a.review_status=?"); params.append(review_status)
|
|
|
+ values = db.fetchall(
|
|
|
+ "SELECT a.*,s.name AS subject_name,o.name AS object_name FROM governance_assertions a "
|
|
|
+ "JOIN ontology_elements s ON s.id=a.subject_id AND s.tenant_id=a.tenant_id "
|
|
|
+ "LEFT JOIN ontology_elements o ON o.id=a.object_id AND o.tenant_id=a.tenant_id "
|
|
|
+ f"WHERE {' AND '.join(where)} ORDER BY a.updated_at DESC LIMIT ?",
|
|
|
+ [*params, min(max(limit, 1), 1000)],
|
|
|
+ )
|
|
|
+ return rows("governance_assertions", values)
|
|
|
+
|
|
|
+
|
|
|
+def review_assertion(db, tenant_id: str, assertion_id: str, status: str, reviewer: str, note: str = "") -> dict:
|
|
|
+ get_assertion(db, tenant_id, assertion_id)
|
|
|
+ if status not in ("accepted", "rejected", "expired") or not reviewer.strip():
|
|
|
+ raise GovernanceError("审核状态或审核人不合法")
|
|
|
+ db.execute(
|
|
|
+ "UPDATE governance_assertions SET review_status=?,reviewer=?,review_note=?,updated_at=? WHERE id=? AND tenant_id=?",
|
|
|
+ (status, reviewer.strip(), note, now_utc(), assertion_id, tenant_id),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return get_assertion(db, tenant_id, assertion_id)
|
|
|
+
|
|
|
+
|
|
|
+def upsert_rule(db, tenant_id: str, project_id: str, data: dict) -> dict:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ name, severity = str(data.get("name", "")).strip(), str(data.get("severity", "medium")).lower()
|
|
|
+ if not name or severity not in SEVERITIES:
|
|
|
+ raise GovernanceError("规则名称或严重级别不合法")
|
|
|
+ existing = db.fetchone(
|
|
|
+ "SELECT id FROM governance_rules WHERE tenant_id=? AND project_id=? AND name=?", (tenant_id, project_id, name)
|
|
|
+ )
|
|
|
+ rule_id, now = (existing["id"] if existing else gen_id("gr_")), now_utc()
|
|
|
+ if existing:
|
|
|
+ db.execute(
|
|
|
+ "UPDATE governance_rules SET description=?,rule_type=?,expression=?,severity=?,enabled=?,version=?,updated_at=? "
|
|
|
+ "WHERE id=? AND tenant_id=?",
|
|
|
+ (str(data.get("description", "")), str(data.get("rule_type", "integrity")),
|
|
|
+ _dump(data.get("expression") or {}), severity, 1 if data.get("enabled", True) else 0,
|
|
|
+ str(data.get("version", "1.0.0")), now, rule_id, tenant_id),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO governance_rules "
|
|
|
+ "(id,tenant_id,project_id,name,description,rule_type,expression,severity,enabled,version,created_at,updated_at) "
|
|
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
|
+ (rule_id, tenant_id, project_id, name, str(data.get("description", "")),
|
|
|
+ str(data.get("rule_type", "integrity")), _dump(data.get("expression") or {}), severity,
|
|
|
+ 1 if data.get("enabled", True) else 0, str(data.get("version", "1.0.0")), now, now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return row("governance_rules", db.fetchone("SELECT * FROM governance_rules WHERE id=?", (rule_id,))) # type: ignore[return-value]
|
|
|
+
|
|
|
+
|
|
|
+def list_rules(db, tenant_id: str, project_id: str) -> list[dict]:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ return rows("governance_rules", db.fetchall(
|
|
|
+ "SELECT * FROM governance_rules WHERE tenant_id=? AND project_id=? ORDER BY severity DESC,name",
|
|
|
+ (tenant_id, project_id),
|
|
|
+ ))
|
|
|
+
|
|
|
+
|
|
|
+def _finding(code: str, severity: str, title: str, description: str, target_id: str = "", assertion_id: str = "") -> dict:
|
|
|
+ return {"code": code, "severity": severity, "title": title, "description": description,
|
|
|
+ "target_id": target_id, "assertion_id": assertion_id}
|
|
|
+
|
|
|
+
|
|
|
+def validate_project(db, tenant_id: str, project_id: str, *, persist: bool = True) -> dict:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ elements = rows("ontology_elements", db.fetchall(
|
|
|
+ "SELECT * FROM ontology_elements WHERE tenant_id=? AND project_id=?", (tenant_id, project_id)
|
|
|
+ ))
|
|
|
+ assertions = rows("governance_assertions", db.fetchall(
|
|
|
+ "SELECT * FROM governance_assertions WHERE tenant_id=? AND project_id=?", (tenant_id, project_id)
|
|
|
+ ))
|
|
|
+ by_id = {item["id"]: item for item in elements}
|
|
|
+ outgoing, incoming, findings = defaultdict(list), defaultdict(list), []
|
|
|
+ for assertion in assertions:
|
|
|
+ outgoing[assertion["subject_id"]].append(assertion)
|
|
|
+ if assertion.get("object_id"):
|
|
|
+ incoming[assertion["object_id"]].append(assertion)
|
|
|
+ subject, obj = by_id.get(assertion["subject_id"]), by_id.get(assertion.get("object_id"))
|
|
|
+ if assertion["predicate"] == "INSTANCE_OF" and subject and obj:
|
|
|
+ if _LAYER_RANK[obj["layer"]] != _LAYER_RANK[subject["layer"]] + 1:
|
|
|
+ findings.append(_finding("LAYER_INSTANCE_MISMATCH", "high", "instanceOf 层级不连续",
|
|
|
+ f"{subject['name']} 必须实例化相邻上层,当前指向 {obj['name']}", subject["id"], assertion["id"]))
|
|
|
+ if assertion["predicate"] in ("SUB_TYPE_OF", "SUBCLASS_OF") and subject and obj and subject["layer"] != obj["layer"]:
|
|
|
+ findings.append(_finding("SUBTYPE_CROSSES_LAYER", "high", "subTypeOf 不能跨元层",
|
|
|
+ f"{subject['name']} 与 {obj['name']} 不在同一层", subject["id"], assertion["id"]))
|
|
|
+ if assertion["assertion_type"] == "inferred" and assertion["predicate"] in {
|
|
|
+ "DELETE", "GRANT", "AUTHORIZE", "DOWNGRADE", "PUBLISH", "RETIRE",
|
|
|
+ }:
|
|
|
+ findings.append(_finding("INFERRED_HIGH_RISK_ACTION", "critical", "推断不能触发高风险动作",
|
|
|
+ "必须经过确定性证据与人工审批", assertion["subject_id"], assertion["id"]))
|
|
|
+ if assertion["review_status"] == "accepted" and assertion["assertion_type"] != "declared":
|
|
|
+ if not assertion.get("evidence_ids") or not assertion.get("generated_by"):
|
|
|
+ findings.append(_finding("ACCEPTED_ASSERTION_WITHOUT_PROVENANCE", "high", "正式断言缺少来源",
|
|
|
+ "accepted 的非声明断言必须关联证据和生成者", assertion["subject_id"], assertion["id"]))
|
|
|
+ for element in elements:
|
|
|
+ accepted_out = [a for a in outgoing[element["id"]] if a["review_status"] == "accepted"]
|
|
|
+ accepted_in = [a for a in incoming[element["id"]] if a["review_status"] == "accepted"]
|
|
|
+ if element["layer"] == "M0" and element["status"] == "published" and not any(a["predicate"] == "INSTANCE_OF" for a in accepted_out):
|
|
|
+ findings.append(_finding("M0_UNKNOWN_TYPE", "high", "M0 事实未归类", "已发布 M0 必须实例化 M1 类型", element["id"]))
|
|
|
+ if element["layer"] == "M1" and element["kind"] == "BusinessCapability" and not any(a["predicate"] == "REALIZED_BY" for a in accepted_out):
|
|
|
+ findings.append(_finding("CAPABILITY_NOT_REALIZED", "high", "业务能力缺少软件实现", "至少需要一个 REALIZED_BY", element["id"]))
|
|
|
+ if element["layer"] == "M1" and element["kind"] == "BusinessObject" and not any(a["predicate"] == "REPRESENTS" for a in accepted_in):
|
|
|
+ findings.append(_finding("BUSINESS_OBJECT_NOT_REPRESENTED", "high", "业务对象缺少技术表达", "至少需要一个 REPRESENTS", element["id"]))
|
|
|
+ attrs = element.get("attributes") or {}
|
|
|
+ if element["profile"] == "software" and attrs.get("environment") == "production" and not any(a["predicate"] == "OWNED_BY" for a in accepted_out):
|
|
|
+ findings.append(_finding("PRODUCTION_ASSET_WITHOUT_OWNER", "critical", "生产资产缺少责任人", "必须关联 Owner/Steward", element["id"]))
|
|
|
+ if element["kind"] in ("Column", "PhysicalColumn") and attrs.get("sensitive"):
|
|
|
+ policies = [by_id.get(a.get("object_id"), {}) for a in accepted_out if a["predicate"] == "GOVERNED_BY"]
|
|
|
+ if not any(p.get("kind") in ("AccessPolicy", "SecurityPolicy") for p in policies):
|
|
|
+ findings.append(_finding("SENSITIVE_FIELD_WITHOUT_POLICY", "critical", "敏感字段缺少访问策略", "必须关联访问策略", element["id"]))
|
|
|
+ if persist:
|
|
|
+ db.execute("DELETE FROM governance_issues WHERE tenant_id=? AND project_id=? AND rule_id LIKE 'validation:%'", (tenant_id, project_id))
|
|
|
+ now = now_utc()
|
|
|
+ for item in findings:
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO governance_issues "
|
|
|
+ "(id,tenant_id,project_id,rule_id,target_id,assertion_id,severity,title,description,status,evidence_ids,assigned_to,created_at,updated_at) "
|
|
|
+ "VALUES (?,?,?,?,?,?,?,?,?,'open','[]','',?,?)",
|
|
|
+ (gen_id("gi_"), tenant_id, project_id, f"validation:{item['code']}", item["target_id"],
|
|
|
+ item["assertion_id"], item["severity"], item["title"], item["description"], now, now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ counts = {severity: sum(1 for item in findings if item["severity"] == severity) for severity in SEVERITIES}
|
|
|
+ return {"valid": not any(item["severity"] in ("high", "critical") for item in findings),
|
|
|
+ "findings": findings, "counts": counts,
|
|
|
+ "checked": {"elements": len(elements), "assertions": len(assertions)}}
|
|
|
+
|
|
|
+
|
|
|
+def list_issues(db, tenant_id: str, project_id: str, status: str = "open") -> list[dict]:
|
|
|
+ require_project(db, tenant_id, project_id)
|
|
|
+ where, params = ["tenant_id=?", "project_id=?"], [tenant_id, project_id]
|
|
|
+ if status:
|
|
|
+ where.append("status=?"); params.append(status)
|
|
|
+ return rows("governance_issues", db.fetchall(
|
|
|
+ f"SELECT * FROM governance_issues WHERE {' AND '.join(where)} ORDER BY "
|
|
|
+ "CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,created_at DESC", params
|
|
|
+ ))
|
|
|
+
|
|
|
+
|
|
|
+def update_issue(db, tenant_id: str, issue_id: str, status: str, assigned_to: str = "") -> dict:
|
|
|
+ if status not in ("open", "acknowledged", "resolved", "waived"):
|
|
|
+ raise GovernanceError("未知问题状态")
|
|
|
+ if not db.fetchone("SELECT id FROM governance_issues WHERE id=? AND tenant_id=?", (issue_id, tenant_id)):
|
|
|
+ raise GovernanceError("治理问题不存在", code="ISSUE_NOT_FOUND", status=404)
|
|
|
+ now = now_utc()
|
|
|
+ db.execute(
|
|
|
+ "UPDATE governance_issues SET status=?,assigned_to=?,updated_at=?,resolved_at=? WHERE id=? AND tenant_id=?",
|
|
|
+ (status, assigned_to, now, now if status in ("resolved", "waived") else None, issue_id, tenant_id),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+ return row("governance_issues", db.fetchone("SELECT * FROM governance_issues WHERE id=?", (issue_id,))) # type: ignore[return-value]
|
|
|
+
|
|
|
+
|
|
|
+def graph(db, tenant_id: str, project_id: str, *, include_pending: bool = True) -> dict:
|
|
|
+ nodes = list_elements(db, tenant_id, project_id, limit=1000)["items"]
|
|
|
+ statuses = ("accepted", "pending") if include_pending else ("accepted",)
|
|
|
+ placeholders = ",".join("?" for _ in statuses)
|
|
|
+ values = db.fetchall(
|
|
|
+ "SELECT a.*,s.name AS subject_name,o.name AS object_name FROM governance_assertions a "
|
|
|
+ "JOIN ontology_elements s ON s.id=a.subject_id AND s.tenant_id=a.tenant_id "
|
|
|
+ "LEFT JOIN ontology_elements o ON o.id=a.object_id AND o.tenant_id=a.tenant_id "
|
|
|
+ f"WHERE a.tenant_id=? AND a.project_id=? AND a.review_status IN ({placeholders}) ORDER BY a.created_at LIMIT 2000",
|
|
|
+ (tenant_id, project_id, *statuses),
|
|
|
+ )
|
|
|
+ return {"nodes": nodes, "edges": rows("governance_assertions", values)}
|
|
|
+
|
|
|
+
|
|
|
+def impact(db, tenant_id: str, element_id: str, max_depth: int = 4) -> dict:
|
|
|
+ center = get_element(db, tenant_id, element_id)
|
|
|
+ graph_data = graph(db, tenant_id, center["project_id"], include_pending=False)
|
|
|
+ by_id = {item["id"]: item for item in graph_data["nodes"]}
|
|
|
+ adjacency = defaultdict(list)
|
|
|
+ for edge in graph_data["edges"]:
|
|
|
+ if edge.get("object_id"):
|
|
|
+ adjacency[edge["subject_id"]].append((edge["object_id"], edge))
|
|
|
+ adjacency[edge["object_id"]].append((edge["subject_id"], edge))
|
|
|
+ depth_limit, queue, visited, used = min(max(int(max_depth), 1), 8), deque([(element_id, 0)]), {element_id: 0}, {}
|
|
|
+ while queue:
|
|
|
+ current, depth = queue.popleft()
|
|
|
+ if depth >= depth_limit:
|
|
|
+ continue
|
|
|
+ for neighbour, edge in adjacency[current]:
|
|
|
+ used[edge["id"]] = edge
|
|
|
+ if neighbour not in visited:
|
|
|
+ visited[neighbour] = depth + 1; queue.append((neighbour, depth + 1))
|
|
|
+ nodes = [{**by_id[node_id], "distance": distance} for node_id, distance in visited.items() if node_id in by_id]
|
|
|
+ nodes.sort(key=lambda item: (item["distance"], item["layer"], item["name"]))
|
|
|
+ return {"center": center, "nodes": nodes, "edges": list(used.values()), "max_depth": depth_limit}
|
|
|
+
|
|
|
+
|
|
|
+def overview(db, tenant_id: str, project_id: str) -> dict:
|
|
|
+ project = require_project(db, tenant_id, project_id)
|
|
|
+ layers = {name: 0 for name in LAYERS}
|
|
|
+ profiles = {name: 0 for name in PROFILES}
|
|
|
+ for item in db.fetchall("SELECT layer,COUNT(*) n FROM ontology_elements WHERE tenant_id=? AND project_id=? GROUP BY layer", (tenant_id, project_id)):
|
|
|
+ layers[item["layer"]] = item["n"]
|
|
|
+ for item in db.fetchall("SELECT profile,COUNT(*) n FROM ontology_elements WHERE tenant_id=? AND project_id=? GROUP BY profile", (tenant_id, project_id)):
|
|
|
+ profiles[item["profile"]] = item["n"]
|
|
|
+ def count(table: str, suffix: str = "") -> int:
|
|
|
+ return int(db.fetchone(f"SELECT COUNT(*) n FROM {table} WHERE tenant_id=? AND project_id=? {suffix}", (tenant_id, project_id))["n"])
|
|
|
+ return {"project": project, "layers": layers, "profiles": profiles, "totals": {
|
|
|
+ "elements": sum(layers.values()), "assertions": count("governance_assertions"),
|
|
|
+ "pending_reviews": count("governance_assertions", "AND review_status='pending'"),
|
|
|
+ "evidence": count("governance_evidence"), "rules": count("governance_rules", "AND enabled=1"),
|
|
|
+ "open_issues": count("governance_issues", "AND status='open'"), "agent_runs": count("governance_agent_runs"),
|
|
|
+ }}
|
|
|
+
|
|
|
+
|
|
|
+def export_jsonld(db, tenant_id: str, project_id: str) -> dict:
|
|
|
+ project = require_project(db, tenant_id, project_id)
|
|
|
+ data = graph(db, tenant_id, project_id)
|
|
|
+ values = []
|
|
|
+ for item in data["nodes"]:
|
|
|
+ values.append({"@id": item["uri"], "@type": item["kind"], "name": item["name"],
|
|
|
+ "layer": item["layer"], "profile": item["profile"], "status": item["status"], **item["attributes"]})
|
|
|
+ uri_by_id = {item["id"]: item["uri"] for item in data["nodes"]}
|
|
|
+ for assertion in data["edges"]:
|
|
|
+ values.append({"@id": f"urn:assertion:{assertion['id']}", "@type": "Assertion",
|
|
|
+ "subject": {"@id": uri_by_id[assertion["subject_id"]]}, "predicate": assertion["predicate"],
|
|
|
+ "object": {"@id": uri_by_id.get(assertion.get("object_id"), "")} if assertion.get("object_id") else assertion["object_value"],
|
|
|
+ "assertionType": assertion["assertion_type"], "confidence": assertion["confidence"],
|
|
|
+ "reviewStatus": assertion["review_status"], "evidence": assertion["evidence_ids"]})
|
|
|
+ return {"@context": {"name": "http://www.w3.org/2000/01/rdf-schema#label",
|
|
|
+ "layer": "https://onto.qm.cn/ontorefactor/meta/1.0#layer",
|
|
|
+ "profile": "https://onto.qm.cn/ontorefactor/meta/1.0#profile"},
|
|
|
+ "@id": project["namespace"], "@graph": values}
|
|
|
+
|
|
|
+
|
|
|
+def seed_tenant_isolation(db, tenant_id: str) -> dict:
|
|
|
+ """Install the idempotent, valid M3-M0 tenant-isolation reference slice."""
|
|
|
+ project = create_project(db, tenant_id, {"name": "DCP 租户与组织域", "namespace": "https://onto.qm.cn/ontorefactor/model/dcp/1.0",
|
|
|
+ "description": "业务、软件、治理 Profile 的 M3-M0 纵向样例"})
|
|
|
+ pid, nodes = project["id"], {}
|
|
|
+ def node(key: str, layer: str, profile: str, kind: str, description: str = "", attrs: Optional[dict] = None, uri: str = ""):
|
|
|
+ nodes[key] = upsert_element(db, tenant_id, pid, {"layer": layer, "profile": profile, "kind": kind,
|
|
|
+ "name": key, "description": description, "attributes": attrs or {}, "status": "published", "uri": uri,
|
|
|
+ "source": "builtin:tenant-isolation"})
|
|
|
+ for name in ("MetaElement", "MetaEntityType", "MetaRelationType", "MetaAttributeType", "MetaConstraintType", "MetaAssertionType", "MetaProfile", "MetaView"):
|
|
|
+ node(name, "M3", "meta", name, "稳定的 M3 建模内核", uri=f"https://onto.qm.cn/ontorefactor/meta/1.0#{name}")
|
|
|
+ for name in ("Object", "Subject", "Activity", "Rule", "Result", "Evidence", "Assertion", "Term", "State", "TimeExtent"):
|
|
|
+ node(name, "M2", "upper", "EntityType", "M2 通用上层概念", uri=f"https://onto.qm.cn/ontorefactor/upper/1.0#{name}")
|
|
|
+ profile_types = {
|
|
|
+ "business": ("BusinessCapabilityType", "BusinessObjectType", "BusinessRuleType", "BusinessEventType"),
|
|
|
+ "software": ("ModuleType", "ClassType", "ApiOperationType", "TableType", "ColumnType", "EventContractType"),
|
|
|
+ "governance": ("OwnerType", "AccessPolicyType", "QualityRuleType", "QualityGateType"),
|
|
|
+ }
|
|
|
+ for profile, names in profile_types.items():
|
|
|
+ for name in names:
|
|
|
+ node(name, "M2", profile, "EntityType", f"M2 {profile} Profile 类型", uri=f"https://onto.qm.cn/ontorefactor/{profile}/1.0#{name}")
|
|
|
+ m1 = (
|
|
|
+ ("TenantManagement", "business", "BusinessCapability", {}), ("Tenant", "business", "BusinessObject", {"core": True}),
|
|
|
+ ("TenantIsolation", "business", "BusinessRule", {}), ("BaseIAM", "software", "Module", {"environment": "production"}),
|
|
|
+ ("TenantController", "software", "Class", {}), ("CreateTenantAPI", "software", "ApiOperation", {}),
|
|
|
+ ("DcpTenantTable", "software", "Table", {"environment": "production"}),
|
|
|
+ ("TenantIdColumn", "software", "Column", {"environment": "production", "sensitive": True}),
|
|
|
+ ("TenantCreatedTopic", "software", "EventContract", {}), ("IAMDataOwner", "governance", "OwnerRole", {}),
|
|
|
+ ("TenantIsolationPolicy", "governance", "AccessPolicy", {}),
|
|
|
+ ("TenantIdCompleteness", "governance", "QualityRule", {"threshold": 1.0}),
|
|
|
+ ("ReleaseSecurityGate", "governance", "QualityGate", {}),
|
|
|
+ )
|
|
|
+ for name, profile, kind, attrs in m1:
|
|
|
+ node(name, "M1", profile, kind, f"DCP 项目模型:{name}", attrs, f"https://onto.qm.cn/ontorefactor/model/dcp/1.0#{name}")
|
|
|
+ node("TenantControllerCommit", "M0", "software", "SourceArtifact", attrs={"commit": "abc123"}, uri="git://dcp-base-tenant/commit/abc123/TenantController.java")
|
|
|
+ node("TenantIdPhysical", "M0", "software", "PhysicalColumn", attrs={"environment": "production", "sensitive": True}, uri="db://prod/dcp_tenant/tenant_id")
|
|
|
+ node("CompletenessRun", "M0", "governance", "Measurement", attrs={"value": .9987, "threshold": 1.0, "result": "failed"}, uri="measurement:tenant-id:20260817")
|
|
|
+ evidence = create_evidence(db, tenant_id, pid, {"kind": "SourceArtifact", "uri": "evidence://dcp/tenant-isolation",
|
|
|
+ "title": "租户隔离设计与 DDL", "content": "tenant_id NOT NULL; every query is tenant scoped", "source": "builtin"})
|
|
|
+ runtime = create_evidence(db, tenant_id, pid, {"kind": "RuntimeObservation", "uri": "evidence://trace/98271",
|
|
|
+ "title": "生产调用 Trace 98271", "content": "observed tenant-scoped access", "source": "apm://trace/98271"})
|
|
|
+ type_map = {"TenantManagement": "BusinessCapabilityType", "Tenant": "BusinessObjectType", "TenantIsolation": "BusinessRuleType",
|
|
|
+ "BaseIAM": "ModuleType", "TenantController": "ClassType", "CreateTenantAPI": "ApiOperationType", "DcpTenantTable": "TableType",
|
|
|
+ "TenantIdColumn": "ColumnType", "TenantCreatedTopic": "EventContractType", "IAMDataOwner": "OwnerType",
|
|
|
+ "TenantIsolationPolicy": "AccessPolicyType", "TenantIdCompleteness": "QualityRuleType", "ReleaseSecurityGate": "QualityGateType"}
|
|
|
+ for child, parent in type_map.items():
|
|
|
+ create_assertion(db, tenant_id, pid, {"subject_id": nodes[child]["id"], "predicate": "INSTANCE_OF", "object_id": nodes[parent]["id"],
|
|
|
+ "assertion_type": "declared", "confidence": 1, "review_status": "accepted", "generated_by": "builtin"})
|
|
|
+ for name in (*sum((tuple(v) for v in profile_types.values()), ()), "Object", "Subject", "Activity", "Rule", "Result", "Evidence", "Assertion", "Term", "State", "TimeExtent"):
|
|
|
+ create_assertion(db, tenant_id, pid, {"subject_id": nodes[name]["id"], "predicate": "INSTANCE_OF", "object_id": nodes["MetaEntityType"]["id"],
|
|
|
+ "assertion_type": "declared", "confidence": 1, "review_status": "accepted", "generated_by": "builtin"})
|
|
|
+ relations = (
|
|
|
+ ("TenantManagement", "REALIZED_BY", "BaseIAM", "declared"), ("BaseIAM", "OWNED_BY", "IAMDataOwner", "declared"),
|
|
|
+ ("TenantController", "BELONGS_TO", "BaseIAM", "extracted"), ("BaseIAM", "EXPOSES", "CreateTenantAPI", "extracted"),
|
|
|
+ ("DcpTenantTable", "REPRESENTS", "Tenant", "declared"), ("CreateTenantAPI", "OPERATES_ON", "Tenant", "declared"),
|
|
|
+ ("TenantCreatedTopic", "REPRESENTS_EVENT", "Tenant", "declared"), ("Tenant", "GOVERNED_BY", "TenantIsolation", "declared"),
|
|
|
+ ("DcpTenantTable", "OWNED_BY", "IAMDataOwner", "declared"), ("TenantIdColumn", "GOVERNED_BY", "TenantIsolationPolicy", "declared"),
|
|
|
+ ("TenantIdColumn", "OWNED_BY", "IAMDataOwner", "declared"), ("TenantIdCompleteness", "VALIDATES", "TenantIdColumn", "declared"),
|
|
|
+ ("ReleaseSecurityGate", "EVALUATES", "BaseIAM", "declared"), ("TenantControllerCommit", "INSTANCE_OF", "TenantController", "extracted"),
|
|
|
+ ("TenantIdPhysical", "INSTANCE_OF", "TenantIdColumn", "observed"), ("TenantIdPhysical", "OWNED_BY", "IAMDataOwner", "observed"),
|
|
|
+ ("TenantIdPhysical", "GOVERNED_BY", "TenantIsolationPolicy", "observed"), ("CompletenessRun", "INSTANCE_OF", "TenantIdCompleteness", "observed"),
|
|
|
+ ("CompletenessRun", "VALIDATES", "TenantIdPhysical", "observed"),
|
|
|
+ )
|
|
|
+ for subject, predicate, obj, assertion_type in relations:
|
|
|
+ create_assertion(db, tenant_id, pid, {"subject_id": nodes[subject]["id"], "predicate": predicate, "object_id": nodes[obj]["id"],
|
|
|
+ "assertion_type": assertion_type, "confidence": 1 if assertion_type == "declared" else .98,
|
|
|
+ "evidence_ids": [runtime["id"] if assertion_type == "observed" else evidence["id"]],
|
|
|
+ "generated_by": "builtin:tenant-isolation", "review_status": "accepted"})
|
|
|
+ for name, rule_type, severity, expression in (
|
|
|
+ ("相邻层实例化", "metamodel", "critical", {"predicate": "INSTANCE_OF", "layerDelta": 1}),
|
|
|
+ ("业务能力必须有软件实现", "integrity", "high", {"kind": "BusinessCapability", "requires": "REALIZED_BY"}),
|
|
|
+ ("生产资产必须有 Owner", "ownership", "critical", {"environment": "production", "requires": "OWNED_BY"}),
|
|
|
+ ("敏感字段必须有策略", "security", "critical", {"sensitive": True, "requires": "GOVERNED_BY"}),
|
|
|
+ ("正式断言必须可追溯", "provenance", "high", {"reviewStatus": "accepted", "requires": ["evidence", "generatedBy"]}),
|
|
|
+ ):
|
|
|
+ upsert_rule(db, tenant_id, pid, {"name": name, "rule_type": rule_type, "severity": severity, "expression": expression,
|
|
|
+ "description": "OntoRefactor 确定性治理门禁"})
|
|
|
+ validation = validate_project(db, tenant_id, pid, persist=True)
|
|
|
+ return {"project": project, "overview": overview(db, tenant_id, pid), "validation": validation}
|