bindings.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. """Server-approved connector source and destination bindings."""
  2. from __future__ import annotations
  3. import json
  4. from urllib.parse import urlsplit
  5. from sqlalchemy import text
  6. from app.core.common.identifiers import new_governance_uid
  7. from app.core.connectors.errors import ConnectorConfigurationError
  8. from app.core.connectors.sdk import SECRET_REF_PATTERN, _walk_secrets
  9. ENVIRONMENTS = frozenset({"development", "staging", "production"})
  10. def validate_approved_config(connector_id, config):
  11. if not isinstance(config, dict):
  12. raise ConnectorConfigurationError("approved connector config must be an object")
  13. _walk_secrets(config, "approved_config")
  14. if connector_id != "rest-catalog":
  15. return dict(config)
  16. if set(config) != {"base_url", "allowed_host", "credential_ref"}:
  17. raise ConnectorConfigurationError("REST approved config is incomplete")
  18. parsed = urlsplit(str(config["base_url"]))
  19. host = str(config["allowed_host"]).lower().rstrip(".")
  20. if (
  21. parsed.scheme != "https"
  22. or not parsed.hostname
  23. or parsed.username
  24. or parsed.password
  25. or parsed.query
  26. or parsed.fragment
  27. or parsed.hostname.lower().rstrip(".") != host
  28. ):
  29. raise ConnectorConfigurationError("REST approved destination is invalid")
  30. credential_ref = str(config["credential_ref"])
  31. if not SECRET_REF_PATTERN.fullmatch(credential_ref):
  32. raise ConnectorConfigurationError("REST approved credential reference is invalid")
  33. return {
  34. "base_url": str(config["base_url"]).rstrip("/"),
  35. "allowed_host": host,
  36. "credential_ref": credential_ref,
  37. }
  38. class ConnectorSourceBindingRepository:
  39. def __init__(self, session):
  40. self.session = session
  41. def approve(
  42. self,
  43. *,
  44. connector_id,
  45. connector_version,
  46. source_uid,
  47. business_domain_uid,
  48. environment,
  49. approved_config,
  50. approved_by,
  51. binding_uid=None,
  52. ):
  53. if environment not in ENVIRONMENTS:
  54. raise ConnectorConfigurationError("connector binding environment is invalid")
  55. config = validate_approved_config(connector_id, approved_config)
  56. uid = binding_uid or new_governance_uid()
  57. if binding_uid:
  58. self.session.execute(
  59. text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"),
  60. {"lock_key": f"connector-binding:{uid}"},
  61. )
  62. current = self.session.execute(
  63. text("""
  64. SELECT COALESCE(MAX(binding_version),0)
  65. FROM public.connector_source_bindings
  66. WHERE uid=CAST(:uid AS uuid)
  67. """),
  68. {"uid": uid},
  69. ).scalar_one()
  70. self.session.execute(
  71. text("""
  72. UPDATE public.connector_source_bindings
  73. SET status='revoked',updated_at=CURRENT_TIMESTAMP
  74. WHERE uid=CAST(:uid AS uuid) AND status='approved'
  75. """),
  76. {"uid": uid},
  77. )
  78. version = int(current) + 1
  79. else:
  80. version = 1
  81. rest = config if connector_id == "rest-catalog" else {}
  82. self.session.execute(
  83. text("""
  84. INSERT INTO public.connector_source_bindings
  85. (uid,binding_version,connector_id,connector_version,source_uid,
  86. business_domain_uid,environment,approved_base_url,allowed_host,
  87. credential_ref,approved_config,status,approved_by)
  88. VALUES(CAST(:uid AS uuid),:binding_version,:connector,:version,
  89. CAST(:source AS uuid),CAST(:domain AS uuid),:environment,
  90. :base_url,:allowed_host,:credential_ref,CAST(:config AS jsonb),
  91. 'approved',CAST(:approved_by AS uuid))
  92. """),
  93. {
  94. "uid": uid,
  95. "binding_version": version,
  96. "connector": connector_id,
  97. "version": connector_version,
  98. "source": source_uid,
  99. "domain": business_domain_uid,
  100. "environment": environment,
  101. "base_url": rest.get("base_url"),
  102. "allowed_host": rest.get("allowed_host"),
  103. "credential_ref": rest.get("credential_ref"),
  104. "config": json.dumps(config, sort_keys=True),
  105. "approved_by": approved_by,
  106. },
  107. )
  108. rebound_principals = 0
  109. if binding_uid and int(current) > 0:
  110. rebound_principals = self.session.execute(
  111. text("""
  112. UPDATE public.connector_principals
  113. SET source_binding_version=:binding_version
  114. WHERE source_binding_uid=CAST(:uid AS uuid)
  115. AND source_binding_version=:old_version
  116. """),
  117. {
  118. "uid": uid,
  119. "old_version": int(current),
  120. "binding_version": version,
  121. },
  122. ).rowcount
  123. self.session.commit()
  124. return {
  125. "binding_uid": uid,
  126. "binding_version": version,
  127. "status": "approved",
  128. "rebound_principals": rebound_principals,
  129. }
  130. def revoke(self, binding_uid, approved_by):
  131. principals = self.session.execute(
  132. text("""
  133. UPDATE public.connector_principals
  134. SET status='revoked'
  135. WHERE source_binding_uid=CAST(:uid AS uuid) AND status='active'
  136. RETURNING uid
  137. """),
  138. {"uid": binding_uid},
  139. ).all()
  140. if principals:
  141. principal_uids = [str(row[0]) for row in principals]
  142. self.session.execute(
  143. text("""
  144. UPDATE public.connector_machine_credentials
  145. SET status='revoked',revoked_at=CURRENT_TIMESTAMP
  146. WHERE principal_uid=ANY(CAST(:principals AS uuid[]))
  147. AND status='active'
  148. """),
  149. {"principals": principal_uids},
  150. )
  151. changed = self.session.execute(
  152. text("""
  153. UPDATE public.connector_source_bindings
  154. SET status='revoked',approved_by=CAST(:actor AS uuid),updated_at=CURRENT_TIMESTAMP
  155. WHERE uid=CAST(:uid AS uuid) AND status='approved'
  156. """),
  157. {"uid": binding_uid, "actor": approved_by},
  158. ).rowcount
  159. self.session.commit()
  160. return {"revoked": bool(changed), "principals_deactivated": len(principals)}
  161. def get_approved(self, binding_uid, binding_version=None):
  162. version_clause = (
  163. "AND binding_version=:binding_version" if binding_version is not None else ""
  164. )
  165. row = (
  166. self.session.execute(
  167. text(f"""
  168. SELECT uid::text,binding_version,connector_id,connector_version,
  169. source_uid::text,business_domain_uid::text,environment,
  170. approved_base_url,allowed_host,credential_ref,approved_config,status
  171. FROM public.connector_source_bindings
  172. WHERE uid=CAST(:uid AS uuid) AND status='approved' {version_clause}
  173. ORDER BY binding_version DESC LIMIT 1
  174. """),
  175. {"uid": binding_uid, "binding_version": binding_version},
  176. )
  177. .mappings()
  178. .one_or_none()
  179. )
  180. if row is None:
  181. raise ConnectorConfigurationError("approved connector binding was not found")
  182. result = dict(row)
  183. config = dict(result.pop("approved_config") or {})
  184. if result["connector_id"] == "rest-catalog":
  185. config = {
  186. "base_url": result.pop("approved_base_url"),
  187. "allowed_host": result.pop("allowed_host"),
  188. "credential_ref": result.pop("credential_ref"),
  189. }
  190. else:
  191. result.pop("approved_base_url", None)
  192. result.pop("allowed_host", None)
  193. result.pop("credential_ref", None)
  194. result["approved_config"] = config
  195. return result
  196. def list_public(self, limit=100):
  197. rows = self.session.execute(
  198. text("""
  199. SELECT uid::text,binding_version,connector_id,connector_version,
  200. source_uid::text,business_domain_uid::text,environment,
  201. approved_base_url,allowed_host,status,approved_by::text,
  202. created_at,updated_at
  203. FROM public.connector_source_bindings
  204. ORDER BY created_at DESC LIMIT :limit
  205. """),
  206. {"limit": min(max(int(limit), 1), 200)},
  207. ).mappings()
  208. return [dict(row) for row in rows]
  209. __all__ = ["ConnectorSourceBindingRepository", "validate_approved_config"]