lambda-preflight 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.
- lambda_preflight/__init__.py +58 -0
- lambda_preflight/events/__init__.py +21 -0
- lambda_preflight/events/builders.py +287 -0
- lambda_preflight/iam/__init__.py +32 -0
- lambda_preflight/iam/conditions.py +131 -0
- lambda_preflight/iam/evaluator.py +177 -0
- lambda_preflight/iam/intercept.py +101 -0
- lambda_preflight/iam/model.py +90 -0
- lambda_preflight/iam/puller.py +190 -0
- lambda_preflight/runtime/__init__.py +13 -0
- lambda_preflight/runtime/context.py +75 -0
- lambda_preflight/runtime/errors.py +34 -0
- lambda_preflight/runtime/harness.py +167 -0
- lambda_preflight/runtime/loader.py +94 -0
- lambda_preflight/testing/__init__.py +3 -0
- lambda_preflight/testing/fixtures.py +43 -0
- lambda_preflight-0.1.0.dist-info/METADATA +143 -0
- lambda_preflight-0.1.0.dist-info/RECORD +21 -0
- lambda_preflight-0.1.0.dist-info/WHEEL +4 -0
- lambda_preflight-0.1.0.dist-info/entry_points.txt +2 -0
- lambda_preflight-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""IAM policy evaluator.
|
|
2
|
+
|
|
3
|
+
Implements AWS's documented identity-policy decision flow:
|
|
4
|
+
|
|
5
|
+
explicit Deny -> wins, always
|
|
6
|
+
else an Allow -> required to permit
|
|
7
|
+
else -> implicit deny
|
|
8
|
+
|
|
9
|
+
When a permission boundary is present, an action must be allowed by *both*
|
|
10
|
+
the identity policies and the boundary (the intersection), matching AWS.
|
|
11
|
+
|
|
12
|
+
Strict mode turns "I cannot faithfully evaluate this" into an error rather
|
|
13
|
+
than a silent pass — the safe default for a tool whose promise is fidelity.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from .conditions import action_matches, evaluate_condition, resource_matches
|
|
22
|
+
from .model import PolicySet, Statement
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AccessDenied(Exception):
|
|
26
|
+
"""Raised when the evaluator denies a simulated call."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CannotEvaluate(Exception):
|
|
30
|
+
"""Raised in strict mode when a decision depends on unsupported logic."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Decision:
|
|
35
|
+
"""Result of an authorization check.
|
|
36
|
+
|
|
37
|
+
``allowed`` is the effective decision. ``reason`` explains it.
|
|
38
|
+
``unevaluable`` lists condition operators that could not be assessed;
|
|
39
|
+
in strict mode a non-empty list turns into CannotEvaluate.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
allowed: bool
|
|
43
|
+
reason: str
|
|
44
|
+
unevaluable: List[str]
|
|
45
|
+
matched_sid: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PolicyEvaluator:
|
|
49
|
+
"""Evaluate simulated AWS calls against a local PolicySet."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
policies: PolicySet,
|
|
54
|
+
strict: bool = False,
|
|
55
|
+
boundary: Optional[List[Statement]] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.policies = policies
|
|
58
|
+
self.strict = strict
|
|
59
|
+
# Explicit boundary arg overrides whatever is on the PolicySet.
|
|
60
|
+
self.boundary = (
|
|
61
|
+
boundary if boundary is not None else policies.boundary
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def evaluate(
|
|
65
|
+
self,
|
|
66
|
+
action: str,
|
|
67
|
+
resource: str = "*",
|
|
68
|
+
context: Optional[Dict[str, Any]] = None,
|
|
69
|
+
) -> Decision:
|
|
70
|
+
"""Return the Decision for ``action`` on ``resource``."""
|
|
71
|
+
context = context or {}
|
|
72
|
+
identity = self._evaluate_statements(
|
|
73
|
+
self.policies.statements, action, resource, context
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Explicit deny in identity policies wins immediately.
|
|
77
|
+
if identity["explicit_deny"]:
|
|
78
|
+
return Decision(
|
|
79
|
+
allowed=False,
|
|
80
|
+
reason=f"Explicit Deny on {action}",
|
|
81
|
+
unevaluable=identity["unevaluable"],
|
|
82
|
+
matched_sid=identity["deny_sid"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
allowed = identity["allow"]
|
|
86
|
+
reason = (
|
|
87
|
+
f"Allowed by {identity['allow_sid'] or 'identity policy'}"
|
|
88
|
+
if allowed
|
|
89
|
+
else f"Implicit deny: no matching Allow for {action}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Permission boundary: must also allow (intersection semantics).
|
|
93
|
+
if self.boundary:
|
|
94
|
+
b = self._evaluate_statements(
|
|
95
|
+
self.boundary, action, resource, context
|
|
96
|
+
)
|
|
97
|
+
if b["explicit_deny"]:
|
|
98
|
+
allowed = False
|
|
99
|
+
reason = f"Denied by permission boundary on {action}"
|
|
100
|
+
elif not b["allow"]:
|
|
101
|
+
allowed = False
|
|
102
|
+
reason = f"Not permitted by permission boundary: {action}"
|
|
103
|
+
identity["unevaluable"].extend(b["unevaluable"])
|
|
104
|
+
|
|
105
|
+
decision = Decision(
|
|
106
|
+
allowed=allowed,
|
|
107
|
+
reason=reason,
|
|
108
|
+
unevaluable=identity["unevaluable"],
|
|
109
|
+
matched_sid=identity["allow_sid"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if self.strict and decision.unevaluable:
|
|
113
|
+
raise CannotEvaluate(
|
|
114
|
+
f"Cannot faithfully evaluate {action} on {resource}: "
|
|
115
|
+
f"unsupported condition operators "
|
|
116
|
+
f"{sorted(set(decision.unevaluable))}"
|
|
117
|
+
)
|
|
118
|
+
return decision
|
|
119
|
+
|
|
120
|
+
def authorize(
|
|
121
|
+
self,
|
|
122
|
+
action: str,
|
|
123
|
+
resource: str = "*",
|
|
124
|
+
context: Optional[Dict[str, Any]] = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Raise AccessDenied if the call is not allowed; else return None."""
|
|
127
|
+
decision = self.evaluate(action, resource, context)
|
|
128
|
+
if not decision.allowed:
|
|
129
|
+
raise AccessDenied(decision.reason)
|
|
130
|
+
|
|
131
|
+
def _evaluate_statements(
|
|
132
|
+
self,
|
|
133
|
+
statements: List[Statement],
|
|
134
|
+
action: str,
|
|
135
|
+
resource: str,
|
|
136
|
+
context: Dict[str, Any],
|
|
137
|
+
) -> Dict[str, Any]:
|
|
138
|
+
"""Scan statements once, collecting allow/deny signals."""
|
|
139
|
+
allow = False
|
|
140
|
+
allow_sid: Optional[str] = None
|
|
141
|
+
explicit_deny = False
|
|
142
|
+
deny_sid: Optional[str] = None
|
|
143
|
+
unevaluable: List[str] = []
|
|
144
|
+
|
|
145
|
+
for stmt in statements:
|
|
146
|
+
if not self._action_in(stmt, action):
|
|
147
|
+
continue
|
|
148
|
+
if not self._resource_in(stmt, resource):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
passed, uneval = evaluate_condition(stmt.conditions, context)
|
|
152
|
+
unevaluable.extend(uneval)
|
|
153
|
+
if not passed:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if stmt.effect == "Deny":
|
|
157
|
+
explicit_deny = True
|
|
158
|
+
deny_sid = stmt.sid
|
|
159
|
+
elif stmt.effect == "Allow":
|
|
160
|
+
allow = True
|
|
161
|
+
allow_sid = allow_sid or stmt.sid
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"allow": allow,
|
|
165
|
+
"allow_sid": allow_sid,
|
|
166
|
+
"explicit_deny": explicit_deny,
|
|
167
|
+
"deny_sid": deny_sid,
|
|
168
|
+
"unevaluable": unevaluable,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _action_in(stmt: Statement, action: str) -> bool:
|
|
173
|
+
return any(action_matches(p, action) for p in stmt.actions)
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _resource_in(stmt: Statement, resource: str) -> bool:
|
|
177
|
+
return any(resource_matches(p, resource) for p in stmt.resources)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""boto3 intercept hook.
|
|
2
|
+
|
|
3
|
+
Hooks botocore's event system so that AWS SDK calls made *inside* a handler
|
|
4
|
+
are authorized against the local evaluator before they go anywhere. This is
|
|
5
|
+
cleaner than asking developers to annotate every call: it maps the botocore
|
|
6
|
+
operation to an IAM action and checks it, raising AccessDenied locally when
|
|
7
|
+
the role's real policy would not permit it.
|
|
8
|
+
|
|
9
|
+
Best-effort action mapping: botocore gives ``service`` and an operation name
|
|
10
|
+
like ``PutObject``; the IAM action is ``s3:PutObject``. A small override map
|
|
11
|
+
handles the cases where service id and IAM prefix differ.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import Any, Dict, Iterator, Optional
|
|
18
|
+
|
|
19
|
+
from .evaluator import PolicyEvaluator
|
|
20
|
+
|
|
21
|
+
# botocore service id -> IAM action prefix, where they diverge.
|
|
22
|
+
_SERVICE_PREFIX = {
|
|
23
|
+
"dynamodb": "dynamodb",
|
|
24
|
+
"s3": "s3",
|
|
25
|
+
"sqs": "sqs",
|
|
26
|
+
"sns": "sns",
|
|
27
|
+
"lambda": "lambda",
|
|
28
|
+
"states": "states",
|
|
29
|
+
"secretsmanager": "secretsmanager",
|
|
30
|
+
"ssm": "ssm",
|
|
31
|
+
"events": "events",
|
|
32
|
+
"sts": "sts",
|
|
33
|
+
"cloudwatch": "cloudwatch",
|
|
34
|
+
"logs": "logs",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def operation_to_action(service_id: str, operation_name: str) -> str:
|
|
39
|
+
"""Map a botocore operation to an IAM action string."""
|
|
40
|
+
prefix = _SERVICE_PREFIX.get(service_id, service_id)
|
|
41
|
+
return f"{prefix}:{operation_name}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BotoIntercept:
|
|
45
|
+
"""Registers a before-call hook on a boto3 session's event system."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
evaluator: PolicyEvaluator,
|
|
50
|
+
context: Optional[Dict[str, Any]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.evaluator = evaluator
|
|
53
|
+
self.context = context or {}
|
|
54
|
+
self._registered_session: Any = None
|
|
55
|
+
|
|
56
|
+
def _before_call(self, model: Any, params: Any, **kwargs: Any) -> None:
|
|
57
|
+
service_id = model.service_model.service_name
|
|
58
|
+
action = operation_to_action(service_id, model.name)
|
|
59
|
+
# Resource-level ARNs are not always derivable from params, so we
|
|
60
|
+
# authorize at action granularity with "*" resource by default.
|
|
61
|
+
# Callers wanting resource-level checks can pre-seed context.
|
|
62
|
+
self.evaluator.authorize(action, "*", self.context)
|
|
63
|
+
|
|
64
|
+
def attach(self, session: Any) -> None:
|
|
65
|
+
"""Attach the hook to a boto3.Session (or boto3 module default)."""
|
|
66
|
+
events = session.events
|
|
67
|
+
events.register("before-call", self._before_call)
|
|
68
|
+
self._registered_session = session
|
|
69
|
+
|
|
70
|
+
def detach(self) -> None:
|
|
71
|
+
if self._registered_session is not None:
|
|
72
|
+
self._registered_session.events.unregister(
|
|
73
|
+
"before-call", self._before_call
|
|
74
|
+
)
|
|
75
|
+
self._registered_session = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def intercept_boto3(
|
|
80
|
+
evaluator: PolicyEvaluator,
|
|
81
|
+
session: Any = None,
|
|
82
|
+
context: Optional[Dict[str, Any]] = None,
|
|
83
|
+
) -> Iterator[BotoIntercept]:
|
|
84
|
+
"""Context manager that authorizes boto3 calls against ``evaluator``.
|
|
85
|
+
|
|
86
|
+
Usage::
|
|
87
|
+
|
|
88
|
+
with intercept_boto3(evaluator, session=my_session):
|
|
89
|
+
harness.invoke(event) # boto3 calls inside are checked
|
|
90
|
+
"""
|
|
91
|
+
if session is None:
|
|
92
|
+
import boto3
|
|
93
|
+
|
|
94
|
+
session = boto3.DEFAULT_SESSION or boto3.Session()
|
|
95
|
+
|
|
96
|
+
hook = BotoIntercept(evaluator, context=context)
|
|
97
|
+
hook.attach(session)
|
|
98
|
+
try:
|
|
99
|
+
yield hook
|
|
100
|
+
finally:
|
|
101
|
+
hook.detach()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""IAM policy data model.
|
|
2
|
+
|
|
3
|
+
A ``PolicySet`` is the merged collection of statements that govern a
|
|
4
|
+
principal, plus optional metadata (permission boundary, source, timestamp)
|
|
5
|
+
used for cache freshness and honest scoping.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _as_list(value: Any) -> List[Any]:
|
|
16
|
+
"""IAM fields may be a scalar or a list; normalise to a list."""
|
|
17
|
+
if value is None:
|
|
18
|
+
return []
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
return value
|
|
21
|
+
return [value]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Statement:
|
|
26
|
+
"""A single IAM policy statement."""
|
|
27
|
+
|
|
28
|
+
effect: str
|
|
29
|
+
actions: List[str]
|
|
30
|
+
resources: List[str]
|
|
31
|
+
conditions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
32
|
+
sid: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, raw: Dict[str, Any]) -> "Statement":
|
|
36
|
+
return cls(
|
|
37
|
+
effect=raw.get("Effect", "Deny"),
|
|
38
|
+
actions=[a.lower() for a in _as_list(raw.get("Action"))],
|
|
39
|
+
resources=_as_list(raw.get("Resource")) or ["*"],
|
|
40
|
+
conditions=raw.get("Condition", {}) or {},
|
|
41
|
+
sid=raw.get("Sid"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PolicySet:
|
|
47
|
+
"""Merged policies for a principal.
|
|
48
|
+
|
|
49
|
+
``boundary`` holds the permission-boundary statements when known.
|
|
50
|
+
``unscoped`` records permission surfaces deliberately not represented
|
|
51
|
+
(SCPs, resource-based policies) so consumers can report honest coverage.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
statements: List[Statement] = field(default_factory=list)
|
|
55
|
+
boundary: List[Statement] = field(default_factory=list)
|
|
56
|
+
source: str = "unknown"
|
|
57
|
+
principal: str = "unknown"
|
|
58
|
+
fetched_at: float = field(default_factory=time.time)
|
|
59
|
+
unscoped: List[str] = field(
|
|
60
|
+
default_factory=lambda: ["scp", "resource_policy"]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_policy_documents(
|
|
65
|
+
cls,
|
|
66
|
+
documents: List[Dict[str, Any]],
|
|
67
|
+
boundary_documents: Optional[List[Dict[str, Any]]] = None,
|
|
68
|
+
source: str = "documents",
|
|
69
|
+
principal: str = "unknown",
|
|
70
|
+
) -> "PolicySet":
|
|
71
|
+
"""Build a PolicySet from raw policy-document dicts."""
|
|
72
|
+
statements: List[Statement] = []
|
|
73
|
+
for doc in documents:
|
|
74
|
+
for raw in _as_list(doc.get("Statement")):
|
|
75
|
+
statements.append(Statement.from_dict(raw))
|
|
76
|
+
|
|
77
|
+
boundary: List[Statement] = []
|
|
78
|
+
for doc in boundary_documents or []:
|
|
79
|
+
for raw in _as_list(doc.get("Statement")):
|
|
80
|
+
boundary.append(Statement.from_dict(raw))
|
|
81
|
+
|
|
82
|
+
return cls(
|
|
83
|
+
statements=statements,
|
|
84
|
+
boundary=boundary,
|
|
85
|
+
source=source,
|
|
86
|
+
principal=principal,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def age_seconds(self) -> float:
|
|
90
|
+
return time.time() - self.fetched_at
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Fetch a real IAM principal's policies and cache them.
|
|
2
|
+
|
|
3
|
+
Pulling the *actual* attached/inline/boundary policies of the execution role
|
|
4
|
+
means the evaluator tests against ground truth instead of a hand-written
|
|
5
|
+
guess that drifts. A Lambda runs as its **execution role**, so the puller is
|
|
6
|
+
role-oriented; a user ARN is accepted too for broader testing but resolved
|
|
7
|
+
and labelled accurately.
|
|
8
|
+
|
|
9
|
+
What can be pulled: identity policies (managed + inline) and the permission
|
|
10
|
+
boundary. What cannot: SCPs and resource-based policies — these are recorded
|
|
11
|
+
in ``PolicySet.unscoped`` rather than silently omitted.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from .model import PolicySet
|
|
23
|
+
|
|
24
|
+
DEFAULT_CACHE_DIR = Path(
|
|
25
|
+
os.environ.get(
|
|
26
|
+
"LAMBDALOCAL_CACHE_DIR",
|
|
27
|
+
str(Path.home() / ".cache" / "lambda_preflight" / "iam"),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PullError(Exception):
|
|
33
|
+
"""Raised when policies cannot be fetched (e.g. missing IAM read access)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pull_role(
|
|
37
|
+
role_name: str,
|
|
38
|
+
session: Any = None,
|
|
39
|
+
cache_dir: Optional[Path] = None,
|
|
40
|
+
max_age_seconds: Optional[float] = None,
|
|
41
|
+
) -> PolicySet:
|
|
42
|
+
"""Fetch (or load from cache) the merged policies for an execution role.
|
|
43
|
+
|
|
44
|
+
``role_name`` may be a bare name or an ARN; the trailing name is used.
|
|
45
|
+
If a fresh cache entry exists (within ``max_age_seconds``, when given),
|
|
46
|
+
it is returned without an AWS call. Otherwise the live fan-out runs and
|
|
47
|
+
the result is cached.
|
|
48
|
+
|
|
49
|
+
Requires ``boto3`` and IAM read permissions for the live path. On an
|
|
50
|
+
access error, raises PullError with guidance to fall back to BYO-JSON.
|
|
51
|
+
"""
|
|
52
|
+
name = role_name.split("/")[-1]
|
|
53
|
+
cache_dir = cache_dir or DEFAULT_CACHE_DIR
|
|
54
|
+
|
|
55
|
+
cached = _load_cache(name, cache_dir, max_age_seconds)
|
|
56
|
+
if cached is not None:
|
|
57
|
+
return cached
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
import boto3 # local import: only needed on the live path
|
|
61
|
+
except ImportError as exc: # pragma: no cover
|
|
62
|
+
raise PullError(
|
|
63
|
+
"boto3 is required to pull policies from AWS. Install it, or use "
|
|
64
|
+
"PolicySet.from_policy_documents() with a local policy JSON."
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
session = session or boto3.Session()
|
|
68
|
+
iam = session.client("iam")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
documents = _fetch_role_policy_documents(iam, name)
|
|
72
|
+
boundary_docs = _fetch_boundary_documents(iam, name)
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - normalise all AWS errors
|
|
74
|
+
raise PullError(
|
|
75
|
+
f"Could not read policies for role '{name}': {exc}. "
|
|
76
|
+
f"You may lack IAM read access; provide a policy JSON instead "
|
|
77
|
+
f"via PolicySet.from_policy_documents()."
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
policy_set = PolicySet.from_policy_documents(
|
|
81
|
+
documents=documents,
|
|
82
|
+
boundary_documents=boundary_docs,
|
|
83
|
+
source=f"aws:role/{name}",
|
|
84
|
+
principal=name,
|
|
85
|
+
)
|
|
86
|
+
_save_cache(name, policy_set, documents, boundary_docs, cache_dir)
|
|
87
|
+
return policy_set
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fetch_role_policy_documents(iam: Any, name: str) -> List[Dict[str, Any]]:
|
|
91
|
+
"""Fan out across managed + inline policies for a role."""
|
|
92
|
+
documents: List[Dict[str, Any]] = []
|
|
93
|
+
|
|
94
|
+
# Attached (managed) policies -> default version document.
|
|
95
|
+
attached = iam.list_attached_role_policies(RoleName=name)
|
|
96
|
+
for pol in attached.get("AttachedPolicies", []):
|
|
97
|
+
arn = pol["PolicyArn"]
|
|
98
|
+
meta = iam.get_policy(PolicyArn=arn)["Policy"]
|
|
99
|
+
version = iam.get_policy_version(
|
|
100
|
+
PolicyArn=arn, VersionId=meta["DefaultVersionId"]
|
|
101
|
+
)
|
|
102
|
+
documents.append(version["PolicyVersion"]["Document"])
|
|
103
|
+
|
|
104
|
+
# Inline policies -> document per policy name.
|
|
105
|
+
inline = iam.list_role_policies(RoleName=name)
|
|
106
|
+
for pol_name in inline.get("PolicyNames", []):
|
|
107
|
+
doc = iam.get_role_policy(RoleName=name, PolicyName=pol_name)
|
|
108
|
+
documents.append(doc["PolicyDocument"])
|
|
109
|
+
|
|
110
|
+
return documents
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _fetch_boundary_documents(iam: Any, name: str) -> List[Dict[str, Any]]:
|
|
114
|
+
"""Fetch the permission-boundary document if one is attached."""
|
|
115
|
+
role = iam.get_role(RoleName=name)["Role"]
|
|
116
|
+
boundary = role.get("PermissionsBoundary")
|
|
117
|
+
if not boundary:
|
|
118
|
+
return []
|
|
119
|
+
arn = boundary["PermissionsBoundaryArn"]
|
|
120
|
+
meta = iam.get_policy(PolicyArn=arn)["Policy"]
|
|
121
|
+
version = iam.get_policy_version(
|
|
122
|
+
PolicyArn=arn, VersionId=meta["DefaultVersionId"]
|
|
123
|
+
)
|
|
124
|
+
return [version["PolicyVersion"]["Document"]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cache_path(name: str, cache_dir: Path) -> Path:
|
|
128
|
+
return cache_dir / f"{name}.json"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _load_cache(
|
|
132
|
+
name: str, cache_dir: Path, max_age_seconds: Optional[float]
|
|
133
|
+
) -> Optional[PolicySet]:
|
|
134
|
+
path = _cache_path(name, cache_dir)
|
|
135
|
+
if not path.exists():
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
blob = json.loads(path.read_text())
|
|
139
|
+
except (json.JSONDecodeError, OSError):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
fetched_at = blob.get("fetched_at", 0)
|
|
143
|
+
if max_age_seconds is not None and (time.time() - fetched_at) > max_age_seconds:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
policy_set = PolicySet.from_policy_documents(
|
|
147
|
+
documents=blob.get("documents", []),
|
|
148
|
+
boundary_documents=blob.get("boundary_documents", []),
|
|
149
|
+
source=blob.get("source", f"cache:{name}"),
|
|
150
|
+
principal=name,
|
|
151
|
+
)
|
|
152
|
+
policy_set.fetched_at = fetched_at
|
|
153
|
+
return policy_set
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _save_cache(
|
|
157
|
+
name: str,
|
|
158
|
+
policy_set: PolicySet,
|
|
159
|
+
documents: List[Dict[str, Any]],
|
|
160
|
+
boundary_docs: List[Dict[str, Any]],
|
|
161
|
+
cache_dir: Path,
|
|
162
|
+
) -> None:
|
|
163
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
path = _cache_path(name, cache_dir)
|
|
165
|
+
payload = {
|
|
166
|
+
"principal": name,
|
|
167
|
+
"source": policy_set.source,
|
|
168
|
+
"fetched_at": policy_set.fetched_at,
|
|
169
|
+
"documents": documents,
|
|
170
|
+
"boundary_documents": boundary_docs,
|
|
171
|
+
}
|
|
172
|
+
path.write_text(json.dumps(payload, indent=2))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_role_policies(
|
|
176
|
+
role_name: str, cache_dir: Optional[Path] = None
|
|
177
|
+
) -> PolicySet:
|
|
178
|
+
"""Load a role's policies from cache only, without any AWS call.
|
|
179
|
+
|
|
180
|
+
Convenience for offline mode: reuse the last pull with no charges and no
|
|
181
|
+
credentials. Raises PullError if nothing is cached.
|
|
182
|
+
"""
|
|
183
|
+
name = role_name.split("/")[-1]
|
|
184
|
+
cached = _load_cache(name, cache_dir or DEFAULT_CACHE_DIR, None)
|
|
185
|
+
if cached is None:
|
|
186
|
+
raise PullError(
|
|
187
|
+
f"No cached policies for '{name}'. Run pull_role() once while "
|
|
188
|
+
f"online, or supply a policy JSON."
|
|
189
|
+
)
|
|
190
|
+
return cached
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""In-process Lambda runtime shim."""
|
|
2
|
+
|
|
3
|
+
from .context import LambdaContext
|
|
4
|
+
from .errors import HandlerLoadError, LambdaTimeout
|
|
5
|
+
from .harness import InvocationResult, LambdaHarness
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"LambdaHarness",
|
|
9
|
+
"InvocationResult",
|
|
10
|
+
"LambdaContext",
|
|
11
|
+
"LambdaTimeout",
|
|
12
|
+
"HandlerLoadError",
|
|
13
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Faithful reproduction of the AWS Lambda ``context`` object.
|
|
2
|
+
|
|
3
|
+
The context object AWS passes to a handler exposes a specific set of
|
|
4
|
+
attributes and one time-sensitive method, ``get_remaining_time_in_millis``.
|
|
5
|
+
Reproducing these accurately is what lets handler code that reads the
|
|
6
|
+
context behave locally the way it does in AWS.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ClientContext:
|
|
19
|
+
"""Mobile SDK client context. Present in the real object; usually None."""
|
|
20
|
+
|
|
21
|
+
client: Optional[dict] = None
|
|
22
|
+
custom: Optional[dict] = None
|
|
23
|
+
env: Optional[dict] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CognitoIdentity:
|
|
28
|
+
"""Cognito identity context. Present in the real object; usually None."""
|
|
29
|
+
|
|
30
|
+
cognito_identity_id: Optional[str] = None
|
|
31
|
+
cognito_identity_pool_id: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LambdaContext:
|
|
35
|
+
"""In-process stand-in for the AWS Lambda runtime context.
|
|
36
|
+
|
|
37
|
+
The remaining-time clock is backed by a real monotonic deadline so that
|
|
38
|
+
handlers which budget work against ``get_remaining_time_in_millis()``
|
|
39
|
+
observe the same countdown behaviour they would in AWS.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
function_name: str = "local-function",
|
|
45
|
+
function_version: str = "$LATEST",
|
|
46
|
+
memory_limit_in_mb: int = 128,
|
|
47
|
+
timeout_seconds: float = 3.0,
|
|
48
|
+
region: str = "us-east-1",
|
|
49
|
+
account_id: str = "000000000000",
|
|
50
|
+
aws_request_id: Optional[str] = None,
|
|
51
|
+
log_group_name: Optional[str] = None,
|
|
52
|
+
log_stream_name: Optional[str] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.function_name = function_name
|
|
55
|
+
self.function_version = function_version
|
|
56
|
+
self.memory_limit_in_mb = str(memory_limit_in_mb)
|
|
57
|
+
self.aws_request_id = aws_request_id or str(uuid.uuid4())
|
|
58
|
+
self.log_group_name = log_group_name or f"/aws/lambda/{function_name}"
|
|
59
|
+
self.log_stream_name = (
|
|
60
|
+
log_stream_name
|
|
61
|
+
or f"{time.strftime('%Y/%m/%d')}/[{function_version}]{uuid.uuid4().hex}"
|
|
62
|
+
)
|
|
63
|
+
self.invoked_function_arn = (
|
|
64
|
+
f"arn:aws:lambda:{region}:{account_id}:function:{function_name}"
|
|
65
|
+
)
|
|
66
|
+
self.identity = CognitoIdentity()
|
|
67
|
+
self.client_context = ClientContext()
|
|
68
|
+
|
|
69
|
+
# Real monotonic deadline drives the remaining-time countdown.
|
|
70
|
+
self._deadline = time.monotonic() + timeout_seconds
|
|
71
|
+
|
|
72
|
+
def get_remaining_time_in_millis(self) -> int:
|
|
73
|
+
"""Milliseconds left before the configured timeout, floored at 0."""
|
|
74
|
+
remaining = (self._deadline - time.monotonic()) * 1000.0
|
|
75
|
+
return max(0, int(remaining))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""AWS-shape error serialization.
|
|
2
|
+
|
|
3
|
+
When a handler raises an unhandled exception, the Lambda runtime returns a
|
|
4
|
+
specific JSON envelope. Downstream consumers (Step Functions, Lambda
|
|
5
|
+
Destinations, the invoke API) parse that exact shape, so reproducing it is
|
|
6
|
+
part of behaving like AWS rather than merely running the function.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import traceback
|
|
12
|
+
from typing import Any, Dict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LambdaTimeout(Exception):
|
|
16
|
+
"""Raised when a handler exceeds its configured timeout."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HandlerLoadError(Exception):
|
|
20
|
+
"""Raised when the ``module.function`` handler string cannot be resolved."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_exception(exc: BaseException) -> Dict[str, Any]:
|
|
24
|
+
"""Return the AWS-style error envelope for an unhandled exception.
|
|
25
|
+
|
|
26
|
+
Matches the runtime's structure: ``errorType``, ``errorMessage`` and a
|
|
27
|
+
``stackTrace`` list of formatted frames.
|
|
28
|
+
"""
|
|
29
|
+
stack = traceback.format_tb(exc.__traceback__)
|
|
30
|
+
return {
|
|
31
|
+
"errorType": type(exc).__name__,
|
|
32
|
+
"errorMessage": str(exc),
|
|
33
|
+
"stackTrace": [frame.rstrip("\n") for frame in stack],
|
|
34
|
+
}
|