imbi-plugin-aws 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ """AWS plugins for Imbi.
2
+
3
+ Ships three plugin entry points from a single distribution:
4
+
5
+ * ``aws-iam-ic`` -- :class:`IdentityPlugin` for AWS IAM Identity Center.
6
+ * ``aws-ssm`` -- :class:`ConfigurationPlugin` backed by SSM Parameter
7
+ Store.
8
+ * ``aws-cloudwatch-logs`` -- :class:`LogsPlugin` backed by CloudWatch
9
+ Logs Insights.
10
+
11
+ The data plugins consume credentials from a flat ``dict[str, str]``
12
+ populated by either the ``ServiceApplication`` static-key path or the
13
+ identity-hydrated path that calls ``aws-iam-ic.materialize()``;
14
+ neither plugin needs to know which source applied.
15
+ """
16
+
17
+ from imbi_plugin_aws.cloudwatch import CloudWatchLogsPlugin
18
+ from imbi_plugin_aws.identity import AwsIamIcPlugin
19
+ from imbi_plugin_aws.ssm import SsmPlugin
20
+
21
+ __all__ = ['AwsIamIcPlugin', 'CloudWatchLogsPlugin', 'SsmPlugin']
@@ -0,0 +1,31 @@
1
+ """Shared utilities for the AWS data plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from imbi_common.plugins.base import PluginContext
6
+
7
+
8
+ def template_vars(ctx: PluginContext) -> dict[str, str | None]:
9
+ """Whitelisted template variables for plugin option expansion."""
10
+ return {
11
+ 'project_slug': ctx.project_slug,
12
+ 'org_slug': ctx.org_slug,
13
+ 'team_slug': ctx.team_slug,
14
+ 'environment': ctx.environment,
15
+ 'project_id': ctx.project_id,
16
+ }
17
+
18
+
19
+ def assignment_region(ctx: PluginContext) -> str | None:
20
+ region = ctx.assignment_options.get('region')
21
+ return str(region) if region else None
22
+
23
+
24
+ def assignment_timeout(
25
+ ctx: PluginContext, *, default: float, key: str = 'timeout_seconds'
26
+ ) -> float:
27
+ raw = ctx.assignment_options.get(key)
28
+ try:
29
+ return float(raw) if raw is not None else default
30
+ except (TypeError, ValueError):
31
+ return default
@@ -0,0 +1,138 @@
1
+ """``MAPS_TO`` traversal helper for the AWS plugin.
2
+
3
+ At call time, ``materialize`` (or any sibling plugin's handler) needs to
4
+ pick a single :class:`imbi_plugin_aws.models.AwsAccount` from the
5
+ actor's context. Operators model the mapping declaratively as
6
+ ``(:Environment | :Project | :ProjectType | :Organization)-[:MAPS_TO]->
7
+ (:AwsAccount)`` edges; this module walks them in selector order and
8
+ returns the first match.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import typing
15
+
16
+ from imbi_common.plugins.base import PluginContext
17
+
18
+ from imbi_plugin_aws.errors import AccountNotResolvedError
19
+ from imbi_plugin_aws.models import AwsAccount
20
+
21
+ LOGGER = logging.getLogger(__name__)
22
+
23
+ DEFAULT_SELECTOR: list[str] = [
24
+ 'project',
25
+ 'environment',
26
+ 'project_type',
27
+ 'organization',
28
+ ]
29
+
30
+ _LABEL_FOR_ANCHOR: dict[str, str] = {
31
+ 'project': 'Project',
32
+ 'environment': 'Environment',
33
+ 'project_type': 'ProjectType',
34
+ 'organization': 'Organization',
35
+ }
36
+
37
+
38
+ async def resolve_account(
39
+ db: typing.Any,
40
+ ctx: PluginContext,
41
+ options: dict[str, typing.Any],
42
+ ) -> AwsAccount:
43
+ """Return the first ``AwsAccount`` reachable via ``MAPS_TO`` from
44
+ the actor's context anchors, in selector order.
45
+
46
+ Tag filters narrow the candidate set per anchor: each candidate's
47
+ ``tags`` dict must be a superset of every entry in
48
+ ``options['tag_filters']``.
49
+
50
+ Raises:
51
+ AccountNotResolvedError: No anchor produced a matching account.
52
+ """
53
+ selector = list(options.get('account_selector', DEFAULT_SELECTOR))
54
+ tag_filters: dict[str, str] = options.get('tag_filters', {}) or {}
55
+
56
+ anchors_checked: list[str] = []
57
+ for anchor in selector:
58
+ label = _LABEL_FOR_ANCHOR.get(anchor)
59
+ if label is None:
60
+ continue
61
+ anchor_id = _anchor_id_from_ctx(anchor, ctx)
62
+ if not anchor_id:
63
+ continue
64
+ anchors_checked.append(anchor)
65
+ candidates = await _candidates_for_anchor(db, label, anchor_id)
66
+ for candidate in candidates:
67
+ if _matches_tag_filters(candidate.tags, tag_filters):
68
+ return candidate
69
+
70
+ raise AccountNotResolvedError(
71
+ selector=selector,
72
+ anchors_checked=anchors_checked,
73
+ )
74
+
75
+
76
+ def _anchor_id_from_ctx(anchor: str, ctx: PluginContext) -> str | None:
77
+ """Look up the id of ``anchor`` from the request's
78
+ :class:`PluginContext`.
79
+
80
+ The host populates ``project_id`` / ``project_slug`` / ``org_slug``
81
+ today; ``environment`` is a slug, not an id, and ``project_type`` is
82
+ not yet on the context — those anchors are no-ops in Phase 1 and
83
+ will be reachable once the host extends ``PluginContext``.
84
+ """
85
+ if anchor == 'project':
86
+ return ctx.project_id or None
87
+ # Environment / project_type / organization arrive as slugs from
88
+ # the host, not ids; the calling plugin must perform the slug→id
89
+ # lookup before passing them in. Phase 1 only resolves via
90
+ # project_id; the other anchors return None.
91
+ return None
92
+
93
+
94
+ async def _candidates_for_anchor(
95
+ db: typing.Any,
96
+ label: str,
97
+ anchor_id: str,
98
+ ) -> list[AwsAccount]:
99
+ """Walk ``MAPS_TO`` from a labeled node id to its ``AwsAccount``
100
+ targets.
101
+
102
+ Uses the same Cypher template the host uses elsewhere — ``{}``
103
+ placeholders are SQL-format-style, ``{{`` / ``}}`` escape Cypher
104
+ map literals.
105
+ """
106
+ query = (
107
+ 'MATCH (n:' + label + ' {{id: {anchor_id}}})'
108
+ '-[:MAPS_TO]->(a:AwsAccount)'
109
+ ' RETURN a{{.*}} AS account'
110
+ )
111
+ rows = await db.execute(query, {'anchor_id': anchor_id}, ['account'])
112
+ accounts: list[AwsAccount] = []
113
+ for row in rows:
114
+ raw = row.get('account')
115
+ if raw is None:
116
+ continue
117
+ # ``parse_agtype`` is the host's helper. Imported lazily to
118
+ # avoid coupling this module to imbi_common's import order
119
+ # in tests that stub the db.
120
+ try:
121
+ from imbi_common.graph import parse_agtype
122
+ except ImportError:
123
+ data = raw
124
+ else:
125
+ data = parse_agtype(raw)
126
+ try:
127
+ accounts.append(AwsAccount.model_validate(data))
128
+ except Exception: # noqa: BLE001
129
+ LOGGER.warning(
130
+ 'AwsAccount row %r failed validation', data, exc_info=True
131
+ )
132
+ return accounts
133
+
134
+
135
+ def _matches_tag_filters(
136
+ tags: dict[str, str], tag_filters: dict[str, str]
137
+ ) -> bool:
138
+ return all(tags.get(k) == v for k, v in tag_filters.items())
@@ -0,0 +1,324 @@
1
+ """Shared AWS credentials + JSON-protocol client for the SSM and
2
+ CloudWatch Logs plugins.
3
+
4
+ We deliberately stay on ``httpx`` (already used by the IAM IC identity
5
+ plugin) and implement a minimal AWS SigV4 signer so the test suite can
6
+ keep using ``respx`` for mocking. Both SSM and CloudWatch Logs speak
7
+ the JSON-1.1 wire protocol, so a single helper covers both surfaces.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import typing
17
+ import urllib.parse
18
+
19
+ import httpx
20
+ from imbi_common.plugins.base import PluginContext
21
+ from imbi_common.plugins.errors import (
22
+ PluginAuthenticationFailed,
23
+ PluginCredentialsMissing,
24
+ PluginTimeoutError,
25
+ PluginUnavailableError,
26
+ )
27
+
28
+ ServiceName = typing.Literal['ssm', 'logs']
29
+
30
+ _SIGNING_ALGORITHM = 'AWS4-HMAC-SHA256'
31
+ _AWS_REQUEST = 'aws4_request'
32
+
33
+ _TARGET_PREFIX: dict[ServiceName, str] = {
34
+ 'ssm': 'AmazonSSM',
35
+ 'logs': 'Logs_20140328',
36
+ }
37
+
38
+ # AWS error codes that indicate the caller's credentials are no longer
39
+ # accepted by the upstream service. Refreshing the IAM IC session and
40
+ # retrying the call once is a reasonable recovery, so the host's retry
41
+ # layer is given a chance via :class:`PluginAuthenticationFailed`.
42
+ _AUTH_ERROR_CODES: frozenset[str] = frozenset(
43
+ {
44
+ 'ExpiredToken',
45
+ 'ExpiredTokenException',
46
+ 'InvalidClientTokenId',
47
+ 'UnauthorizedException',
48
+ 'UnrecognizedClientException',
49
+ }
50
+ )
51
+
52
+
53
+ class AwsCredentials(typing.NamedTuple):
54
+ """A validated set of AWS credentials + region."""
55
+
56
+ access_key_id: str
57
+ secret_access_key: str
58
+ session_token: str | None
59
+ region: str
60
+
61
+
62
+ _AWS_IDENTITY_KEYS = (
63
+ 'aws_access_key_id',
64
+ 'aws_secret_access_key',
65
+ 'aws_session_token',
66
+ 'aws_region',
67
+ 'aws_account_id',
68
+ )
69
+
70
+
71
+ def resolve_credentials(
72
+ credentials: dict[str, str],
73
+ *,
74
+ region: str | None = None,
75
+ ctx: PluginContext | None = None,
76
+ ) -> AwsCredentials:
77
+ """Validate the credentials dict and resolve the effective region.
78
+
79
+ Two credential sources are supported and combined here:
80
+
81
+ * ``credentials`` — static keys stored on the ``Plugin`` node by
82
+ the operator (``api_token``-style auth).
83
+ * ``ctx.identity.extra`` — STS keys minted by an identity plugin
84
+ (currently ``aws-iam-ic``). The host attaches these to
85
+ :attr:`PluginContext.identity` via ``hydrate_identity`` and the
86
+ data plugin must consult them, since the host does not merge
87
+ identity creds into the ``credentials`` dict.
88
+
89
+ Identity-supplied keys take precedence when present (a connected
90
+ user's STS keys are always preferable to static fallbacks).
91
+ ``region`` argument wins over both — it's the assignment option.
92
+
93
+ Raises:
94
+ PluginCredentialsMissing: when either AWS key is missing or
95
+ when the region cannot be resolved.
96
+ """
97
+ merged: dict[str, str] = {k: v for k, v in credentials.items() if v}
98
+ if ctx is not None and ctx.identity is not None:
99
+ for key in _AWS_IDENTITY_KEYS:
100
+ value = ctx.identity.extra.get(key)
101
+ if isinstance(value, str) and value:
102
+ merged[key] = value
103
+
104
+ access_key = merged.get('aws_access_key_id') or ''
105
+ secret_key = merged.get('aws_secret_access_key') or ''
106
+ if not access_key and not secret_key:
107
+ raise PluginCredentialsMissing(
108
+ 'AWS credentials missing: aws_access_key_id and '
109
+ 'aws_secret_access_key are required'
110
+ )
111
+ if not access_key or not secret_key:
112
+ raise PluginCredentialsMissing(
113
+ 'AWS credentials malformed: both aws_access_key_id and '
114
+ 'aws_secret_access_key must be provided'
115
+ )
116
+ resolved_region = region or merged.get('aws_region')
117
+ if not resolved_region:
118
+ raise PluginCredentialsMissing(
119
+ 'AWS region missing: pass region explicitly or set '
120
+ 'aws_region in credentials'
121
+ )
122
+ session_token = merged.get('aws_session_token') or None
123
+ return AwsCredentials(
124
+ access_key_id=access_key,
125
+ secret_access_key=secret_key,
126
+ session_token=session_token,
127
+ region=resolved_region,
128
+ )
129
+
130
+
131
+ def _hmac(key: bytes, data: str) -> bytes:
132
+ return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest()
133
+
134
+
135
+ def _signing_key(
136
+ secret_access_key: str, date_stamp: str, region: str, service: str
137
+ ) -> bytes:
138
+ k_date = _hmac(f'AWS4{secret_access_key}'.encode(), date_stamp)
139
+ k_region = _hmac(k_date, region)
140
+ k_service = _hmac(k_region, service)
141
+ return _hmac(k_service, _AWS_REQUEST)
142
+
143
+
144
+ def sign_request(
145
+ *,
146
+ method: str,
147
+ url: str,
148
+ headers: dict[str, str],
149
+ body: bytes,
150
+ service: str,
151
+ credentials: AwsCredentials,
152
+ now: datetime.datetime | None = None,
153
+ ) -> dict[str, str]:
154
+ """Return signed request headers (SigV4) for an AWS JSON request.
155
+
156
+ The returned mapping is the *complete* set of headers to send: the
157
+ caller's ``headers`` plus ``Host``, ``X-Amz-Date``,
158
+ ``X-Amz-Security-Token`` (when applicable), and ``Authorization``.
159
+ """
160
+ parsed = urllib.parse.urlsplit(url)
161
+ host = parsed.netloc
162
+ canonical_uri = parsed.path or '/'
163
+ canonical_query = parsed.query
164
+ timestamp = (now or datetime.datetime.now(datetime.UTC)).strftime(
165
+ '%Y%m%dT%H%M%SZ'
166
+ )
167
+ date_stamp = timestamp[:8]
168
+ payload_hash = hashlib.sha256(body).hexdigest()
169
+
170
+ signed: dict[str, str] = {**headers, 'host': host, 'x-amz-date': timestamp}
171
+ if credentials.session_token:
172
+ signed['x-amz-security-token'] = credentials.session_token
173
+
174
+ canonical_headers_pairs = sorted(
175
+ (name.lower(), value.strip()) for name, value in signed.items()
176
+ )
177
+ canonical_headers = ''.join(
178
+ f'{name}:{value}\n' for name, value in canonical_headers_pairs
179
+ )
180
+ signed_header_names = ';'.join(name for name, _ in canonical_headers_pairs)
181
+ canonical_request = '\n'.join(
182
+ [
183
+ method.upper(),
184
+ canonical_uri,
185
+ canonical_query,
186
+ canonical_headers,
187
+ signed_header_names,
188
+ payload_hash,
189
+ ]
190
+ )
191
+
192
+ credential_scope = (
193
+ f'{date_stamp}/{credentials.region}/{service}/{_AWS_REQUEST}'
194
+ )
195
+ string_to_sign = '\n'.join(
196
+ [
197
+ _SIGNING_ALGORITHM,
198
+ timestamp,
199
+ credential_scope,
200
+ hashlib.sha256(canonical_request.encode()).hexdigest(),
201
+ ]
202
+ )
203
+
204
+ signature = hmac.new(
205
+ _signing_key(
206
+ credentials.secret_access_key,
207
+ date_stamp,
208
+ credentials.region,
209
+ service,
210
+ ),
211
+ string_to_sign.encode(),
212
+ hashlib.sha256,
213
+ ).hexdigest()
214
+ authorization = (
215
+ f'{_SIGNING_ALGORITHM} '
216
+ f'Credential={credentials.access_key_id}/{credential_scope}, '
217
+ f'SignedHeaders={signed_header_names}, '
218
+ f'Signature={signature}'
219
+ )
220
+ signed['Authorization'] = authorization
221
+ return signed
222
+
223
+
224
+ def _service_endpoint(service: ServiceName, region: str) -> str:
225
+ return f'https://{service}.{region}.amazonaws.com/'
226
+
227
+
228
+ def _extract_error_code(payload: dict[str, typing.Any]) -> str:
229
+ error_type = payload.get('__type') or payload.get('Code') or ''
230
+ if isinstance(error_type, str) and '#' in error_type:
231
+ error_type = error_type.split('#', 1)[1]
232
+ return str(error_type or '')
233
+
234
+
235
+ def _map_status_error(
236
+ status_code: int,
237
+ error_code: str,
238
+ message: str,
239
+ error_map: dict[str, type[Exception]],
240
+ ) -> Exception:
241
+ """Translate an AWS error into the appropriate Imbi exception.
242
+
243
+ Auth-failure error codes take precedence over the caller-supplied
244
+ ``error_map`` so the host retry layer can refresh the actor's IAM
245
+ IC connection — historical maps surface these as
246
+ :class:`PluginCredentialsMissing`, which means "configure new
247
+ static creds" and would terminate the request before the refresh
248
+ has a chance to fire.
249
+ """
250
+ if error_code in _AUTH_ERROR_CODES:
251
+ return PluginAuthenticationFailed(message or error_code)
252
+ if error_code in error_map:
253
+ return error_map[error_code](message or error_code)
254
+ if status_code >= 500:
255
+ return PluginUnavailableError(message or f'AWS {status_code}')
256
+ return PluginUnavailableError(
257
+ message or f'AWS error {error_code or status_code}'
258
+ )
259
+
260
+
261
+ async def call_aws_json(
262
+ *,
263
+ service: ServiceName,
264
+ action: str,
265
+ body: dict[str, typing.Any],
266
+ credentials: AwsCredentials,
267
+ error_map: dict[str, type[Exception]],
268
+ timeout: float = 15.0,
269
+ client: httpx.AsyncClient | None = None,
270
+ ) -> dict[str, typing.Any]:
271
+ """Invoke an AWS JSON-1.1 action and return the decoded response.
272
+
273
+ ``error_map`` maps AWS error codes (e.g. ``ParameterNotFound``) to
274
+ the Imbi exception class to raise. Errors not in the map fall back
275
+ to :class:`PluginUnavailableError` for 5xx and a default of
276
+ :class:`PluginUnavailableError` otherwise.
277
+ """
278
+ payload = json.dumps(body).encode()
279
+ target = f'{_TARGET_PREFIX[service]}.{action}'
280
+ base_headers: dict[str, str] = {
281
+ 'Content-Type': 'application/x-amz-json-1.1',
282
+ 'X-Amz-Target': target,
283
+ }
284
+ url = _service_endpoint(service, credentials.region)
285
+ signed_headers = sign_request(
286
+ method='POST',
287
+ url=url,
288
+ headers=base_headers,
289
+ body=payload,
290
+ service=service,
291
+ credentials=credentials,
292
+ )
293
+
294
+ async def _do(http: httpx.AsyncClient) -> httpx.Response:
295
+ return await http.post(url, content=payload, headers=signed_headers)
296
+
297
+ try:
298
+ if client is None:
299
+ async with httpx.AsyncClient(timeout=timeout) as http:
300
+ response = await _do(http)
301
+ else:
302
+ response = await _do(client)
303
+ except (httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
304
+ raise PluginTimeoutError(str(exc)) from exc
305
+
306
+ if response.status_code == 200:
307
+ if not response.content:
308
+ return {}
309
+ return typing.cast(dict[str, typing.Any], response.json())
310
+
311
+ try:
312
+ err_payload = typing.cast(dict[str, typing.Any], response.json())
313
+ except ValueError:
314
+ err_payload = {}
315
+ error_code = _extract_error_code(err_payload)
316
+ error_message = str(
317
+ err_payload.get('message')
318
+ or err_payload.get('Message')
319
+ or response.text
320
+ or ''
321
+ )
322
+ raise _map_status_error(
323
+ response.status_code, error_code, error_message, error_map
324
+ )