From 979a294c8fcdbcd843323021e69d613dc1ce3f68 Mon Sep 17 00:00:00 2001 From: Masaki Yatsu Date: Sat, 6 Sep 2025 20:56:00 +0900 Subject: [PATCH] feat(jupyterhub): SecretStore.list_fieds() --- python-package/README.md | 21 +++++++ python-package/buunstack/secrets.py | 95 ++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/python-package/README.md b/python-package/README.md index 5b38322..5255069 100644 --- a/python-package/README.md +++ b/python-package/README.md @@ -37,8 +37,15 @@ secrets.put('api-keys', api_keys = secrets.get('api-keys') openai_key = api_keys['openai_key'] +# Get specific field directly +openai_key = secrets.get('api-keys', field='openai_key') + # List all your secrets all_secrets = secrets.list() + +# List fields in a specific secret +fields = secrets.list_fields('api-keys') +print(f'Available fields: {fields}') # ['openai_key', 'github_token', 'database_url'] ``` ### Configuration Options @@ -59,6 +66,20 @@ print(f"JupyterHub sync enabled: {status['sync_with_jupyterhub']}") print(f"API configured: {status.get('jupyterhub_api_configured', False)}") ``` +### Advanced Operations + +```python +# Delete a specific field from a secret +secrets.delete('api-keys', field='github_token') + +# Delete an entire secret +secrets.delete('old-config') + +# Check if a field exists before accessing +if 'openai_key' in secrets.list_fields('api-keys'): + key = secrets.get('api-keys', field='openai_key') +``` + ### Environment Variables Helper ```python diff --git a/python-package/buunstack/secrets.py b/python-package/buunstack/secrets.py index a158085..4cc7cb9 100644 --- a/python-package/buunstack/secrets.py +++ b/python-package/buunstack/secrets.py @@ -2,6 +2,8 @@ Secrets management with user-specific Vault token authentication """ +from __future__ import annotations + import logging import os import warnings @@ -353,13 +355,31 @@ class SecretStore: path = f"{self.base_path}/{key}" if field is None: - # Delete entire secret + # Delete entire secret - first check if it exists try: + # Check if the secret exists first + response = self.client.secrets.kv.v2.read_secret_version( + path=path, mount_point="secret", raise_on_deleted_version=False + ) + if ( + not response + or "data" not in response + or "data" not in response["data"] + ): + raise KeyError(f"Secret '{key}' not found") + + # Now delete it self.client.secrets.kv.v2.delete_metadata_and_all_versions( path=path, mount_point="secret" ) logger.info(f"Deleted secret: {key}") + except KeyError as e: + logger.error(f"Failed to delete: {e}") + raise except Exception as e: + # Check if the error is due to the secret not existing + if "path not found" in str(e).lower() or "not found" in str(e).lower(): + raise KeyError(f"Secret '{key}' not found") from e logger.error(f'Failed to delete secret "{key}": {e}') raise else: @@ -393,7 +413,8 @@ class SecretStore: logger.info(f"Deleted field '{field}' from secret '{key}'") else: raise KeyError(f"Secret '{key}' not found") - except KeyError: + except KeyError as e: + logger.error(f"Failed to delete field: {e}") raise except Exception as e: logger.error( @@ -430,9 +451,77 @@ class SecretStore: logger.info(f"Listed {len(keys)} secrets") return keys except Exception as e: - logger.warning(f"Could not list secrets: {e}") + # This is expected when no secrets exist yet - just return empty list + logger.debug(f"No secrets found or error listing: {e}") return [] + def list_fields(self, key: str) -> list[str]: + """ + List all field names in a specific secret. + + Returns a list of all field names (keys) stored in the specified secret. + Does not include the actual field values for security reasons. + + Parameters + ---------- + key : str + The key/name of the secret to list fields for. + + Returns + ------- + list[str] + List of field names in the secret. Empty list if the secret doesn't exist. + + Raises + ------ + KeyError + If the secret key doesn't exist. + ConnectionError + If unable to connect to Vault server. + + Examples + -------- + >>> secrets = SecretStore() + >>> secrets.put('api-keys', openai='sk-123', github='ghp-456', azure='az-789') + >>> fields = secrets.list_fields('api-keys') + >>> print(f'Fields in api-keys: {fields}') + ['openai', 'github', 'azure'] + + >>> # Check available fields before accessing + >>> if 'openai' in secrets.list_fields('api-keys'): + ... openai_key = secrets.get('api-keys', field='openai') + """ + self._ensure_authenticated() + + path = f"{self.base_path}/{key}" + try: + response = self.client.secrets.kv.v2.read_secret_version( + path=path, mount_point="secret", raise_on_deleted_version=False + ) + if response and "data" in response and "data" in response["data"]: + data = response["data"]["data"] + fields = list(data.keys()) + logger.info(f"Listed {len(fields)} fields in secret '{key}'") + return fields + else: + raise KeyError(f"Secret '{key}' not found") + except Exception as e: + if "permission denied" in str(e).lower(): + logger.info("Permission denied, re-authenticating...") + self._ensure_authenticated() + response = self.client.secrets.kv.v2.read_secret_version( + path=path, mount_point="secret", raise_on_deleted_version=False + ) + if response and "data" in response and "data" in response["data"]: + data = response["data"]["data"] + fields = list(data.keys()) + logger.info(f"Listed {len(fields)} fields in secret '{key}'") + return fields + else: + raise KeyError(f"Secret '{key}' not found") + logger.warning(f"Could not list fields for secret '{key}': {e}") + raise KeyError(f"Secret '{key}' not found") from e + def get_status(self) -> dict[str, Any]: """ Get status information about the SecretStore instance.