credentials.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. """AES-GCM codec and platform PostgreSQL repository for data-source secrets."""
  2. import base64
  3. import binascii
  4. import json
  5. from typing import Optional
  6. from cryptography.exceptions import InvalidTag
  7. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  8. from sqlalchemy import text
  9. from app.core.common.identifiers import new_governance_uid
  10. from app.core.data_source.errors import DataSourceCredentialUnavailable
  11. from app.core.data_source.models import DataSourceCredential, SealedCredential
  12. class CredentialCodec:
  13. """Encrypt credentials with data-source identity bound as AES-GCM AAD."""
  14. def __init__(self, master_key: bytes, key_version: str):
  15. if len(master_key) != 32:
  16. raise DataSourceCredentialUnavailable(
  17. "master key must decode to 32 bytes"
  18. )
  19. if not str(key_version).strip():
  20. raise DataSourceCredentialUnavailable(
  21. "credential key version is not configured"
  22. )
  23. self._master_key = bytes(master_key)
  24. self.key_version = str(key_version).strip()
  25. @classmethod
  26. def from_base64(cls, encoded_key: str, key_version: str):
  27. if not str(encoded_key or "").strip():
  28. raise DataSourceCredentialUnavailable(
  29. "master key is not configured"
  30. )
  31. value = str(encoded_key).strip()
  32. padding = "=" * (-len(value) % 4)
  33. try:
  34. decoded = base64.b64decode(
  35. value + padding,
  36. altchars=b"-_",
  37. validate=True,
  38. )
  39. except (binascii.Error, ValueError) as exc:
  40. raise DataSourceCredentialUnavailable(
  41. "master key is not valid base64"
  42. ) from exc
  43. return cls(decoded, key_version)
  44. @staticmethod
  45. def _aad(data_source_uid: str, credential_version: int) -> bytes:
  46. return f"{data_source_uid}:{int(credential_version)}".encode("utf-8")
  47. def encrypt(
  48. self,
  49. data_source_uid: str,
  50. credential_version: int,
  51. credential: DataSourceCredential,
  52. ) -> SealedCredential:
  53. import os
  54. nonce = os.urandom(12)
  55. payload = json.dumps(
  56. {
  57. "username": credential.username,
  58. "password": credential.password,
  59. "options": dict(credential.options),
  60. },
  61. sort_keys=True,
  62. separators=(",", ":"),
  63. ).encode("utf-8")
  64. encrypted_payload = AESGCM(self._master_key).encrypt(
  65. nonce,
  66. payload,
  67. self._aad(data_source_uid, credential_version),
  68. )
  69. return SealedCredential(
  70. id=new_governance_uid(),
  71. data_source_uid=str(data_source_uid),
  72. credential_version=int(credential_version),
  73. encrypted_payload=encrypted_payload,
  74. nonce=nonce,
  75. key_version=self.key_version,
  76. )
  77. def decrypt(self, sealed: SealedCredential) -> DataSourceCredential:
  78. if sealed.key_version != self.key_version:
  79. raise DataSourceCredentialUnavailable(
  80. "credential key version is unavailable"
  81. )
  82. try:
  83. plaintext = AESGCM(self._master_key).decrypt(
  84. bytes(sealed.nonce),
  85. bytes(sealed.encrypted_payload),
  86. self._aad(
  87. sealed.data_source_uid,
  88. sealed.credential_version,
  89. ),
  90. )
  91. payload = json.loads(plaintext.decode("utf-8"))
  92. username = payload["username"]
  93. password = payload["password"]
  94. options = payload.get("options", {})
  95. if (
  96. not isinstance(username, str)
  97. or not isinstance(password, str)
  98. or not isinstance(options, dict)
  99. ):
  100. raise ValueError("malformed credential payload")
  101. return DataSourceCredential(username, password, options)
  102. except (
  103. InvalidTag,
  104. UnicodeDecodeError,
  105. json.JSONDecodeError,
  106. KeyError,
  107. TypeError,
  108. ValueError,
  109. ) as exc:
  110. raise DataSourceCredentialUnavailable(
  111. "credential cannot be decrypted"
  112. ) from exc
  113. class DataSourceCredentialRepository:
  114. """Store immutable encrypted credential versions in the platform DB."""
  115. def __init__(self, codec: CredentialCodec):
  116. self.codec = codec
  117. def create_version(
  118. self,
  119. session,
  120. *,
  121. data_source_uid: str,
  122. credential: DataSourceCredential,
  123. actor_uid: Optional[str],
  124. ) -> SealedCredential:
  125. session.execute(
  126. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  127. {"key": f"datasource-credential:{data_source_uid}"},
  128. )
  129. version = int(
  130. session.execute(
  131. text(
  132. """
  133. SELECT COALESCE(MAX(credential_version), 0) + 1
  134. FROM public.datasource_credentials
  135. WHERE data_source_uid = CAST(:uid AS uuid)
  136. """
  137. ),
  138. {"uid": data_source_uid},
  139. ).scalar_one()
  140. )
  141. sealed = self.codec.encrypt(data_source_uid, version, credential)
  142. session.execute(
  143. text(
  144. """
  145. UPDATE public.datasource_credentials
  146. SET status = 'retired', retired_at = CURRENT_TIMESTAMP
  147. WHERE data_source_uid = CAST(:uid AS uuid)
  148. AND status = 'active'
  149. """
  150. ),
  151. {"uid": data_source_uid},
  152. )
  153. session.execute(
  154. text(
  155. """
  156. INSERT INTO public.datasource_credentials (
  157. id, data_source_uid, credential_version,
  158. encrypted_payload, nonce, key_version, status
  159. ) VALUES (
  160. CAST(:id AS uuid), CAST(:uid AS uuid), :version,
  161. :encrypted_payload, :nonce, :key_version, 'active'
  162. )
  163. """
  164. ),
  165. {
  166. "id": sealed.id,
  167. "uid": sealed.data_source_uid,
  168. "version": sealed.credential_version,
  169. "encrypted_payload": sealed.encrypted_payload,
  170. "nonce": sealed.nonce,
  171. "key_version": sealed.key_version,
  172. },
  173. )
  174. self._audit(
  175. session,
  176. data_source_uid=data_source_uid,
  177. credential_version=version,
  178. actor_uid=actor_uid,
  179. event_type="credential_version_created",
  180. success=True,
  181. safe_detail="encrypted credential version created",
  182. )
  183. return sealed
  184. def get_active(
  185. self,
  186. session,
  187. data_source_uid: str,
  188. credential_version: Optional[int] = None,
  189. ) -> DataSourceCredential:
  190. version_clause = (
  191. "AND credential_version = :version"
  192. if credential_version is not None
  193. else ""
  194. )
  195. parameters = {"uid": data_source_uid}
  196. if credential_version is not None:
  197. parameters["version"] = int(credential_version)
  198. row = (
  199. session.execute(
  200. text(
  201. f"""
  202. SELECT id::text, data_source_uid::text,
  203. credential_version, encrypted_payload,
  204. nonce, key_version, status
  205. FROM public.datasource_credentials
  206. WHERE data_source_uid = CAST(:uid AS uuid)
  207. AND status = 'active'
  208. {version_clause}
  209. ORDER BY credential_version DESC
  210. LIMIT 1
  211. """
  212. ),
  213. parameters,
  214. )
  215. .mappings()
  216. .one_or_none()
  217. )
  218. if row is None:
  219. raise DataSourceCredentialUnavailable()
  220. sealed = SealedCredential(
  221. id=row["id"],
  222. data_source_uid=row["data_source_uid"],
  223. credential_version=int(row["credential_version"]),
  224. encrypted_payload=bytes(row["encrypted_payload"]),
  225. nonce=bytes(row["nonce"]),
  226. key_version=row["key_version"],
  227. status=row["status"],
  228. )
  229. return self.codec.decrypt(sealed)
  230. def revoke_all(
  231. self,
  232. session,
  233. *,
  234. data_source_uid: str,
  235. actor_uid: Optional[str],
  236. ) -> int:
  237. result = session.execute(
  238. text(
  239. """
  240. UPDATE public.datasource_credentials
  241. SET status = 'revoked',
  242. retired_at = COALESCE(retired_at, CURRENT_TIMESTAMP)
  243. WHERE data_source_uid = CAST(:uid AS uuid)
  244. AND status <> 'revoked'
  245. """
  246. ),
  247. {"uid": data_source_uid},
  248. )
  249. count = int(result.rowcount or 0)
  250. self._audit(
  251. session,
  252. data_source_uid=data_source_uid,
  253. credential_version=None,
  254. actor_uid=actor_uid,
  255. event_type="credentials_revoked",
  256. success=True,
  257. safe_detail=f"revoked credential versions: {count}",
  258. )
  259. return count
  260. def compensate_failed_activation(
  261. self,
  262. session,
  263. *,
  264. data_source_uid: str,
  265. failed_version: int,
  266. restore_version: Optional[int],
  267. actor_uid: Optional[str],
  268. ) -> None:
  269. session.execute(
  270. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  271. {"key": f"datasource-credential:{data_source_uid}"},
  272. )
  273. session.execute(
  274. text(
  275. """
  276. UPDATE public.datasource_credentials
  277. SET status = 'revoked',
  278. retired_at = COALESCE(retired_at, CURRENT_TIMESTAMP)
  279. WHERE data_source_uid = CAST(:uid AS uuid)
  280. AND credential_version = :failed_version
  281. """
  282. ),
  283. {
  284. "uid": data_source_uid,
  285. "failed_version": int(failed_version),
  286. },
  287. )
  288. if restore_version is not None:
  289. session.execute(
  290. text(
  291. """
  292. UPDATE public.datasource_credentials
  293. SET status = 'active', retired_at = NULL
  294. WHERE data_source_uid = CAST(:uid AS uuid)
  295. AND credential_version = :restore_version
  296. AND status = 'retired'
  297. """
  298. ),
  299. {
  300. "uid": data_source_uid,
  301. "restore_version": int(restore_version),
  302. },
  303. )
  304. self._audit(
  305. session,
  306. data_source_uid=data_source_uid,
  307. credential_version=failed_version,
  308. actor_uid=actor_uid,
  309. event_type="credential_activation_compensated",
  310. success=True,
  311. safe_detail="failed version revoked and prior version restored",
  312. )
  313. def record_pool_event(
  314. self,
  315. session,
  316. *,
  317. data_source_uid: str,
  318. actor_uid: Optional[str],
  319. event_type: str,
  320. safe_detail: str,
  321. ) -> None:
  322. self._audit(
  323. session,
  324. data_source_uid=data_source_uid,
  325. credential_version=None,
  326. actor_uid=actor_uid,
  327. event_type=event_type,
  328. success=True,
  329. safe_detail=safe_detail,
  330. )
  331. @staticmethod
  332. def _audit(
  333. session,
  334. *,
  335. data_source_uid: str,
  336. credential_version: Optional[int],
  337. actor_uid: Optional[str],
  338. event_type: str,
  339. success: bool,
  340. safe_detail: str,
  341. ) -> None:
  342. session.execute(
  343. text(
  344. """
  345. INSERT INTO public.datasource_credential_audit_events (
  346. data_source_uid, credential_version, actor_uid,
  347. event_type, success, safe_detail
  348. ) VALUES (
  349. CAST(:uid AS uuid), :version, CAST(:actor_uid AS uuid),
  350. :event_type, :success, :safe_detail
  351. )
  352. """
  353. ),
  354. {
  355. "uid": data_source_uid,
  356. "version": credential_version,
  357. "actor_uid": actor_uid,
  358. "event_type": event_type,
  359. "success": bool(success),
  360. "safe_detail": safe_detail[:500],
  361. },
  362. )