definitions.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """Secret-free Neo4j repository for external data-source definitions."""
  2. import json
  3. from dataclasses import dataclass
  4. from app.core.common.identifiers import ensure_governance_uid
  5. from app.core.data_source.models import DataSourceDefinition
  6. NEO4J_SECRET_KEYS = {
  7. "username",
  8. "password",
  9. "passwd",
  10. "credential",
  11. "credentials",
  12. "encrypted_payload",
  13. "nonce",
  14. "api_key",
  15. "token",
  16. "authorization",
  17. "conn_str",
  18. "connection_string",
  19. "connection_url",
  20. }
  21. @dataclass(frozen=True)
  22. class DataSourceFilters:
  23. uid: str | None = None
  24. name_en: str | None = None
  25. name_zh: str | None = None
  26. database_type: str | None = None
  27. status: bool | None = None
  28. class DataSourceDefinitionRepository:
  29. """Read and write DataSource nodes using stable UIDs only."""
  30. def __init__(self, session):
  31. self.session = session
  32. @staticmethod
  33. def _assert_secret_free(properties: dict) -> None:
  34. def visit(value):
  35. if isinstance(value, dict):
  36. for key, item in value.items():
  37. if str(key).strip().lower() in NEO4J_SECRET_KEYS:
  38. raise ValueError(
  39. "secret properties cannot be stored in DataSource"
  40. )
  41. visit(item)
  42. elif isinstance(value, (list, tuple)):
  43. for item in value:
  44. visit(item)
  45. visit(properties)
  46. @classmethod
  47. def _to_properties(cls, definition: DataSourceDefinition) -> dict:
  48. extra_properties = dict(definition.extra_properties)
  49. cls._assert_secret_free(extra_properties)
  50. properties = {
  51. "uid": definition.uid,
  52. "name_en": definition.name_en,
  53. "name_zh": definition.name_zh,
  54. "type": definition.database_type,
  55. "host": definition.host,
  56. "port": definition.port,
  57. "database": definition.database,
  58. "schema": definition.schema,
  59. "credential_ref": definition.credential_ref,
  60. "credential_version": definition.credential_version,
  61. "pool_size": definition.pool_size,
  62. "max_overflow": definition.max_overflow,
  63. "tls_options_json": json.dumps(
  64. dict(definition.tls_options),
  65. sort_keys=True,
  66. separators=(",", ":"),
  67. ),
  68. "status": definition.status,
  69. "desc": definition.description,
  70. "extra_properties_json": json.dumps(
  71. extra_properties,
  72. sort_keys=True,
  73. separators=(",", ":"),
  74. ),
  75. }
  76. properties = {
  77. key: value for key, value in properties.items() if value is not None
  78. }
  79. cls._assert_secret_free(properties)
  80. return properties
  81. @staticmethod
  82. def _from_properties(properties: dict) -> DataSourceDefinition:
  83. values = dict(properties)
  84. tls_raw = values.pop("tls_options_json", "{}") or "{}"
  85. extras_raw = values.pop("extra_properties_json", "{}") or "{}"
  86. try:
  87. tls_options = json.loads(tls_raw)
  88. except (TypeError, ValueError):
  89. tls_options = {}
  90. try:
  91. extra_properties = json.loads(extras_raw)
  92. except (TypeError, ValueError) as exc:
  93. raise ValueError(
  94. "data source extra properties are invalid"
  95. ) from exc
  96. if not isinstance(extra_properties, dict):
  97. raise ValueError("data source extra properties are invalid")
  98. DataSourceDefinitionRepository._assert_secret_free(extra_properties)
  99. known = {
  100. "uid",
  101. "name_en",
  102. "name_zh",
  103. "type",
  104. "host",
  105. "port",
  106. "database",
  107. "schema",
  108. "credential_ref",
  109. "credential_version",
  110. "pool_size",
  111. "max_overflow",
  112. "status",
  113. "desc",
  114. }
  115. return DataSourceDefinition(
  116. uid=values.get("uid"),
  117. name_en=values.get("name_en", ""),
  118. name_zh=values.get("name_zh"),
  119. database_type=values.get("type", ""),
  120. host=values.get("host", ""),
  121. port=values.get("port", 0),
  122. database=values.get("database", ""),
  123. schema=values.get("schema"),
  124. credential_ref=values.get("credential_ref"),
  125. credential_version=values.get("credential_version"),
  126. pool_size=values.get("pool_size"),
  127. max_overflow=values.get("max_overflow"),
  128. tls_options=tls_options if isinstance(tls_options, dict) else {},
  129. status=bool(values.get("status", True)),
  130. description=values.get("desc"),
  131. extra_properties={
  132. **{
  133. key: value
  134. for key, value in values.items()
  135. if key not in known and key != "_id"
  136. },
  137. **extra_properties,
  138. },
  139. )
  140. def get(self, uid: str) -> DataSourceDefinition | None:
  141. record = self.session.run(
  142. """
  143. MATCH (n:DataSource {uid: $uid})
  144. RETURN properties(n) AS properties
  145. """,
  146. {"uid": str(uid)},
  147. ).single()
  148. if record is None:
  149. return None
  150. return self._from_properties(dict(record["properties"]))
  151. def list(self, filters: DataSourceFilters = None):
  152. filters = filters or DataSourceFilters()
  153. clauses = []
  154. parameters = {}
  155. mapping = {
  156. "uid": "n.uid",
  157. "name_en": "n.name_en",
  158. "name_zh": "n.name_zh",
  159. "database_type": "n.type",
  160. "status": "n.status",
  161. }
  162. for field_name, property_name in mapping.items():
  163. value = getattr(filters, field_name)
  164. if value is not None:
  165. clauses.append(f"{property_name} = ${field_name}")
  166. parameters[field_name] = value
  167. where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
  168. records = self.session.run(
  169. f"""
  170. MATCH (n:DataSource)
  171. {where}
  172. RETURN properties(n) AS properties
  173. ORDER BY n.name_en
  174. """,
  175. parameters,
  176. )
  177. return [
  178. self._from_properties(dict(record["properties"]))
  179. for record in records
  180. ]
  181. def save(self, definition: DataSourceDefinition) -> DataSourceDefinition:
  182. uid_holder = {"uid": definition.uid} if definition.uid else {}
  183. uid = ensure_governance_uid(uid_holder)
  184. saved = definition.with_uid(uid)
  185. properties = self._to_properties(saved)
  186. self.session.run(
  187. """
  188. MERGE (n:DataSource {uid: $uid})
  189. SET n = $properties
  190. RETURN properties(n) AS properties
  191. """,
  192. {"uid": uid, "properties": properties},
  193. )
  194. return saved
  195. def delete(self, uid: str) -> bool:
  196. record = self.session.run(
  197. """
  198. MATCH (n:DataSource {uid: $uid})
  199. WITH n, count(n) AS found
  200. DETACH DELETE n
  201. RETURN found AS deleted_count
  202. """,
  203. {"uid": str(uid)},
  204. ).single()
  205. return bool(record and record["deleted_count"])
  206. def credential_references(self):
  207. records = self.session.run(
  208. """
  209. MATCH (n:DataSource)
  210. WHERE n.uid IS NOT NULL
  211. RETURN n.uid AS uid, n.credential_ref AS credential_ref,
  212. n.credential_version AS credential_version
  213. """
  214. )
  215. return {
  216. str(record["uid"]): (
  217. record["credential_ref"],
  218. int(record["credential_version"]),
  219. )
  220. for record in records
  221. if record.get("credential_ref") is not None
  222. and record.get("credential_version") is not None
  223. }
  224. def remove_legacy_secret_fields(self, uid: str) -> None:
  225. self.session.run(
  226. """
  227. MATCH (n:DataSource {uid: $uid})
  228. REMOVE n.username, n.password, n.conn_str,
  229. n.connection_string, n.connection_url
  230. """,
  231. {"uid": str(uid)},
  232. )