aws-python-helper 1.2.0__py3-none-any.whl → 1.4.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.
@@ -186,6 +186,28 @@ class API(ABC):
186
186
  """
187
187
  return self._is_authenticated
188
188
 
189
+ async def granted_permissions(self):
190
+ """Which permissions the caller holds, or None when this API does not know.
191
+
192
+ Override it to turn on permission checking for a service. Where the list
193
+ comes from is the service's business: its own database, a map cached from
194
+ core, whatever fits.
195
+
196
+ Returning None with AUTHORIZATION=permission is refused, not waved
197
+ through. An endpoint that cannot say what its caller may do has to be a
198
+ closed door: the alternative is that forgetting to wire this up leaves it
199
+ open to anyone holding a token, which is the failure this whole mechanism
200
+ exists to prevent.
201
+ """
202
+ return None
203
+
204
+ @property
205
+ def service_code(self) -> str:
206
+ """Names this service inside a permission, as in `core:GET /users`."""
207
+ import os
208
+
209
+ return os.getenv('SERVICE_CODE', os.getenv('NAMESPACE', 'service'))
210
+
189
211
  @property
190
212
  def auth_data(self) -> Optional[Dict[str, Any]]:
191
213
  """
@@ -78,13 +78,27 @@ class Dispatcher:
78
78
 
79
79
  # 2. Authorization based on mode
80
80
  authorization = os.getenv('AUTHORIZATION', '').lower()
81
- requires_user = authorization in ('user', 'full')
82
- requires_state = authorization in ('state', 'full')
81
+ requires_user = authorization in ('user', 'full', 'permission')
82
+ requires_permission = authorization == 'permission'
83
+
84
+ # Pedir el state es independiente de pedir un permiso, así que va por
85
+ # su propia variable. Los valores viejos `state` y `full` la implican,
86
+ # para que un servicio sin migrar siga comportándose igual.
87
+ requires_state = (
88
+ authorization in ('state', 'full')
89
+ or os.getenv('REQUIRES_STATE', '').lower() in ('1', 'true', 'yes')
90
+ )
83
91
 
84
92
  # 2a. Authenticate (if mode requires user)
85
93
  if requires_user:
86
94
  await self._authenticate(api)
87
95
 
96
+ # 2a-bis. Check what the caller is allowed to do
97
+ if requires_permission:
98
+ denial = await self._authorize(api)
99
+ if denial:
100
+ return denial
101
+
88
102
  # 2b. Validate state header (if mode requires state)
89
103
  if requires_state:
90
104
  state = self.headers.get('constitution-state')
@@ -236,6 +250,43 @@ class Dispatcher:
236
250
 
237
251
  return api
238
252
 
253
+ async def _authorize(self, api: API):
254
+ """Refuse the request when the caller lacks the permission it needs.
255
+
256
+ Returns the response to send back, or None to carry on.
257
+ """
258
+ from ..permissions import allows, required_for
259
+
260
+ granted = await api.granted_permissions()
261
+ if granted is None:
262
+ logger.error(
263
+ "AUTHORIZATION=permission but %s does not implement "
264
+ "granted_permissions(); refusing the request",
265
+ type(api).__name__,
266
+ )
267
+ return {
268
+ 'code': 403,
269
+ 'body': {
270
+ 'error': 'Forbidden',
271
+ 'message': 'Permission checking is not configured for this endpoint',
272
+ },
273
+ 'headers': {},
274
+ }
275
+
276
+ required = required_for(api.service_code, self.method, self.endpoint)
277
+ if allows(granted, required):
278
+ return None
279
+
280
+ logger.warning("Denied: %s lacks %r", self.headers.get('x-caller', 'caller'), required)
281
+ return {
282
+ 'code': 403,
283
+ 'body': {
284
+ 'error': 'Forbidden',
285
+ 'message': f"Te falta el permiso '{required}'",
286
+ },
287
+ 'headers': {},
288
+ }
289
+
239
290
  async def _authenticate(self, api: API):
240
291
  """
241
292
  Execute authentication middleware
@@ -0,0 +1,9 @@
1
+ from .catalog import ALL, allows, discover, required_for, service_wildcard
2
+
3
+ __all__ = [
4
+ "ALL",
5
+ "allows",
6
+ "discover",
7
+ "required_for",
8
+ "service_wildcard",
9
+ ]
@@ -0,0 +1,105 @@
1
+ """Derives a service's permissions from the API files it actually has.
2
+
3
+ The endpoints are already declared twice — in the terraform routes and in the
4
+ file convention the dispatcher resolves on. A hand-written list would be a third
5
+ copy, and the first endpoint added without touching it would be an unguarded
6
+ hole nobody notices.
7
+
8
+ Mirrors `aws_python_helper.api.fetcher` in reverse: that one turns a request into
9
+ a file, this one turns the files into the requests they answer. It lives beside
10
+ the fetcher so the two cannot drift: a routing rule that changes in one would
11
+ otherwise silently stop matching in the other, and an access check that never
12
+ matches fails open or shut without anyone noticing.
13
+ """
14
+
15
+ import os
16
+ from typing import Dict, List
17
+
18
+ from ..api.fetcher import Fetcher
19
+
20
+ # Cómo se llama el archivo según el método. `list` y `get` son ambos GET: uno
21
+ # responde la colección, el otro un elemento.
22
+ FILE_METHODS = {
23
+ "list": "GET",
24
+ "get": "GET",
25
+ "post": "POST",
26
+ "put": "PUT",
27
+ "patch": "PATCH",
28
+ "delete": "DELETE",
29
+ }
30
+
31
+ ALL = "*"
32
+
33
+
34
+ def discover(api_root: str, service: str) -> List[Dict[str, str]]:
35
+ """Every endpoint this service answers, as permission records.
36
+
37
+ The `{id}` of a route is left out on purpose: holding an id does not change
38
+ who may call the endpoint, and leaving it out makes the mapping between a
39
+ file and a permission exact.
40
+ """
41
+ found: List[Dict[str, str]] = []
42
+
43
+ for current, _, files in os.walk(api_root):
44
+ for filename in sorted(files):
45
+ stem, extension = os.path.splitext(filename)
46
+ if extension != ".py" or stem not in FILE_METHODS:
47
+ continue
48
+
49
+ relative = os.path.relpath(current, api_root)
50
+ parts = [] if relative == "." else relative.split(os.sep)
51
+ path = "/" + "/".join(parts)
52
+ method = FILE_METHODS[stem]
53
+
54
+ # `list` responde la colección y `get` un elemento: mismo método
55
+ # sobre rutas distintas, así que son dos permisos, no uno.
56
+ if stem == "get":
57
+ path = f"{path.rstrip('/')}/{{id}}"
58
+
59
+ found.append({
60
+ "code": f"{service}:{method} {path}",
61
+ "service": service,
62
+ "method": method,
63
+ "path": path,
64
+ "scope": "item" if stem == "get" else "collection",
65
+ })
66
+
67
+ return sorted(found, key=lambda item: item["code"])
68
+
69
+
70
+ def service_wildcard(service: str) -> str:
71
+ return f"{service}:*"
72
+
73
+
74
+ def required_for(service: str, method: str, endpoint: str, api_root: str = "api") -> str:
75
+ """The permission a request needs.
76
+
77
+ Asks the framework which file will answer, and names the permission after it.
78
+ Reimplementing the routing rules here would be a second copy that drifts, and
79
+ a permission that never matches fails open or shut without anyone noticing —
80
+ which is the worst way for an access check to be wrong.
81
+ """
82
+ resolved = Fetcher(endpoint, method.lower()).file_path
83
+ marker = os.sep + api_root + os.sep
84
+ if marker not in resolved:
85
+ return ""
86
+
87
+ relative = resolved.split(marker, 1)[1]
88
+ directory, filename = os.path.split(relative)
89
+ stem = os.path.splitext(filename)[0]
90
+ if stem not in FILE_METHODS:
91
+ return ""
92
+
93
+ path = "/" + directory.replace(os.sep, "/") if directory else "/"
94
+ if stem == "get":
95
+ path = f"{path.rstrip('/')}/{{id}}"
96
+ return f"{service}:{FILE_METHODS[stem]} {path}"
97
+
98
+
99
+ def allows(granted: List[str], required: str) -> bool:
100
+ """Whether a set of granted permissions covers the one an endpoint needs."""
101
+ if not required:
102
+ return False
103
+ if ALL in granted or required in granted:
104
+ return True
105
+ return service_wildcard(required.split(":", 1)[0]) in granted
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -2,8 +2,8 @@ aws_python_helper/__init__.py,sha256=fc6TMZCNo4dnxXgbd6_l13GQhPCKsuLtUCbwqP25mpI
2
2
  aws_python_helper/api/__init__.py,sha256=2H8s1bbZArgrlecFb0YDQjBQKphhhwl70zx5oGC4fnk,232
3
3
  aws_python_helper/api/auth_middleware.py,sha256=KU6tyH6gPOsaG4oLRr-O2Edbmz92sc39GI7ZsIbVhco,3432
4
4
  aws_python_helper/api/auth_validators.py,sha256=knpMd6NA5Lo2JLgkgJhAQnFzm38I9LzI_SInrVIR-gk,8563
5
- aws_python_helper/api/base.py,sha256=CjNc3rUiPKfuxPSEm1AwS8yP7HoSLu0e0LwQCz-RNzw,8975
6
- aws_python_helper/api/dispatcher.py,sha256=526POV-h-MaZsad9pcx-pvP26ElIpURSOfKA7MZWVAI,8425
5
+ aws_python_helper/api/base.py,sha256=5nUF6MM5dRXUtC_DytM8xlaC3Jx_dBeKoSOBRg2Gc-c,9897
6
+ aws_python_helper/api/dispatcher.py,sha256=ikqsLmxYLNjNFzdN-oXwzsB_ZG7bljJOyqEWRpSnLsg,10337
7
7
  aws_python_helper/api/exceptions.py,sha256=C8G5jn0c6sTtHWG4daLdnqe57IbqNzsQolSBsl_YNno,1019
8
8
  aws_python_helper/api/fetcher.py,sha256=TVzfL2uIe8uvWzC1kZ9VPE0JOYd73XUjs8xu9ViBl7o,7758
9
9
  aws_python_helper/api/handler.py,sha256=IRfJUhYwp8ymBLOXsqsdhEIBmpUFaMuBAGGMjS23jaY,3467
@@ -32,6 +32,8 @@ aws_python_helper/model_index_sync/__init__.py,sha256=pPrS_4uahYF0EMkCds9biZk9Kf
32
32
  aws_python_helper/model_index_sync/base.py,sha256=26UpYxxvN5_so7fp79OH-07nQchNNmZzmH8heCNJ4kk,7527
33
33
  aws_python_helper/model_query/__init__.py,sha256=c25YrhNsL2FoF9KtLmS50Nud7R8DDS2QwEKT8bdB2Vs,67
34
34
  aws_python_helper/model_query/base.py,sha256=AJmkgQlEN4_4-5izfhhd0DERiES7xa6XV0OsdNRkGOU,8842
35
+ aws_python_helper/permissions/__init__.py,sha256=6n6AsbDhfQBctPrTPDpCz8KH9tfKCG1VTtck9d7Vcos,175
36
+ aws_python_helper/permissions/catalog.py,sha256=NgkXw4XjGpuXWzw2MR_Lp0AHkSjnuht4UKDOEYjvk-M,3788
35
37
  aws_python_helper/repository/__init__.py,sha256=N-1-RoFm60U4o9dEo_dHCXcPvPy_DjFlxbiwfmYGBQQ,55
36
38
  aws_python_helper/repository/base.py,sha256=jPdYRqDwg8tAXEzcqzVLT1ZMl-nBrwsFpnD9-841_mQ,12578
37
39
  aws_python_helper/sns/__init__.py,sha256=dNJDBuwObZr2KGlYqfp8DuTp1RQmEnHlyjqvq307rZY,118
@@ -44,7 +46,7 @@ aws_python_helper/utils/__init__.py,sha256=1L4bUGhMKy_UmXx-w03SDZetGo5MDOB_LIP_f
44
46
  aws_python_helper/utils/json_encoder.py,sha256=kPrglxttUF7ysW4LkUbzh89Va3jM3s12J98MH-mnKLE,2748
45
47
  aws_python_helper/utils/response.py,sha256=IxN6HxUkL_pcOMdfXj1WFjIyCBDV0BU9AC_e4ecOkU8,3633
46
48
  aws_python_helper/utils/serializer.py,sha256=WNM5izzL5sm9iPruqZbXrrzK5Kl4VxqEWLz5zQ9FeVs,2869
47
- aws_python_helper-1.2.0.dist-info/METADATA,sha256=6tv4IZfYkG2fsicGy_jXQ3-cdu8Zu7PWP5vBr_tTZ84,70276
48
- aws_python_helper-1.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
49
- aws_python_helper-1.2.0.dist-info/top_level.txt,sha256=W_0xjk-0rtf9zOwWqSQaNwgZw6EF-cjmNTAdRtXZFHk,18
50
- aws_python_helper-1.2.0.dist-info/RECORD,,
49
+ aws_python_helper-1.4.0.dist-info/METADATA,sha256=qhaLY1bQTFPhA8A-wEq4XP4U_MKOL5EXQPc8HEA0HAg,70276
50
+ aws_python_helper-1.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
51
+ aws_python_helper-1.4.0.dist-info/top_level.txt,sha256=W_0xjk-0rtf9zOwWqSQaNwgZw6EF-cjmNTAdRtXZFHk,18
52
+ aws_python_helper-1.4.0.dist-info/RECORD,,