dataflow-core 2.1.6__py3-none-any.whl → 2.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dataflow-core might be problematic. Click here for more details.

@@ -0,0 +1,59 @@
1
+ # secrets_manager/factory.py
2
+ import os
3
+ from .interface import SecretManager
4
+ from .providers.aws_manager import AWSSecretsManager
5
+ from .providers.azure_manager import AzureKeyVault
6
+ from ..configuration import ConfigurationManager
7
+
8
+ # A custom exception for clear error messages
9
+ class SecretProviderError(Exception):
10
+ pass
11
+
12
+ def get_secret_manager() -> SecretManager:
13
+ """
14
+ Factory function to get the configured secret manager instance.
15
+
16
+ Reads the cloud provider configuration from dataflow_auth.cfg
17
+ to determine which cloud provider's secret manager to instantiate.
18
+ """
19
+ try:
20
+ # dataflow_config = None
21
+ # if os.getenv('HOSTNAME'):
22
+ # dataflow_config = ConfigurationManager('/dataflow/app/auth_config/dataflow_auth.cfg')
23
+ # else:
24
+ dataflow_config = ConfigurationManager('/dataflow/app/config/dataflow.cfg')
25
+ except Exception as e:
26
+ raise SecretProviderError(
27
+ f"Failed to read cloud provider configuration: {str(e)}. "
28
+ "Please check that the configuration file exists and contains the 'cloud' section."
29
+ )
30
+
31
+ provider = dataflow_config.get_config_value('cloudProvider', 'cloud')
32
+ if not provider:
33
+ raise SecretProviderError(
34
+ "The cloud provider is not configured in config file. "
35
+ "Please set the 'cloud' value in the 'cloud' section to 'aws' or 'azure'."
36
+ )
37
+
38
+ provider = provider.lower()
39
+ print(f"Initializing secret manager for provider: {provider}")
40
+
41
+ if provider == "aws":
42
+ return AWSSecretsManager()
43
+
44
+ elif provider == "azure":
45
+ vault_url = dataflow_config.get_config_value('cloudProvider', 'key_vault')
46
+ if not vault_url:
47
+ raise SecretProviderError(
48
+ "AZURE_VAULT_URL must be set when using the Azure provider."
49
+ )
50
+ return AzureKeyVault(vault_url=vault_url)
51
+
52
+ # You can easily add more providers here in the future
53
+ # elif provider == "gcp":
54
+ # return GCPSecretManager()
55
+
56
+ else:
57
+ raise SecretProviderError(
58
+ f"Unsupported secret provider: '{provider}'. Supported providers are: aws, azure."
59
+ )
@@ -0,0 +1,22 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class SecretManager(ABC):
4
+ @abstractmethod
5
+ def create_secret(self, vault_path: str, secret_data: dict) -> str:
6
+ pass
7
+
8
+ @abstractmethod
9
+ def get_secret_by_key(self, vault_path: str):
10
+ pass
11
+
12
+ @abstractmethod
13
+ def update_secret(self, vault_path: str, update_data):
14
+ pass
15
+
16
+ @abstractmethod
17
+ def delete_secret(self, vault_path: str):
18
+ pass
19
+
20
+ @abstractmethod
21
+ def test_connection(self, vault_path: str):
22
+ pass
File without changes
@@ -0,0 +1,164 @@
1
+
2
+ import boto3
3
+ import json
4
+ from botocore.exceptions import BotoCoreError, ClientError, EndpointConnectionError, NoCredentialsError
5
+ from ..interface import SecretManager
6
+ from ...utils.exceptions import (
7
+ SecretNotFoundException,
8
+ SecretAlreadyExistsException,
9
+ SecretManagerAuthException,
10
+ SecretManagerServiceException
11
+ )
12
+
13
+ class AWSSecretsManager(SecretManager):
14
+ def __init__(self):
15
+ try:
16
+ self.client = boto3.client('secretsmanager')
17
+ except EndpointConnectionError as e:
18
+ raise SecretManagerServiceException("initialize_aws_client", original_error=str(e))
19
+ except NoCredentialsError as e:
20
+ raise SecretManagerAuthException("initialize_aws_client", original_error=str(e))
21
+ except Exception as e:
22
+ raise SecretManagerServiceException("initialize_aws_client", original_error=str(e))
23
+
24
+ def create_secret(self, vault_path: str, secret_data: dict) -> str:
25
+ try:
26
+ # Convert dictionary to JSON string before saving
27
+ secret_string = json.dumps(secret_data)
28
+
29
+ self.client.create_secret(
30
+ Name=vault_path,
31
+ SecretString=secret_string,
32
+ Description=secret_data.get("description", "Created by AWSSecretsManager")
33
+ )
34
+ return "Secret created successfully"
35
+ except ClientError as e:
36
+ error_code = e.response['Error']['Code']
37
+ error_message = e.response['Error']['Message']
38
+
39
+ if error_code == 'ResourceExistsException':
40
+ raise SecretAlreadyExistsException("secret", vault_path, original_error=str(e))
41
+ elif error_code == 'InvalidRequestException' and 'scheduled for deletion' in error_message:
42
+ # Special case for secrets in recovery period
43
+ raise SecretAlreadyExistsException("secret", vault_path, original_error=str(e), is_scheduled_for_deletion=True)
44
+ elif error_code in ['AccessDeniedException', 'UnauthorizedOperation', 'UnrecognizedClientException']:
45
+ raise SecretManagerAuthException("create_secret", original_error=str(e))
46
+ elif error_code in ['InvalidRequestException', 'LimitExceededException']:
47
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
48
+ elif error_code == 'InternalServiceErrorException':
49
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
50
+ else:
51
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
52
+ except BotoCoreError as e:
53
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
54
+ except Exception as e:
55
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
56
+
57
+ def get_secret_by_key(self, vault_path: str) -> dict:
58
+ try:
59
+ response = self.client.get_secret_value(SecretId=vault_path)
60
+ secret_string = response.get('SecretString')
61
+
62
+ # Convert JSON string back to dictionary before returning
63
+ secret_data = json.loads(secret_string)
64
+ return secret_data
65
+ except ClientError as e:
66
+ error_code = e.response['Error']['Code']
67
+ if error_code == 'ResourceNotFoundException':
68
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
69
+ elif error_code in ['AccessDeniedException', 'UnauthorizedOperation', 'UnrecognizedClientException']:
70
+ raise SecretManagerAuthException("get_secret", original_error=str(e))
71
+ elif error_code in ['InvalidRequestException', 'DecryptionFailureException']:
72
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
73
+ elif error_code == 'InternalServiceErrorException':
74
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
75
+ else:
76
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
77
+ except json.JSONDecodeError as e:
78
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
79
+ except BotoCoreError as e:
80
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
81
+ except Exception as e:
82
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
83
+
84
+ def update_secret(self, vault_path: str, update_data: dict) -> str:
85
+ try:
86
+ # Get current secret data
87
+ current = self.client.get_secret_value(SecretId=vault_path)
88
+ current_string = current['SecretString']
89
+
90
+ # Convert current JSON string to dictionary
91
+ current_data = json.loads(current_string)
92
+
93
+ # Update with new data
94
+ current_data.update(update_data)
95
+
96
+ # Convert updated dictionary back to JSON string
97
+ updated_string = json.dumps(current_data)
98
+
99
+ self.client.update_secret(
100
+ SecretId=vault_path,
101
+ SecretString=updated_string,
102
+ Description=current_data.get('description', '')
103
+ )
104
+ return "Secret updated successfully"
105
+ except ClientError as e:
106
+ error_code = e.response['Error']['Code']
107
+ if error_code == 'ResourceNotFoundException':
108
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
109
+ elif error_code in ['AccessDeniedException', 'UnauthorizedOperation', 'UnrecognizedClientException']:
110
+ raise SecretManagerAuthException("update_secret", original_error=str(e))
111
+ elif error_code in ['InvalidRequestException', 'DecryptionFailureException']:
112
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
113
+ elif error_code == 'InternalServiceErrorException':
114
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
115
+ else:
116
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
117
+ except json.JSONDecodeError as e:
118
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
119
+ except BotoCoreError as e:
120
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
121
+ except Exception as e:
122
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
123
+
124
+ def delete_secret(self, vault_path: str) -> str:
125
+ try:
126
+ if "git-ssh" in vault_path:
127
+ self.client.delete_secret(
128
+ SecretId=vault_path,
129
+ ForceDeleteWithoutRecovery=True
130
+ )
131
+ else:
132
+ self.client.delete_secret(
133
+ SecretId=vault_path,
134
+ RecoveryWindowInDays=7
135
+ )
136
+ return "Secret deleted successfully"
137
+ except ClientError as e:
138
+ error_code = e.response['Error']['Code']
139
+ if error_code == 'ResourceNotFoundException':
140
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
141
+ elif error_code in ['AccessDeniedException', 'UnauthorizedOperation', 'UnrecognizedClientException']:
142
+ raise SecretManagerAuthException("delete_secret", original_error=str(e))
143
+ elif error_code == 'InvalidRequestException':
144
+ # Can occur if secret is already scheduled for deletion or in invalid state
145
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
146
+ elif error_code == 'InternalServiceErrorException':
147
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
148
+ else:
149
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
150
+ except BotoCoreError as e:
151
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
152
+ except Exception as e:
153
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
154
+
155
+ def test_connection(self, vault_path: str) -> str:
156
+ try:
157
+ secret = self.get_secret_by_key(vault_path)
158
+ return secret.get('status', 'Unknown')
159
+ except SecretNotFoundException:
160
+ raise
161
+ except (SecretManagerAuthException, SecretManagerServiceException):
162
+ raise
163
+ except Exception as e:
164
+ raise SecretManagerServiceException("test_connection", original_error=str(e))
@@ -0,0 +1,185 @@
1
+
2
+ from azure.keyvault.secrets import SecretClient
3
+ from azure.identity import DefaultAzureCredential
4
+ from azure.core.exceptions import (
5
+ ResourceNotFoundError,
6
+ ResourceExistsError,
7
+ HttpResponseError,
8
+ ClientAuthenticationError,
9
+ ServiceRequestError
10
+ )
11
+ from ..interface import SecretManager
12
+ from ...utils.exceptions import (
13
+ SecretNotFoundException,
14
+ SecretAlreadyExistsException,
15
+ SecretManagerAuthException,
16
+ SecretManagerServiceException
17
+ )
18
+ import json
19
+
20
+ class AzureKeyVault(SecretManager):
21
+ def __init__(self, vault_url: str):
22
+ try:
23
+ credential = DefaultAzureCredential(additionally_allowed_tenants=["*"])
24
+ self.client = SecretClient(vault_url=vault_url, credential=credential)
25
+ except ClientAuthenticationError as e:
26
+ raise SecretManagerAuthException("initialize_azure_client", original_error=str(e))
27
+ except ServiceRequestError as e:
28
+ raise SecretManagerServiceException("initialize_azure_client", original_error=str(e))
29
+ except Exception as e:
30
+ raise SecretManagerServiceException("initialize_azure_client", original_error=str(e))
31
+
32
+ def create_secret(self, vault_path: str, secret_data: dict) -> str:
33
+ try:
34
+ # Convert dictionary to JSON string before saving
35
+ secret_string = json.dumps(secret_data)
36
+
37
+ self.client.set_secret(
38
+ name=vault_path,
39
+ value=secret_string,
40
+ content_type="application/json",
41
+ tags={"description": secret_data.get("description", "Created by AzureKeyVault")}
42
+ )
43
+ return "Secret created successfully"
44
+ except ResourceExistsError as e:
45
+ raise SecretAlreadyExistsException("secret", vault_path, original_error=str(e))
46
+ except ClientAuthenticationError as e:
47
+ raise SecretManagerAuthException("create_secret", original_error=str(e))
48
+ except HttpResponseError as e:
49
+ if e.status_code == 403:
50
+ raise SecretManagerAuthException("create_secret", original_error=str(e))
51
+ elif e.status_code == 409:
52
+ # Check if it's a scheduled deletion case
53
+ if "deleted but not purged" in str(e) or "being recovered" in str(e):
54
+ raise SecretAlreadyExistsException("secret", vault_path, original_error=str(e), is_scheduled_for_deletion=True)
55
+ else:
56
+ raise SecretAlreadyExistsException("secret", vault_path, original_error=str(e))
57
+ elif e.status_code == 429:
58
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
59
+ elif e.status_code >= 500:
60
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
61
+ else:
62
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
63
+ except ServiceRequestError as e:
64
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
65
+ except Exception as e:
66
+ raise SecretManagerServiceException("create_secret", original_error=str(e))
67
+
68
+ def get_secret_by_key(self, vault_path: str) -> dict:
69
+ try:
70
+ secret = self.client.get_secret(vault_path)
71
+ secret_string = secret.value
72
+
73
+ # Convert JSON string back to dictionary before returning
74
+ secret_data = json.loads(secret_string)
75
+ return secret_data
76
+ except ResourceNotFoundError as e:
77
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
78
+ except ClientAuthenticationError as e:
79
+ raise SecretManagerAuthException("get_secret", original_error=str(e))
80
+ except HttpResponseError as e:
81
+ if e.status_code == 403:
82
+ raise SecretManagerAuthException("get_secret", original_error=str(e))
83
+ elif e.status_code == 404:
84
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
85
+ elif e.status_code >= 500:
86
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
87
+ else:
88
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
89
+ except json.JSONDecodeError as e:
90
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
91
+ except ServiceRequestError as e:
92
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
93
+ except Exception as e:
94
+ raise SecretManagerServiceException("get_secret", original_error=str(e))
95
+
96
+ def update_secret(self, vault_path: str, update_data: dict) -> str:
97
+ try:
98
+ # Get current secret data
99
+ current_secret = self.client.get_secret(vault_path)
100
+ current_string = current_secret.value
101
+
102
+ # Convert current JSON string to dictionary
103
+ current_data = json.loads(current_string)
104
+
105
+ # Update with new data
106
+ current_data.update(update_data)
107
+
108
+ # Convert updated dictionary back to JSON string
109
+ updated_string = json.dumps(current_data)
110
+
111
+ self.client.set_secret(
112
+ name=vault_path,
113
+ value=updated_string,
114
+ content_type="application/json",
115
+ tags={"description": current_data.get("description", "")}
116
+ )
117
+ return "Secret updated successfully"
118
+ except ResourceNotFoundError as e:
119
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
120
+ except ClientAuthenticationError as e:
121
+ raise SecretManagerAuthException("update_secret", original_error=str(e))
122
+ except HttpResponseError as e:
123
+ if e.status_code == 403:
124
+ raise SecretManagerAuthException("update_secret", original_error=str(e))
125
+ elif e.status_code == 404:
126
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
127
+ elif e.status_code >= 500:
128
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
129
+ else:
130
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
131
+ except json.JSONDecodeError as e:
132
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
133
+ except ServiceRequestError as e:
134
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
135
+ except Exception as e:
136
+ raise SecretManagerServiceException("update_secret", original_error=str(e))
137
+
138
+ def delete_secret(self, vault_path: str) -> str:
139
+ try:
140
+ if "git-ssh" in vault_path:
141
+ # For SSH keys, try to purge immediately after deletion
142
+ delete_poller = self.client.begin_delete_secret(vault_path)
143
+ delete_poller.wait() # Wait for deletion to complete
144
+ try:
145
+ # Try to purge immediately (only works if soft-delete is enabled and allows purging)
146
+ self.client.purge_deleted_secret(vault_path)
147
+ except (ResourceNotFoundError, HttpResponseError):
148
+ # Purge may fail if soft-delete is disabled or purging is not allowed - that's ok
149
+ pass
150
+ else:
151
+ # For other secrets, just delete (respects soft-delete settings)
152
+ delete_poller = self.client.begin_delete_secret(vault_path)
153
+ delete_poller.wait() # Wait for deletion to complete
154
+ return "Secret deleted successfully"
155
+ except ResourceNotFoundError as e:
156
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
157
+ except ClientAuthenticationError as e:
158
+ raise SecretManagerAuthException("delete_secret", original_error=str(e))
159
+ except HttpResponseError as e:
160
+ if e.status_code == 403:
161
+ raise SecretManagerAuthException("delete_secret", original_error=str(e))
162
+ elif e.status_code == 404:
163
+ raise SecretNotFoundException("secret", vault_path, original_error=str(e))
164
+ elif e.status_code == 409:
165
+ # Can occur if secret is already being deleted or in invalid state
166
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
167
+ elif e.status_code >= 500:
168
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
169
+ else:
170
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
171
+ except ServiceRequestError as e:
172
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
173
+ except Exception as e:
174
+ raise SecretManagerServiceException("delete_secret", original_error=str(e))
175
+
176
+ def test_connection(self, vault_path: str) -> str:
177
+ try:
178
+ secret = self.get_secret_by_key(vault_path)
179
+ return secret.get('status', 'Unknown')
180
+ except SecretNotFoundException:
181
+ raise
182
+ except (SecretManagerAuthException, SecretManagerServiceException):
183
+ raise
184
+ except Exception as e:
185
+ raise SecretManagerServiceException("test_connection", original_error=str(e))
@@ -0,0 +1,156 @@
1
+ from .interface import SecretManager
2
+ from ..schemas.connection import ConnectionSave, ConnectionUpdate, ConnectionRead
3
+ from ..schemas.secret import SecretSave, SecretUpdate, SecretRead
4
+ from ..schemas.git_ssh import SSHSave, SSHRead
5
+ from abc import ABC, abstractmethod
6
+ from typing import List
7
+
8
+ # --------------------------------------------------------------------------
9
+ # BASE ACCESSOR
10
+ # This contains all the methods for CRUD operations on secrets, connections, and SSH keys.
11
+ # --------------------------------------------------------------------------
12
+ class _BaseAccessor(ABC):
13
+ def __init__(self, secret_manager: SecretManager):
14
+ self.secret_manager = secret_manager
15
+
16
+ @abstractmethod
17
+ def _get_vault_path(self, secret_type: str, key: str) -> str:
18
+ """This must be implemented by each specific context."""
19
+ pass
20
+
21
+ # --------------------------------------------------------------------------
22
+ # CONNECTION CRUD OPERATIONS
23
+ # --------------------------------------------------------------------------
24
+ def create_connection(self, connection_data: ConnectionSave) -> str:
25
+ """Create a new connection."""
26
+ vault_path = self._get_vault_path("connections", connection_data.conn_id)
27
+ # Convert schema to dict and then to JSON string for storage
28
+ connection_dict = connection_data.model_dump()
29
+ return self.secret_manager.create_secret(vault_path, connection_dict)
30
+
31
+ def get_connection(self, key: str) -> ConnectionRead:
32
+ """Get a connection by key."""
33
+ vault_path = self._get_vault_path("connections", key)
34
+ raw_data = self.secret_manager.get_secret_by_key(vault_path)
35
+ # Convert stored dict back to schema
36
+ return ConnectionRead(**raw_data)
37
+
38
+ def update_connection(self, key: str, connection_data: ConnectionUpdate) -> str:
39
+ """Update an existing connection."""
40
+ vault_path = self._get_vault_path("connections", key)
41
+
42
+ # First, get the existing connection data
43
+ existing_data: dict = self.secret_manager.get_secret_by_key(vault_path)
44
+
45
+ # Convert update schema to dict, only include non-None values
46
+ update_dict: dict = connection_data.model_dump(exclude_none=True)
47
+
48
+ # Merge existing data with update data
49
+ existing_data.update(update_dict)
50
+
51
+ # Send the complete merged data back to storage
52
+ return self.secret_manager.update_secret(vault_path, existing_data)
53
+
54
+ def delete_connection(self, key: str) -> str:
55
+ """Delete a connection."""
56
+ vault_path = self._get_vault_path("connections", key)
57
+ return self.secret_manager.delete_secret(vault_path)
58
+
59
+ def test_connection(self, key: str):
60
+ """Test a connection."""
61
+ vault_path = self._get_vault_path("connections", key)
62
+ return self.secret_manager.test_connection(vault_path)
63
+
64
+ # --------------------------------------------------------------------------
65
+ # SSH CRUD OPERATIONS
66
+ # --------------------------------------------------------------------------
67
+ def create_ssh(self, ssh_data: SSHSave) -> str:
68
+ """Create a new SSH key."""
69
+ vault_path = self._get_vault_path("git-ssh", ssh_data.key_name)
70
+ # Convert schema to dict for storage
71
+ ssh_dict = ssh_data.model_dump()
72
+ return self.secret_manager.create_secret(vault_path, ssh_dict)
73
+
74
+ def get_ssh(self, key: str) -> SSHRead:
75
+ """Get an SSH key by key."""
76
+ vault_path = self._get_vault_path("git-ssh", key)
77
+ raw_data = self.secret_manager.get_secret_by_key(vault_path)
78
+ # Convert stored dict back to schema
79
+ return SSHRead(**raw_data)
80
+
81
+ def delete_ssh(self, key: str) -> str:
82
+ """Delete an SSH key."""
83
+ vault_path = self._get_vault_path("git-ssh", key)
84
+ return self.secret_manager.delete_secret(vault_path)
85
+
86
+ # --------------------------------------------------------------------------
87
+ # SECRET CRUD OPERATIONS
88
+ # --------------------------------------------------------------------------
89
+ def create_secret(self, secret_data: SecretSave) -> str:
90
+ """Create a new secret."""
91
+ vault_path = self._get_vault_path("secrets", secret_data.key)
92
+ # Convert schema to dict for storage
93
+ secret_dict = secret_data.model_dump()
94
+ return self.secret_manager.create_secret(vault_path, secret_dict)
95
+
96
+ def get_secret(self, key: str) -> SecretRead:
97
+ """Get a secret by key."""
98
+ vault_path = self._get_vault_path("secrets", key)
99
+ raw_data = self.secret_manager.get_secret_by_key(vault_path)
100
+ # Convert stored dict back to schema
101
+ return SecretRead(**raw_data)
102
+
103
+ def update_secret(self, key: str, secret_data: SecretUpdate) -> str:
104
+ """Update an existing secret."""
105
+ vault_path = self._get_vault_path("secrets", key)
106
+ # Convert schema to dict, only include non-None values
107
+ update_dict = secret_data.model_dump(exclude_none=True)
108
+ return self.secret_manager.update_secret(vault_path, update_dict)
109
+
110
+ def delete_secret(self, key: str) -> str:
111
+ """Delete a secret."""
112
+ vault_path = self._get_vault_path("secrets", key)
113
+ return self.secret_manager.delete_secret(vault_path)
114
+
115
+ # --------------------------------------------------------------------------
116
+ # CONTEXT-SPECIFIC ACCESSORS
117
+ # These classes implement the logic for building the vault path based on the context.
118
+ # --------------------------------------------------------------------------
119
+ class _RuntimeAccessor(_BaseAccessor):
120
+ def __init__(self, secret_manager: SecretManager, runtime_env: str, slug: str = None):
121
+ super().__init__(secret_manager)
122
+ self.runtime_env = runtime_env
123
+ self.slug = slug
124
+
125
+ def _get_vault_path(self, secret_type: str, key: str) -> str:
126
+ # Special case for git-ssh in runtime context
127
+ if secret_type == "git-ssh":
128
+ return f"{self.runtime_env}-{secret_type}-{self.slug}"
129
+
130
+ # Standard format for all other secret types
131
+ context = self.slug if self.slug else "global"
132
+ return f"{self.runtime_env}-{context}-{secret_type}-{key}"
133
+
134
+ class _StudioAccessor(_BaseAccessor):
135
+ def __init__(self, secret_manager: SecretManager, user_name: str):
136
+ super().__init__(secret_manager)
137
+ self.user_name = user_name
138
+
139
+ def _get_vault_path(self, secret_type: str, key: str) -> str:
140
+ return f"{self.user_name}-{secret_type}-{key}"
141
+
142
+ # --------------------------------------------------------------------------
143
+ # PUBLIC INTERFACE CLASS
144
+ # This is the class external systems will interact with.
145
+ # --------------------------------------------------------------------------
146
+ class SecretsService:
147
+ def __init__(self, secret_manager: SecretManager):
148
+ self._secret_manager = secret_manager
149
+
150
+ def runtime(self, env: str, slug: str = None) -> _RuntimeAccessor:
151
+ """Sets the context to RUNTIME and returns the appropriate accessor."""
152
+ return _RuntimeAccessor(self._secret_manager, env, slug)
153
+
154
+ def studio(self, user: str) -> _StudioAccessor:
155
+ """Sets the context to STUDIO and returns the appropriate accessor."""
156
+ return _StudioAccessor(self._secret_manager, user)