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
|
cnos_aws-1.11.4/PKG-INFO
ADDED
|
@@ -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,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"
|