From 8918dfc15cb31bad9f334ffaf013c3c60f1fc164 Mon Sep 17 00:00:00 2001 From: Adam Sachs Date: Wed, 18 Mar 2026 22:06:04 -0400 Subject: [PATCH] Extract shared OnePasswordClient into fides.common.onepassword Move the duplicated 1Password SDK integration from test helpers into a shared, instance-based client class. Each consumer (SaaS test secrets, seed profile resolution) can instantiate its own client with different tokens and vault IDs. The existing test helper module-level API is preserved as thin wrappers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fides/common/onepassword/__init__.py | 5 + src/fides/common/onepassword/client.py | 199 +++++++++++++++++++ tests/ops/test_helpers/onepassword_client.py | 177 +++++------------ 3 files changed, 251 insertions(+), 130 deletions(-) create mode 100644 src/fides/common/onepassword/__init__.py create mode 100644 src/fides/common/onepassword/client.py diff --git a/src/fides/common/onepassword/__init__.py b/src/fides/common/onepassword/__init__.py new file mode 100644 index 00000000000..0aee432f217 --- /dev/null +++ b/src/fides/common/onepassword/__init__.py @@ -0,0 +1,5 @@ +"""1Password client utilities for retrieving secrets and items.""" + +from fides.common.onepassword.client import OnePasswordClient + +__all__ = ["OnePasswordClient"] diff --git a/src/fides/common/onepassword/client.py b/src/fides/common/onepassword/client.py new file mode 100644 index 00000000000..4bff42f4bb2 --- /dev/null +++ b/src/fides/common/onepassword/client.py @@ -0,0 +1,199 @@ +""" +Instance-based 1Password client for retrieving secrets and items. + +Provides lazy client initialization and vault item lookup by title. +Each instance is parameterized by its own service account token and vault ID, +so multiple consumers (e.g. SaaS test secrets, seed profile resolution) +can coexist with different credentials. +""" + +import asyncio +import json +from typing import Any, Dict, List, Optional + +from loguru import logger +from onepassword.client import Client # type: ignore[import-untyped] + + +class OnePasswordClient: + """1Password SDK client with lazy initialization and title-based lookup.""" + + def __init__( + self, + service_account_token: str, + vault_id: str, + integration_name: str = "Fides", + integration_version: str = "v1.0.0", + ): + self._token = service_account_token + self._vault_id = vault_id + self._integration_name = integration_name + self._integration_version = integration_version + self._client: Optional[Client] = None + self._title_to_id: Dict[str, Dict[str, str]] = {} + self._mappings_initialized = False + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _get_client(self) -> Client: + """Lazily authenticate and return the 1Password SDK client.""" + if self._client is None: + self._client = await Client.authenticate( + auth=self._token, + integration_name=self._integration_name, + integration_version=self._integration_version, + ) + return self._client + + async def _ensure_mappings(self) -> None: + """Build the title→ID index for items in the configured vault.""" + if self._mappings_initialized: + return + + client = await self._get_client() + items = await client.items.list(vault_id=self._vault_id) + + for item in items: + logger.debug(f"1PW: indexed item '{item.title}'") + self._title_to_id[item.title] = { + "item_id": item.id, + "category": item.category, + } + + self._mappings_initialized = True + logger.info( + f"1PW: initialized mappings for {len(self._title_to_id)} items " + f"in vault {self._vault_id}" + ) + + @staticmethod + def _item_to_dict(item: Any) -> Dict[str, Any]: + """Convert a 1Password SDK item object to a plain dict.""" + item_dict: Dict[str, Any] = { + "id": item.id, + "title": item.title, + "category": item.category, + "fields": [], + } + + if hasattr(item, "fields") and item.fields: + for field in item.fields: + item_dict["fields"].append( + { + "id": getattr(field, "id", ""), + "title": getattr(field, "title", ""), + "type": getattr(field, "field_type", ""), + "value": getattr(field, "value", ""), + "purpose": getattr(field, "purpose", ""), + } + ) + + return item_dict + + # ------------------------------------------------------------------ + # Public API — async + # ------------------------------------------------------------------ + + async def get_item(self, title: str) -> Optional[Dict[str, Any]]: + """ + Retrieve a 1Password item by its title. + + Returns a dict with keys: id, title, category, fields (list of + dicts with id, title, type, value, purpose). Returns None if + the item is not found. + """ + await self._ensure_mappings() + + if title not in self._title_to_id: + logger.warning(f"1PW: item '{title}' not found in vault {self._vault_id}") + return None + + info = self._title_to_id[title] + client = await self._get_client() + item = await client.items.get( + item_id=info["item_id"], + vault_id=self._vault_id, + ) + + item_dict = self._item_to_dict(item) + logger.info( + f"1PW: retrieved item '{title}' with {len(item_dict['fields'])} fields" + ) + return item_dict + + async def get_secrets(self, title: str) -> Dict[str, str]: + """ + Get field name → value pairs from a 1Password item. + + Filters out fields with empty titles or values. + """ + item = await self.get_item(title) + if not item: + return {} + + return { + field["title"]: field["value"] + for field in item.get("fields", []) + if field.get("title") and field.get("value") + } + + async def get_item_notes(self, title: str) -> Optional[str]: + """ + Get the notes (notesPlain) content from a 1Password item. + + Returns the string content of the NOTES field, or None if the + item is not found or has no notes. + """ + item = await self.get_item(title) + if not item: + return None + + for field in item.get("fields", []): + if field.get("purpose") == "NOTES" or field.get("id") == "notesPlain": + return field.get("value") + + return None + + async def get_item_notes_json(self, title: str) -> Optional[Dict[str, Any]]: + """ + Get the notes content from a 1Password item, parsed as JSON. + + Returns the parsed dict, or None if the item is not found, + has no notes, or the notes are not valid JSON. + """ + notes = await self.get_item_notes(title) + if notes is None: + return None + + try: + return json.loads(notes) + except json.JSONDecodeError: + logger.error(f"1PW: notes for item '{title}' are not valid JSON") + return None + + async def list_items(self) -> List[str]: + """List all item titles in the configured vault.""" + await self._ensure_mappings() + return list(self._title_to_id.keys()) + + # ------------------------------------------------------------------ + # Public API — sync wrappers + # ------------------------------------------------------------------ + + def get_secrets_sync(self, title: str) -> Dict[str, str]: + """Synchronous wrapper around :meth:`get_secrets`.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(self.get_secrets(title)) + finally: + loop.close() + + def get_item_notes_json_sync(self, title: str) -> Optional[Dict[str, Any]]: + """Synchronous wrapper around :meth:`get_item_notes_json`.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(self.get_item_notes_json(title)) + finally: + loop.close() diff --git a/tests/ops/test_helpers/onepassword_client.py b/tests/ops/test_helpers/onepassword_client.py index 70900b49b20..8ea36a2ae7e 100644 --- a/tests/ops/test_helpers/onepassword_client.py +++ b/tests/ops/test_helpers/onepassword_client.py @@ -1,166 +1,83 @@ -import asyncio +""" +Thin wrappers around :class:`fides.common.onepassword.OnePasswordClient` +for SaaS connector test secrets. + +Preserves the existing module-level API so callers don't need to change. +""" + import os from typing import Any, Dict, List, Optional from loguru import logger -from onepassword.client import Client # Official SDK import - -from fides.api.common_exceptions import FidesopsException - -# Parameters for the 1Password client -params = { - "SAAS_SECRETS_OP_VAULT_ID": os.getenv("SAAS_SECRETS_OP_VAULT_ID"), - "SAAS_OP_SERVICE_ACCOUNT_TOKEN": os.getenv("SAAS_OP_SERVICE_ACCOUNT_TOKEN"), -} - - -_async_client = None -_title_to_id_mapping = {} -_mappings_initialized = False - - -async def _get_or_create_client(): - """Lazy initialization of 1Password client""" - global _async_client - if _async_client is None: - if not params["SAAS_OP_SERVICE_ACCOUNT_TOKEN"]: - logger.error("Missing SAAS_OP_SERVICE_ACCOUNT_TOKEN.") - return None - - _async_client = await Client.authenticate( - auth=params["SAAS_OP_SERVICE_ACCOUNT_TOKEN"], - integration_name="Fides Saas Secrets", - integration_version="v1.0.0", - ) - return _async_client +from fides.common.onepassword import OnePasswordClient -async def _initialize_mappings(): - """Initialize title-to-ID mappings for all vaults and items in memory""" - global _title_to_id_mapping, _mappings_initialized +_client: Optional[OnePasswordClient] = None - if _mappings_initialized: - return - client = await _get_or_create_client() - if client is None: - logger.error("Failed to initialize 1Password Mappings.") - return - - logger.info("Initializing vault and item mappings...") - - # Get items in the SaaS Secrets vault - items = await client.items.list(vault_id=params["SAAS_SECRETS_OP_VAULT_ID"]) +def _get_client() -> Optional[OnePasswordClient]: + """Lazily create a shared client for SaaS test secrets.""" + global _client + if _client is not None: + return _client - for item in items: - logger.debug(f"Processing item: {item.title}") + token = os.getenv("SAAS_OP_SERVICE_ACCOUNT_TOKEN") + vault_id = os.getenv("SAAS_SECRETS_OP_VAULT_ID") - # Create id mappings (store metadata, not secrets) - _title_to_id_mapping[item.title] = { - "item_id": item.id, - "category": item.category, - } + if not token: + logger.error("Missing SAAS_OP_SERVICE_ACCOUNT_TOKEN.") + return None + if not vault_id: + logger.error("Missing SAAS_SECRETS_OP_VAULT_ID.") + return None - _mappings_initialized = True - logger.info(f"Initialized mappings for {len(_title_to_id_mapping)} items") + _client = OnePasswordClient( + service_account_token=token, + vault_id=vault_id, + integration_name="Fides Saas Secrets", + ) + return _client async def get_item_by_title(title: str) -> Optional[Dict[str, Any]]: """ - Retrieve a 1Password item by its title - - Args: - title: The title of the item to retrieve + Retrieve a 1Password item by its title. - Returns: - Dict containing item information and fields, or None if not found + Returns a dict with id, title, category, and fields, or None if not found. """ - await _initialize_mappings() - - if title not in _title_to_id_mapping: - logger.warning(f"Item '{title}' not found in mapping") - return None - - info = _title_to_id_mapping[title] - - client = await _get_or_create_client() + client = _get_client() if client is None: - logger.error("Failed to initialize 1Password client.") return None - - item = await client.items.get( - item_id=info["item_id"], vault_id=params["SAAS_SECRETS_OP_VAULT_ID"] - ) - - # Convert to dictionary format - item_dict = { - "id": item.id, - "title": item.title, - "category": item.category, - "fields": [], - } - - # Add field information - if hasattr(item, "fields") and item.fields: - for field in item.fields: - field_info = { - "id": getattr(field, "id", ""), - "title": getattr(field, "title", ""), - "type": getattr(field, "field_type", ""), - "value": getattr(field, "value", ""), - "purpose": getattr(field, "purpose", ""), - } - item_dict["fields"].append(field_info) - - logger.info(f"Retrieved item '{title}' with {len(item_dict['fields'])} fields") - return item_dict + return await client.get_item(title) async def list_available_items() -> List[str]: - """ - List all available item titles across all vaults - - Returns: - List of item titles - """ - await _initialize_mappings() - return list(_title_to_id_mapping.keys()) + """List all available item titles in the SaaS secrets vault.""" + client = _get_client() + if client is None: + return [] + return await client.list_items() def get_secrets(connector: str) -> Dict[str, Any]: """ - Get secrets from 1Password with flexible field selection + Get secrets from 1Password by connector name (synchronous). Args: - connector: The connector name (used as default item name) - fields: Specific fields to retrieve. If None, tries to get common fields + connector: The connector name (used as the 1PW item title) Returns: Dict mapping field names to values """ - # Create an isolated event loop without affecting global state - loop = asyncio.new_event_loop() - try: - # Don't set as global loop - just use it locally - return loop.run_until_complete(get_secrets_by_title(connector)) - finally: - loop.close() + client = _get_client() + if client is None: + return {} + return client.get_secrets_sync(connector) async def get_secrets_by_title(title: str) -> Dict[str, Any]: - """Async implementation of secret retrieval by title""" - item_data = await get_item_by_title(title) - - if not item_data: - logger.warning(f"No item found with title: {title}") + """Async implementation of secret retrieval by title.""" + client = _get_client() + if client is None: return {} - - secrets_map = {} - for field in item_data.get("fields", []): - field_title = field.get("title", "") - field_value = field.get("value", "") - if field_title and field_value: - secrets_map[field_title] = field_value - - logger.info(f"Loading secrets for {title}: {', '.join(secrets_map.keys())}") - return secrets_map + return await client.get_secrets(title)