py-app-runner 0.5.49.dev0__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.
Files changed (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,313 @@
1
+ import logging
2
+ import os
3
+ import uuid
4
+ from asyncio import Future, get_event_loop
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ from tornado.websocket import WebSocketHandler as TornadoWebSocketHandler
9
+
10
+ from py_app_runner.bridge.encoders import JsonEncoder, MessageEncoder, MsgpackEncoder
11
+ from py_app_runner.config import is_env_prod
12
+ from py_app_runner.http_exception import HTTPException
13
+ from py_app_runner.registry import AppRegistry
14
+ from py_app_runner.request_handler.handlers import RequestHandlerApiKeys
15
+ from py_app_runner.return_model import MessageModel, ReturnModel, StatusModel
16
+ from py_app_runner.utils import json_encode
17
+ from py_app_runner.wbcm.ws_interface import WebSocketHandlerInterface
18
+
19
+
20
+ def allowed_ws_origins() -> set[str]:
21
+ """Origins accepted for WebSocket upgrades.
22
+
23
+ Read on demand rather than at import time: the value comes from the app config
24
+ (with an env fallback), and neither is populated when this module is imported.
25
+ An empty set means "accept any origin".
26
+ """
27
+ configured = AppRegistry.config().get("ws_allowed_origins")
28
+ if configured is None:
29
+ configured = os.environ.get("WS_ALLOWED_ORIGINS", "")
30
+
31
+ if isinstance(configured, str):
32
+ configured = configured.split(",")
33
+
34
+ return {str(o).strip().lower().rstrip("/") for o in configured if str(o).strip()}
35
+
36
+
37
+ # * BaseWebSocketHandler - protocol-agnostic WebSocket handler
38
+ class BaseWebSocketHandler(RequestHandlerApiKeys, TornadoWebSocketHandler, WebSocketHandlerInterface):
39
+ """Base WebSocket request handler with pluggable encoding."""
40
+
41
+ encoder: MessageEncoder
42
+
43
+ #########################
44
+ ### Class lifecycle #####
45
+ #########################
46
+
47
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
48
+ logger_name = f"{__name__}.{self.__class__.__name__}"
49
+ self.logger = logging.getLogger(logger_name)
50
+
51
+ self.loop = get_event_loop()
52
+
53
+ self.uid = None
54
+ self.msg_id = None
55
+ self.service = None
56
+ self.current_session_sid = None
57
+ self.device_id: str | None = None
58
+ self._api_key_valid: bool | None = None
59
+ self._rejected_api_key: str | None = None
60
+
61
+ super().__init__(*args, **kwargs)
62
+
63
+ ###############
64
+ ### Helpers ###
65
+ ###############
66
+
67
+ def error_message(self, msg: str | HTTPException, code: int = -1, **kwargs: Any):
68
+ error: dict[str, Any]
69
+ if isinstance(msg, HTTPException):
70
+ error = msg.to_dict()
71
+ else:
72
+ error = {"code": code, "msg": msg}
73
+
74
+ message: dict[str, Any] = {"error": error}
75
+ message.update(kwargs)
76
+ self.write_custom_message(message)
77
+
78
+ #################
79
+ ### Overrides ###
80
+ #################
81
+
82
+ def write_custom_message(
83
+ self,
84
+ message: bytes | str | dict[str, Any] | list[Any],
85
+ wrap_in_data: bool = True,
86
+ **kwargs: Any,
87
+ ) -> Future[None]:
88
+ if isinstance(message, (StatusModel, MessageModel, ReturnModel)):
89
+ message = message.to_dict()
90
+ if wrap_in_data:
91
+ message = {"data": message}
92
+
93
+ response_message: str | bytes
94
+ if isinstance(message, dict):
95
+ # Add service
96
+ if self.service and "service" not in message:
97
+ message["service"] = self.service
98
+
99
+ # Add msg_id
100
+ if self.msg_id and "msg_id" not in message:
101
+ message["msg_id"] = self.msg_id
102
+
103
+ # Add additional fields
104
+ message.update(kwargs)
105
+
106
+ # Encode message using the pluggable encoder
107
+ response_message = self.encoder.encode(message)
108
+
109
+ elif isinstance(message, (str, bytes)):
110
+ # Already-encoded payload, pass through untouched
111
+ response_message = message
112
+
113
+ else:
114
+ response_message = self.encoder.encode(message) # type: ignore[arg-type]
115
+
116
+ if self.ws_connection is not None and self.ws_connection.is_closing() is False:
117
+ return super().write_message(response_message, binary=self.encoder.is_binary) # type: ignore
118
+
119
+ # Resolved, not dangling: callers may await the result.
120
+ dropped: Future[None] = Future()
121
+ dropped.set_result(None)
122
+ return dropped
123
+
124
+ ########################
125
+ ### Request handling ###
126
+ ########################
127
+
128
+ async def reload_token(self, new_auth_token: str) -> None:
129
+ old_auth_token = self.auth_token
130
+ if old_auth_token != new_auth_token:
131
+ self.auth_token = new_auth_token
132
+ await self._ensure_current_user()
133
+
134
+ def check_origin(self, origin: str) -> bool:
135
+ allowed = allowed_ws_origins()
136
+ if not allowed:
137
+ if is_env_prod():
138
+ self.logger.warning(
139
+ "ws_allowed_origins is not configured; accepting WebSocket upgrade from origin %s."
140
+ " Set it to prevent cross-site WebSocket hijacking.",
141
+ origin,
142
+ )
143
+ return True
144
+
145
+ normalized = origin.lower().rstrip("/")
146
+ if normalized in allowed:
147
+ return True
148
+
149
+ # Also check just the scheme + host (ignoring path)
150
+ parsed = urlparse(normalized)
151
+ origin_host = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else normalized
152
+ return origin_host in allowed
153
+
154
+ async def open(self, *args: str, **kwargs: str) -> None:
155
+ # Cache the connection
156
+ self.uid = str(uuid.uuid4())
157
+ self.wb_connection_manager.user_connections.add_connection(self)
158
+
159
+ self.logger.debug(
160
+ "WS open uid=%s remote_ip=%s origin=%s",
161
+ self.uid,
162
+ self.request.remote_ip,
163
+ self.request.headers.get("Origin", "<none>"),
164
+ )
165
+
166
+ # Validate API key from query param or header at connection time
167
+ status = await self.has_valid_api_key()
168
+ if status is True:
169
+ self._api_key_valid = True
170
+ elif status == "API key is missing":
171
+ self._api_key_valid = None
172
+ else:
173
+ self._api_key_valid = False
174
+ self.logger.debug("WS closing uid=%s reason=api_key_failed status=%s", self.uid, status)
175
+ self.close(4401, f"API key validation failed: {status}")
176
+
177
+ async def on_message(self, message: str | bytes) -> None:
178
+ self.logger.debug(f"Received message ({self.encoder.name}): {message!r}")
179
+ try:
180
+ message_data = self.encoder.decode(message)
181
+ except Exception:
182
+ msg_str = "It was impossible to parse the given data"
183
+ self.logger.exception(f"Error: {msg_str}\nData: {message!r}")
184
+ self.error_message(msg_str, code=1000)
185
+ return
186
+
187
+ try:
188
+ # Assign message id
189
+ msg_id = message_data.get("msg_id", None)
190
+ service = message_data.get("service", None)
191
+
192
+ if not msg_id or not service:
193
+ raise HTTPException(
194
+ f"Missing input data; Data received: {json_encode(message_data, pretty=True)}",
195
+ code=1001,
196
+ http_status=400,
197
+ )
198
+
199
+ self.msg_id = int(msg_id)
200
+ self.service = service
201
+ res = self.find_service(service_name=service)
202
+ if not res.result:
203
+ raise HTTPException(
204
+ f"Could not find service by provided name: {self.service}",
205
+ code=1002,
206
+ http_status=400,
207
+ )
208
+
209
+ bridge_request, requires_api_key = res.result
210
+ if not bridge_request:
211
+ raise HTTPException(f"Service not found: {self.service}", code=1003, http_status=400)
212
+
213
+ # Check cached API key validation from connection handshake;
214
+ # fall back to in-message api_key (browsers cannot send custom headers on WS)
215
+ if requires_api_key is not False:
216
+ if self._api_key_valid is None:
217
+ in_msg_api_key = message_data.get("api_key", None)
218
+ # Remember only the last rejected key, so retrying the same bad key
219
+ # costs nothing while a corrected one can still be accepted.
220
+ if in_msg_api_key and in_msg_api_key != self._rejected_api_key:
221
+ status = await self.has_valid_api_key(in_msg_api_key)
222
+ if status is True:
223
+ self._api_key_valid = True
224
+ else:
225
+ self._rejected_api_key = in_msg_api_key
226
+
227
+ if self._api_key_valid is not True:
228
+ raise HTTPException(
229
+ "Application is not authenticated: API key is missing or invalid",
230
+ code=1401,
231
+ http_status=401,
232
+ )
233
+
234
+ # Set auth token
235
+ auth_token = message_data.get("auth_token", None)
236
+ if auth_token:
237
+ await self.reload_token(auth_token)
238
+
239
+ # Device session token auth
240
+ device_session_token = message_data.get("device_session_token", None)
241
+ if device_session_token and not self.device_id:
242
+ await self._authenticate_device(device_session_token)
243
+
244
+ # Timer
245
+ async with self.timer.aenter("bridge.websockets.on_message.runRequestHandler"):
246
+ req_data = message_data.get("data", {})
247
+ action = req_data.get("action", None)
248
+ input_data = req_data.get("data", {})
249
+
250
+ if not action:
251
+ raise HTTPException("Missing action", code=1005, http_status=400)
252
+
253
+ # Run request handler
254
+ return_data = await bridge_request(action, input_data, self)
255
+ if return_data is not None:
256
+ self.write_custom_message(return_data)
257
+
258
+ except HTTPException as e:
259
+ self.log_request(error=e)
260
+ self.error_message(str(e.message), e.code or -1)
261
+ return
262
+
263
+ except Exception as e:
264
+ self.log_request(error=e)
265
+ self.logger.exception(f"Error processing request with exception: {e}")
266
+ self.error_message(
267
+ "Found an error while processing your request. Please try again later."
268
+ " Send us a message if the problem persists."
269
+ )
270
+ return
271
+
272
+ finally:
273
+ self.msg_id = None
274
+ self.service = None
275
+
276
+ self.timer.print_timer_stats()
277
+ self.timer.reset_timers()
278
+
279
+ async def _authenticate_device(self, session_token: str) -> None:
280
+ """Validate a device session token and register the device connection.
281
+ Override in subclasses to implement project-specific device authentication."""
282
+ pass
283
+
284
+ def on_close(self):
285
+ self.logger.debug(
286
+ "WS close uid=%s code=%s reason=%s",
287
+ self.uid,
288
+ self.close_code,
289
+ self.close_reason or "<none>",
290
+ )
291
+ if self.uid:
292
+ self.wb_connection_manager.user_connections.remove_connection(self)
293
+ if self.device_id:
294
+ self.wb_connection_manager.device_connections.remove_connection(self)
295
+ self.device_id = None
296
+ self.uid = None
297
+ self.auth_token = None
298
+ self.current_user = None
299
+ self.current_session_sid = None
300
+
301
+
302
+ # * WebSocketHandler - JSON WebSocket handler (backward compatible)
303
+ class WebSocketHandler(BaseWebSocketHandler):
304
+ """JSON WebSocket handler at /v1/socket."""
305
+
306
+ encoder = JsonEncoder()
307
+
308
+
309
+ # * MsgpackWebSocketHandler - MessagePack WebSocket handler
310
+ class MsgpackWebSocketHandler(BaseWebSocketHandler):
311
+ """MessagePack WebSocket handler at /v1/socket/msgpack."""
312
+
313
+ encoder = MsgpackEncoder()
@@ -0,0 +1,73 @@
1
+ class Colors:
2
+ # Reset
3
+ ColorOff = "\033[0m" # Text Reset
4
+
5
+ # Regular Colors
6
+ Black = "\033[0;30m" # Black
7
+ Red = "\033[0;31m" # Red
8
+ Green = "\033[0;32m" # Green
9
+ Yellow = "\033[0;33m" # Yellow
10
+ Blue = "\033[0;34m" # Blue
11
+ Purple = "\033[0;35m" # Purple
12
+ Cyan = "\033[0;36m" # Cyan
13
+ White = "\033[0;37m" # White
14
+
15
+ # Bold
16
+ BBlack = "\033[1;30m" # Black
17
+ BRed = "\033[1;31m" # Red
18
+ BGreen = "\033[1;32m" # Green
19
+ BYellow = "\033[1;33m" # Yellow
20
+ BBlue = "\033[1;34m" # Blue
21
+ BPurple = "\033[1;35m" # Purple
22
+ BCyan = "\033[1;36m" # Cyan
23
+ BWhite = "\033[1;37m" # White
24
+
25
+ # Underline
26
+ UBlack = "\033[4;30m" # Black
27
+ URed = "\033[4;31m" # Red
28
+ UGreen = "\033[4;32m" # Green
29
+ UYellow = "\033[4;33m" # Yellow
30
+ UBlue = "\033[4;34m" # Blue
31
+ UPurple = "\033[4;35m" # Purple
32
+ UCyan = "\033[4;36m" # Cyan
33
+ UWhite = "\033[4;37m" # White
34
+
35
+ # Background
36
+ BgBlack = "\033[40m" # Black
37
+ BgRed = "\033[41m" # Red
38
+ BgGreen = "\033[42m" # Green
39
+ BgYellow = "\033[43m" # Yellow
40
+ BgBlue = "\033[44m" # Blue
41
+ BgPurple = "\033[45m" # Purple
42
+ BgCyan = "\033[46m" # Cyan
43
+ BgWhite = "\033[47m" # White
44
+
45
+ # High Intensty
46
+ IBlack = "\033[0;90m" # Black
47
+ IRed = "\033[0;91m" # Red
48
+ IGreen = "\033[0;92m" # Green
49
+ IYellow = "\033[0;93m" # Yellow
50
+ IBlue = "\033[0;94m" # Blue
51
+ IPurple = "\033[0;95m" # Purple
52
+ ICyan = "\033[0;96m" # Cyan
53
+ IWhite = "\033[0;97m" # White
54
+
55
+ # Bold High Intensty
56
+ BIBlack = "\033[1;90m" # Black
57
+ BIRed = "\033[1;91m" # Red
58
+ BIGreen = "\033[1;92m" # Green
59
+ BIYellow = "\033[1;93m" # Yellow
60
+ BIBlue = "\033[1;94m" # Blue
61
+ BIPurple = "\033[1;95m" # Purple
62
+ BICyan = "\033[1;96m" # Cyan
63
+ BIWhite = "\033[1;97m" # White
64
+
65
+ # High Intensty backgrounds
66
+ BgIBlack = "\033[0;100m" # Black
67
+ BgIRed = "\033[0;101m" # Red
68
+ BgIGreen = "\033[0;102m" # Green
69
+ BgIYellow = "\033[0;103m" # Yellow
70
+ BgIBlue = "\033[0;104m" # Blue
71
+ BgIPurple = "\033[10;95m" # Purple
72
+ BgICyan = "\033[0;106m" # Cyan
73
+ BgIWhite = "\033[0;107m" # White
@@ -0,0 +1,132 @@
1
+ import logging
2
+ import sys
3
+ from copy import deepcopy
4
+ from os import environ, getcwd, path
5
+ from typing import Any
6
+
7
+ from dotenv import load_dotenv
8
+
9
+
10
+ def coerce_value(value: Any) -> Any:
11
+ """Turn a plain integer string into an int, leaving everything else alone.
12
+
13
+ The round-trip check keeps values where the text form carries meaning - a zero
14
+ padded id or a numeric secret must not silently become a different number.
15
+ """
16
+ if isinstance(value, list) or not isinstance(value, str):
17
+ return value
18
+
19
+ if value.isdigit() and str(int(value)) == value:
20
+ return int(value)
21
+
22
+ return value
23
+
24
+
25
+ def replace_rec(
26
+ keys: list[str],
27
+ finalDict: dict[str, Any],
28
+ finalValue: Any,
29
+ ) -> dict[str, Any]:
30
+ key = keys.pop(0)
31
+ if len(keys) == 0:
32
+ finalDict[key] = coerce_value(finalValue)
33
+ return finalDict
34
+
35
+ if key not in finalDict:
36
+ finalDict[key] = {}
37
+
38
+ # An env var whose key path runs through an existing scalar cannot be merged
39
+ # (e.g. API_KEY_PEPPER against a config that already has api.key). Overwriting
40
+ # the scalar would silently destroy it, so refuse instead.
41
+ if not isinstance(finalDict[key], dict):
42
+ raise ValueError(f"Cannot descend into non-dict config key '{key}': it already holds a scalar value")
43
+
44
+ finalDict[key] = replace_rec(keys, finalDict[key], finalValue)
45
+ return finalDict
46
+
47
+
48
+ def parse_splitted_values(value: str, separator: str = ",") -> list[str]:
49
+ valueList = value.split(separator)
50
+ formattedList: list[str] = []
51
+ for item in valueList:
52
+ item = item.strip(" \r\n\t\\")
53
+ if len(item) == 0 or item[0] == "#":
54
+ continue
55
+
56
+ formattedList.append(item)
57
+
58
+ return formattedList
59
+
60
+
61
+ def is_env(env: str, config: dict[str, Any] | None = None) -> bool:
62
+ if config is None:
63
+ from py_app_runner.registry import AppRegistry
64
+
65
+ config = AppRegistry.config()
66
+ return env == config.get("environment", "")
67
+
68
+
69
+ def is_env_dev(config: dict[str, Any] | None = None) -> bool:
70
+ return is_env("dev", config)
71
+
72
+
73
+ def is_env_test(config: dict[str, Any] | None = None) -> bool:
74
+ return is_env("test", config)
75
+
76
+
77
+ def is_env_prod(config: dict[str, Any] | None = None) -> bool:
78
+ return is_env("prod", config)
79
+
80
+
81
+ def load_config(
82
+ defaults: dict[str, Any] | None = None,
83
+ split_value_keys: list[str] | None = None,
84
+ ) -> dict[str, Any]:
85
+ """
86
+ Load configuration from environment variables.
87
+
88
+ 1. Calls load_dotenv() from the current working directory
89
+ 2. Takes a `defaults` dict (the project's base config structure)
90
+ 3. Applies replace_rec() to fill in from env vars
91
+ 4. Handles split_value_keys (CSV splitting)
92
+ 5. Returns the populated dict
93
+ """
94
+ currentDirectory = getcwd()
95
+ load_dotenv(path.join(currentDirectory, ".env"))
96
+
97
+ appEnv = environ.get("APP_ENV", None)
98
+ if not appEnv:
99
+ logging.getLogger().error("*\n*\n* It seems that `.env` file is not loaded\n*\n*")
100
+ sys.exit(-1)
101
+
102
+ if defaults is None:
103
+ defaults = {}
104
+
105
+ if split_value_keys is None:
106
+ split_value_keys = []
107
+
108
+ # Deep copy: replace_rec writes into nested dicts, which a shallow copy would
109
+ # share with the caller's (often module level) defaults.
110
+ config_dict: dict[str, Any] = deepcopy(defaults)
111
+
112
+ # Always set environment from APP_ENV
113
+ config_dict["environment"] = appEnv
114
+
115
+ for key in environ:
116
+ keys = key.split("_")
117
+ keys = [item.lower() for item in keys]
118
+ value = environ[key]
119
+
120
+ if len(keys) == 0 or config_dict.get(keys[0], None) is None:
121
+ continue
122
+
123
+ new_value: Any = parse_splitted_values(value) if key in split_value_keys else value
124
+
125
+ try:
126
+ config_dict = replace_rec(keys, config_dict, new_value)
127
+ except ValueError as e:
128
+ # One unmappable variable must not take the whole service down, but it
129
+ # must be loud: the value the operator set is not in effect.
130
+ logging.getLogger(__name__).error("Ignoring env var %s: %s", key, e)
131
+
132
+ return config_dict
@@ -0,0 +1,14 @@
1
+ """Field encryption, the shared client envelope, and password hashing."""
2
+
3
+ from py_app_runner.crypto.errors import CryptoError
4
+ from py_app_runner.crypto.fields import FieldCrypto, generate_key, is_encrypted, key_id_of
5
+ from py_app_runner.crypto.passwords import PasswordHasher
6
+
7
+ __all__ = [
8
+ "CryptoError",
9
+ "FieldCrypto",
10
+ "PasswordHasher",
11
+ "generate_key",
12
+ "is_encrypted",
13
+ "key_id_of",
14
+ ]
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+ import logging
3
+ from argparse import Namespace
4
+
5
+ import psycopg
6
+
7
+ from py_app_runner.crypto.commands import Out, cmd_key, cmd_rotate
8
+ from py_app_runner.crypto.errors import CryptoError
9
+ from py_app_runner.crypto.fields import FieldCrypto
10
+ from py_app_runner.migrations._service import connect_kwargs
11
+ from py_app_runner.pybridge import PyBridge
12
+ from py_app_runner.registry import AppRegistry
13
+
14
+ _DEFAULT_DB = "main"
15
+
16
+
17
+ async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
18
+ out: Out = print
19
+
20
+ # `key` runs before any config is read and any connection is opened. Generating the
21
+ # first key must not require a working install, because until it exists there isn't
22
+ # one - the environment variable it goes into is what the config refers to.
23
+ if args.step == "key":
24
+ raise SystemExit(cmd_key(out))
25
+
26
+ # Same reasoning as migrations: runner.py catches Exception around init_service and
27
+ # returns normally, which exits 0. A rotate that could not reach the database must not
28
+ # report success to a deploy script, so every non-SystemExit failure becomes a non-zero
29
+ # exit here rather than in runner.py.
30
+ code = 1
31
+ try:
32
+ config = AppRegistry.config()
33
+ crypto = FieldCrypto.from_config(config)
34
+
35
+ db_name = getattr(args, "db", None) or _DEFAULT_DB
36
+ db_config = config.get("db") or {}
37
+ if db_name not in db_config:
38
+ out(
39
+ f'error: no database {db_name!r} in config["db"]; '
40
+ f"configured are: {', '.join(sorted(db_config)) or 'none'}."
41
+ )
42
+ raise SystemExit(2)
43
+
44
+ if args.step == "rotate":
45
+ async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
46
+ code = await cmd_rotate(
47
+ conn,
48
+ crypto,
49
+ args.table,
50
+ args.column,
51
+ args.id,
52
+ args.batch,
53
+ getattr(args, "dry_run", False),
54
+ out,
55
+ )
56
+ else:
57
+ out(f"error: unknown crypto command {args.step!r}")
58
+ code = 1
59
+
60
+ except CryptoError as e:
61
+ # Configuration and key material problems are the expected failure here, and their
62
+ # messages already say what to do. A stack trace would bury that.
63
+ out(f"error: {e}")
64
+ raise SystemExit(1) from None
65
+ except (KeyboardInterrupt, asyncio.CancelledError):
66
+ # Above `except Exception` because both derive from BaseException. An interrupted
67
+ # rotate has re-encrypted some rows and not others, which is safe to resume but must
68
+ # not be reported as done.
69
+ logger.error("crypto: interrupted; the column may be partially rotated")
70
+ raise SystemExit(1) from None
71
+ except Exception:
72
+ logger.exception("crypto: unhandled failure")
73
+ raise SystemExit(1) from None
74
+
75
+ raise SystemExit(code)
@@ -0,0 +1,54 @@
1
+ """CLI subparsers for the built-in crypto service.
2
+
3
+ python3 src/app.py crypto key
4
+ python3 src/app.py crypto rotate --table NAME --column NAME [--id NAME]
5
+ [--batch N] [--dry-run] [--db NAME]
6
+
7
+ `--dry-run` shadows a real top-level flag on runner.py's parser, so it needs
8
+ `default=SUPPRESS` or argparse copies the subparser default back over the parent
9
+ namespace - the same collision migrations' `apply` has.
10
+ """
11
+
12
+ import logging
13
+ from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
14
+
15
+ from py_app_runner.pybridge import PyBridge
16
+
17
+
18
+ def reg_subparsers(
19
+ subparsers: "_SubParsersAction[ArgumentParser]",
20
+ _pybridge: PyBridge,
21
+ _base_logger: logging.Logger,
22
+ ) -> None:
23
+ """Command line subparsers"""
24
+
25
+ parser = subparsers.add_parser(
26
+ "crypto",
27
+ description="Generate key material and re-encrypt stored columns",
28
+ help="Field encryption",
29
+ )
30
+ group = parser.add_subparsers(title="command", dest="step", required=True)
31
+
32
+ group.add_parser("key", help="Print fresh key material for an environment variable")
33
+
34
+ rotate_parser = group.add_parser("rotate", help="Re-encrypt a column under the current key")
35
+ rotate_parser.add_argument("--table", required=True, help="Table holding the column")
36
+ rotate_parser.add_argument("--column", required=True, help="Encrypted column")
37
+ rotate_parser.add_argument("--id", default="id", help="Primary key to page through (default: id)")
38
+ rotate_parser.add_argument(
39
+ "--batch",
40
+ type=int,
41
+ default=500,
42
+ help="Rows read per statement (default: 500)",
43
+ )
44
+ rotate_parser.add_argument(
45
+ "--dry-run",
46
+ action="store_true",
47
+ default=SUPPRESS,
48
+ help="Report what would change, change nothing",
49
+ )
50
+ rotate_parser.add_argument(
51
+ "--db",
52
+ default=None,
53
+ help='Entry of config["db"] to rotate in (default: main)',
54
+ )