cnos-aws 1.11.4__tar.gz

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.
@@ -0,0 +1,33 @@
1
+ .coop/logs/
2
+ .coop/tmp/
3
+ node_modules/
4
+ dist/
5
+ coverage/
6
+ .artifacts/
7
+ .turbo/
8
+ .DS_Store
9
+ Thumbs.db
10
+ .idea/
11
+ .vscode/
12
+ *.log
13
+ .env
14
+ .env.local
15
+ .env.*.local
16
+ !.env.example
17
+ pnpm-debug.log*
18
+
19
+ # Claude Code worktrees and session scratch
20
+ .claude/
21
+ .tmp/
22
+
23
+ # Python bytecode
24
+ __pycache__/
25
+ *.pyc
26
+ *.pyo
27
+ *.pyd
28
+ .pytest_cache/
29
+
30
+ # Java build output
31
+ target/
32
+ *.class
33
+ *.jar
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cnos-aws
3
+ Version: 1.11.4
4
+ Summary: CNOS AWS Secrets Manager vault provider
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: boto3>=1.26.0
7
+ Requires-Dist: cnos>=1.11.4
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cnos-aws"
7
+ version = "1.11.4"
8
+ description = "CNOS AWS Secrets Manager vault provider"
9
+ requires-python = ">=3.8"
10
+ dependencies = [
11
+ "cnos>=1.11.4",
12
+ "boto3>=1.26.0",
13
+ ]
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["src/cnos_aws"]
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ """CNOS AWS Secrets Manager vault provider."""
2
+ from cnos_aws.provider import AwsSecretsManagerProvider, factory
3
+
4
+ __all__ = ["AwsSecretsManagerProvider", "factory"]
@@ -0,0 +1,214 @@
1
+ """AWS Secrets Manager CNOS vault provider — mirrors Go's awssecretsmanager.go."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from cnos.errors import CnosError
7
+ from cnos.types import (
8
+ SecretVaultProvider,
9
+ SecretVaultProviderFactory,
10
+ VaultAuthConfig,
11
+ VaultDefinition,
12
+ )
13
+
14
+ PROVIDER_NAME = "aws-secrets-manager"
15
+
16
+
17
+ def _string_config(config: Optional[Dict[str, Any]], key: str) -> str:
18
+ if not config:
19
+ return ""
20
+ val = config.get(key, "")
21
+ return str(val).strip() if val else ""
22
+
23
+
24
+ def _first_string_config(config: Optional[Dict[str, Any]], *keys: str) -> str:
25
+ for key in keys:
26
+ val = _string_config(config, key)
27
+ if val:
28
+ return val
29
+ return ""
30
+
31
+
32
+ def _unique_sorted(refs: List[str]) -> List[str]:
33
+ return sorted(set(refs))
34
+
35
+
36
+ class AwsSecretsManagerProvider(SecretVaultProvider):
37
+ """AWS Secrets Manager provider. Supports injecting a mock client for tests."""
38
+
39
+ def __init__(
40
+ self,
41
+ vault_id: str,
42
+ definition: VaultDefinition,
43
+ client: Any = None,
44
+ ) -> None:
45
+ self._vault_id = vault_id
46
+ self._definition = definition
47
+ self._config = self._read_config(definition)
48
+ self._client = client
49
+ self._authenticated = False
50
+
51
+ @staticmethod
52
+ def _read_config(definition: VaultDefinition) -> Dict[str, str]:
53
+ config = definition.auth.config or {}
54
+ return {
55
+ "region": _string_config(config, "region"),
56
+ "endpoint": _string_config(config, "endpoint"),
57
+ "version_id": _first_string_config(config, "versionId", "version"),
58
+ "version_stage": _string_config(config, "versionStage"),
59
+ }
60
+
61
+ def authenticate(self, auth: VaultAuthConfig) -> None:
62
+ if auth.method not in ("iam", "environment"):
63
+ raise CnosError(
64
+ f'vault "{self._vault_id}" uses {PROVIDER_NAME} and requires iam authentication'
65
+ )
66
+ self._authenticated = True
67
+ if self._client is None:
68
+ self._client = self._build_client()
69
+
70
+ def _build_client(self) -> Any:
71
+ try:
72
+ import boto3
73
+ except ImportError as exc:
74
+ raise CnosError("cnos-aws: boto3 is required. Install with: pip install boto3") from exc
75
+ kwargs: Dict[str, Any] = {}
76
+ if self._config["region"]:
77
+ kwargs["region_name"] = self._config["region"]
78
+ client = boto3.client("secretsmanager", **kwargs)
79
+ if self._config["endpoint"]:
80
+ client = boto3.client(
81
+ "secretsmanager",
82
+ endpoint_url=self._config["endpoint"],
83
+ **{k: v for k, v in kwargs.items()},
84
+ )
85
+ return client
86
+
87
+ def batch_get(self, refs: List[str]) -> Dict[str, Any]:
88
+ unique = _unique_sorted(refs)
89
+ # If version params set, fall back to individual gets
90
+ if self._config["version_id"] or self._config["version_stage"]:
91
+ return self._get_each(unique)
92
+
93
+ # Try BatchGetSecretValue
94
+ external_to_logical: Dict[str, str] = {}
95
+ secret_ids: List[str] = []
96
+ for ref in unique:
97
+ external = self._external_secret_id(ref)
98
+ external_to_logical[external] = ref
99
+ secret_ids.append(external)
100
+
101
+ try:
102
+ response = self._client.batch_get_secret_value(SecretIdList=secret_ids)
103
+ except Exception as exc:
104
+ if self._is_resource_not_found(exc) or self._is_operation_not_supported(exc):
105
+ return self._get_each(unique)
106
+ raise CnosError(f"cnos-aws: batch_get_secret_value failed: {exc}") from exc
107
+
108
+ # Check batch errors
109
+ for err in response.get("Errors") or []:
110
+ if err.get("ErrorCode") == "ResourceNotFoundException":
111
+ continue
112
+ code = err.get("ErrorCode", "UnknownError")
113
+ msg = err.get("Message", "")
114
+ secret_id = err.get("SecretId", "")
115
+ raise CnosError(
116
+ f"AWS Secrets Manager batch read failed for {secret_id!r}: {code}: {msg}"
117
+ )
118
+
119
+ resolved: Dict[str, Any] = {}
120
+ for secret in response.get("SecretValues") or []:
121
+ ref = self._resolve_output_ref(secret, external_to_logical)
122
+ value = self._decode_secret_value(secret)
123
+ if ref and value is not None:
124
+ resolved[ref] = value
125
+ return resolved
126
+
127
+ def get(self, ref: str) -> Optional[Any]:
128
+ value = self._get_one(ref)
129
+ return value
130
+
131
+ def _get_each(self, refs: List[str]) -> Dict[str, Any]:
132
+ result: Dict[str, Any] = {}
133
+ for ref in refs:
134
+ val = self._get_one(ref)
135
+ if val is not None:
136
+ result[ref] = val
137
+ return result
138
+
139
+ def _get_one(self, ref: str) -> Optional[str]:
140
+ kwargs: Dict[str, Any] = {"SecretId": self._external_secret_id(ref)}
141
+ if self._config["version_id"]:
142
+ kwargs["VersionId"] = self._config["version_id"]
143
+ if self._config["version_stage"]:
144
+ kwargs["VersionStage"] = self._config["version_stage"]
145
+ try:
146
+ response = self._client.get_secret_value(**kwargs)
147
+ except Exception as exc:
148
+ if self._is_resource_not_found(exc):
149
+ return None
150
+ raise CnosError(f"cnos-aws: get_secret_value failed for {ref!r}: {exc}") from exc
151
+ return self._decode_get_secret_value(response)
152
+
153
+ def _external_secret_id(self, ref: str) -> str:
154
+ """Map logical ref to external secret ID via definition.mapping."""
155
+ mapping = self._definition.mapping or {}
156
+ for external, logical in mapping.items():
157
+ if logical == ref:
158
+ return external
159
+ return ref
160
+
161
+ def _logical_ref_for_external(self, secret_id: str) -> str:
162
+ mapping = self._definition.mapping or {}
163
+ return mapping.get(secret_id, secret_id)
164
+
165
+ def _resolve_output_ref(self, secret: Dict[str, Any], external_to_logical: Dict[str, str]) -> str:
166
+ arn = secret.get("ARN") or ""
167
+ if arn and arn in external_to_logical:
168
+ return external_to_logical[arn]
169
+ name = secret.get("Name") or ""
170
+ if name:
171
+ if name in external_to_logical:
172
+ return external_to_logical[name]
173
+ return self._logical_ref_for_external(name)
174
+ return ""
175
+
176
+ @staticmethod
177
+ def _decode_secret_value(secret: Dict[str, Any]) -> Optional[str]:
178
+ if secret.get("SecretString") is not None:
179
+ return secret["SecretString"]
180
+ if secret.get("SecretBinary") is not None:
181
+ return secret["SecretBinary"].decode("utf-8", errors="replace")
182
+ return None
183
+
184
+ @staticmethod
185
+ def _decode_get_secret_value(response: Dict[str, Any]) -> Optional[str]:
186
+ if response.get("SecretString") is not None:
187
+ return response["SecretString"]
188
+ if response.get("SecretBinary") is not None:
189
+ return response["SecretBinary"].decode("utf-8", errors="replace")
190
+ return None
191
+
192
+ @staticmethod
193
+ def _is_resource_not_found(exc: Exception) -> bool:
194
+ cls_name = type(exc).__name__
195
+ if "ResourceNotFoundException" in cls_name:
196
+ return True
197
+ code = getattr(exc, "response", {}).get("Error", {}).get("Code", "")
198
+ return code == "ResourceNotFoundException"
199
+
200
+ @staticmethod
201
+ def _is_operation_not_supported(exc: Exception) -> bool:
202
+ cls_name = type(exc).__name__
203
+ return "InvalidRequestException" in cls_name or "OperationNotPermitted" in cls_name
204
+
205
+
206
+ def factory(client: Any = None) -> SecretVaultProviderFactory:
207
+ """Return a SecretVaultProviderFactory for AWS Secrets Manager.
208
+
209
+ Pass client= to inject a mock for tests.
210
+ """
211
+ def create(vault_id: str, definition: VaultDefinition) -> AwsSecretsManagerProvider:
212
+ return AwsSecretsManagerProvider(vault_id, definition, client=client)
213
+
214
+ return SecretVaultProviderFactory(provider=PROVIDER_NAME, create=create)
File without changes
@@ -0,0 +1,139 @@
1
+ """Tests for the AWS Secrets Manager provider."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from cnos.types import VaultAuthConfig, VaultAuthDefinition, VaultDefinition
7
+ from cnos_aws.provider import AwsSecretsManagerProvider, PROVIDER_NAME
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Mock client
12
+ # ---------------------------------------------------------------------------
13
+
14
+ class MockAwsClient:
15
+ def __init__(self, secrets: dict, raise_on_batch: bool = False, not_found: set = None):
16
+ self._secrets = secrets
17
+ self._raise_on_batch = raise_on_batch
18
+ self._not_found = not_found or set()
19
+
20
+ def batch_get_secret_value(self, **kwargs):
21
+ if self._raise_on_batch:
22
+ raise _ResourceNotFoundException("not found")
23
+ secret_ids = kwargs.get("SecretIdList", [])
24
+ values = []
25
+ errors = []
26
+ for sid in secret_ids:
27
+ if sid in self._not_found:
28
+ errors.append({"ErrorCode": "ResourceNotFoundException", "SecretId": sid})
29
+ elif sid in self._secrets:
30
+ values.append({"Name": sid, "SecretString": self._secrets[sid]})
31
+ return {"SecretValues": values, "Errors": errors}
32
+
33
+ def get_secret_value(self, **kwargs):
34
+ sid = kwargs.get("SecretId", "")
35
+ if sid in self._not_found:
36
+ raise _ResourceNotFoundException(sid)
37
+ if sid in self._secrets:
38
+ return {"SecretString": self._secrets[sid]}
39
+ raise _ResourceNotFoundException(sid)
40
+
41
+
42
+ class _ResourceNotFoundException(Exception):
43
+ def __init__(self, msg=""):
44
+ super().__init__(msg)
45
+ self.response = {"Error": {"Code": "ResourceNotFoundException"}}
46
+
47
+
48
+ def _make_provider(secrets: dict, **kwargs) -> AwsSecretsManagerProvider:
49
+ definition = VaultDefinition(
50
+ provider=PROVIDER_NAME,
51
+ auth=VaultAuthDefinition(method="iam"),
52
+ mapping={},
53
+ )
54
+ client = MockAwsClient(secrets, **kwargs)
55
+ p = AwsSecretsManagerProvider("test-vault", definition, client=client)
56
+ p._authenticated = True
57
+ return p
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Tests
62
+ # ---------------------------------------------------------------------------
63
+
64
+ class TestAuthenticate:
65
+ def test_iam_auth_accepted(self):
66
+ p = _make_provider({})
67
+ p._authenticated = False
68
+ p.authenticate(VaultAuthConfig(method="iam"))
69
+ assert p._authenticated
70
+
71
+ def test_environment_auth_accepted(self):
72
+ p = _make_provider({})
73
+ p._authenticated = False
74
+ p.authenticate(VaultAuthConfig(method="environment"))
75
+ assert p._authenticated
76
+
77
+ def test_wrong_auth_raises(self):
78
+ p = _make_provider({})
79
+ p._authenticated = False
80
+ with pytest.raises(Exception, match="iam authentication"):
81
+ p.authenticate(VaultAuthConfig(method="token", token="tok"))
82
+
83
+
84
+ class TestBatchGet:
85
+ def test_returns_secrets(self):
86
+ p = _make_provider({"my-secret": "value123"})
87
+ result = p.batch_get(["my-secret"])
88
+ assert result == {"my-secret": "value123"}
89
+
90
+ def test_missing_secret_not_in_result(self):
91
+ p = _make_provider({}, not_found={"missing-secret"})
92
+ result = p.batch_get(["missing-secret"])
93
+ assert "missing-secret" not in result
94
+
95
+ def test_multiple_secrets(self):
96
+ p = _make_provider({"s1": "v1", "s2": "v2"})
97
+ result = p.batch_get(["s1", "s2"])
98
+ assert result == {"s1": "v1", "s2": "v2"}
99
+
100
+ def test_deduplicates_refs(self):
101
+ p = _make_provider({"s1": "v1"})
102
+ result = p.batch_get(["s1", "s1", "s1"])
103
+ assert result == {"s1": "v1"}
104
+
105
+
106
+ class TestGet:
107
+ def test_existing_secret(self):
108
+ p = _make_provider({"sec": "val"})
109
+ assert p.get("sec") == "val"
110
+
111
+ def test_missing_returns_none(self):
112
+ p = _make_provider({}, not_found={"missing"})
113
+ assert p.get("missing") is None
114
+
115
+
116
+ class TestMapping:
117
+ def test_external_mapping(self):
118
+ definition = VaultDefinition(
119
+ provider=PROVIDER_NAME,
120
+ auth=VaultAuthDefinition(method="iam"),
121
+ mapping={"arn:aws:secretsmanager:us-east-1:123:secret:prod/db": "db.password"},
122
+ )
123
+ client = MockAwsClient({"arn:aws:secretsmanager:us-east-1:123:secret:prod/db": "secret!"})
124
+ p = AwsSecretsManagerProvider("v", definition, client=client)
125
+ p._authenticated = True
126
+ result = p.batch_get(["db.password"])
127
+ assert result.get("db.password") == "secret!"
128
+
129
+
130
+ class TestFallbackToIndividualGet:
131
+ def test_fallback_on_batch_error(self):
132
+ p = _make_provider({"sec": "val"}, raise_on_batch=True)
133
+ result = p.batch_get(["sec"])
134
+ assert result.get("sec") == "val"
135
+
136
+
137
+ class TestProviderName:
138
+ def test_provider_name_constant(self):
139
+ assert PROVIDER_NAME == "aws-secrets-manager"