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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""What both drivers answer to.
|
|
2
|
+
|
|
3
|
+
A Protocol rather than a base class, so neither driver inherits anything it does not use and
|
|
4
|
+
a test double does not have to subclass to stand in for one.
|
|
5
|
+
|
|
6
|
+
The shapes are fixed to the database driver's vocabulary - `pending`, `delayed`, `reserved`
|
|
7
|
+
- even where redis models the same idea differently. There is one status command and it
|
|
8
|
+
prints one table, so the driver is what adapts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from py_app_runner.queue.job import Job
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class QueueDriver(Protocol):
|
|
18
|
+
async def push(
|
|
19
|
+
self,
|
|
20
|
+
name: str,
|
|
21
|
+
payload: dict[str, Any] | None = None,
|
|
22
|
+
delay: int = 0,
|
|
23
|
+
queue: str = "default",
|
|
24
|
+
priority: int = 0,
|
|
25
|
+
unique: str | None = None,
|
|
26
|
+
max_attempts: int = 3,
|
|
27
|
+
) -> int:
|
|
28
|
+
"""Queue a job, returning its id - or the id of the job already holding `unique`."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
async def reserve(self, queues: list[str], timeout: int, worker: str) -> Job | None:
|
|
32
|
+
"""Claim the next due job, or None. `queues` is a precedence order, not a merged
|
|
33
|
+
sort. The claim lasts `timeout` seconds and must become claimable again after that
|
|
34
|
+
without anybody having to release it."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
async def delete(self, job: Job) -> None:
|
|
38
|
+
"""Done. Forget it."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
async def release(self, job: Job, delay: int = 0, error: str = "") -> None:
|
|
42
|
+
"""Put it back for another attempt. Must not change `attempts` - reserving already
|
|
43
|
+
counted this one."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
async def fail(self, job: Job, error: str) -> None:
|
|
47
|
+
"""Out of attempts. Keep it where a human will find it."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
async def pending(self, queue: str | None = None) -> int:
|
|
51
|
+
"""How many could be picked up right now: excludes jobs not yet due and jobs another
|
|
52
|
+
worker currently holds."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
async def stats(self) -> list[dict[str, Any]]: ...
|
|
56
|
+
|
|
57
|
+
async def failed_count(self) -> int: ...
|
|
58
|
+
|
|
59
|
+
async def failed_rows(self, limit: int) -> list[dict[str, Any]]: ...
|
|
60
|
+
|
|
61
|
+
async def retry_failed(self, job_id: int | None, max_attempts: int) -> int: ...
|
|
62
|
+
|
|
63
|
+
async def forget_failed(self, job_id: int | None, before: str | None) -> int: ...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The job as a handler sees it, and the errors the queue raises."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class QueueError(Exception):
|
|
8
|
+
"""A queue that is configured or called wrongly. Never raised for a job that failed -
|
|
9
|
+
that is what the failed table is for."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Job:
|
|
14
|
+
id: int
|
|
15
|
+
queue: str
|
|
16
|
+
name: str
|
|
17
|
+
payload: dict[str, Any]
|
|
18
|
+
# The row exactly as it was written. Carried so a job moved to the failed table keeps
|
|
19
|
+
# byte-identical payload, rather than a re-encoding of a decoding of it.
|
|
20
|
+
payload_json: str
|
|
21
|
+
# Includes this attempt: reserving *is* the attempt, so the first run sees 1.
|
|
22
|
+
attempts: int
|
|
23
|
+
max_attempts: int
|
|
24
|
+
# Driver bookmark. Empty on the database driver, the stream entry id on redis.
|
|
25
|
+
handle: str = ""
|
|
26
|
+
|
|
27
|
+
_release_delay: int | None = field(default=None, repr=False)
|
|
28
|
+
|
|
29
|
+
def release(self, delay: int = 0) -> None:
|
|
30
|
+
"""Put this job back for another attempt.
|
|
31
|
+
|
|
32
|
+
A deliberate "not now" - a rate limit upstream, a file that has not landed yet -
|
|
33
|
+
rather than a failure. It does not count against the attempt budget any differently
|
|
34
|
+
from a crash, because the claim already counted it.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
self._release_delay = max(0, delay)
|
|
38
|
+
|
|
39
|
+
def was_released(self) -> bool:
|
|
40
|
+
return self._release_delay is not None
|
|
41
|
+
|
|
42
|
+
def release_delay(self) -> int:
|
|
43
|
+
return self._release_delay or 0
|
|
44
|
+
|
|
45
|
+
def is_last_attempt(self) -> bool:
|
|
46
|
+
return self.attempts >= self.max_attempts
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The worker loop.
|
|
2
|
+
|
|
3
|
+
Polls rather than blocks, so latency is bounded by `sleep` and nothing holds a connection
|
|
4
|
+
open waiting. One reserve, one job, one completion per iteration.
|
|
5
|
+
|
|
6
|
+
The per-job timeout is `asyncio.timeout()`, which genuinely cancels the task rather than
|
|
7
|
+
asking it to stop at a point it may never reach - a signal-based timeout cannot interrupt a
|
|
8
|
+
job blocked in a query, which is the case most worth interrupting. The visibility timeout
|
|
9
|
+
stays as the backstop for a worker that dies outright rather than merely overrunning.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
import traceback
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from py_app_runner.queue.handler import resolve
|
|
20
|
+
from py_app_runner.queue.job import Job
|
|
21
|
+
|
|
22
|
+
Out = Callable[[str], None]
|
|
23
|
+
|
|
24
|
+
_logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Five consecutive failures to reserve means the queue is unreadable, not that it is empty.
|
|
27
|
+
# Exiting lets a supervisor restart the process rather than having it sit there logging.
|
|
28
|
+
MAX_CONSECUTIVE_FAILURES = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def backoff(attempt: int, steps: list[int] | int | None = None) -> int:
|
|
32
|
+
"""Delay before the next attempt, given how many have already been made.
|
|
33
|
+
|
|
34
|
+
A list gives one delay per attempt and repeats the last entry forever, so a job that
|
|
35
|
+
keeps failing settles at a sensible interval rather than growing without bound.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
if steps is None:
|
|
39
|
+
steps = [10, 60, 300]
|
|
40
|
+
|
|
41
|
+
if isinstance(steps, int):
|
|
42
|
+
return max(0, steps)
|
|
43
|
+
|
|
44
|
+
if not steps:
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
return max(0, steps[min(max(0, attempt - 1), len(steps) - 1)])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Worker:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
queue: Any,
|
|
54
|
+
handlers: dict[str, Any] | None = None,
|
|
55
|
+
backoff_steps: list[int] | int | None = None,
|
|
56
|
+
out: Out | None = None,
|
|
57
|
+
worker_id: str = "worker",
|
|
58
|
+
) -> None:
|
|
59
|
+
self.queue = queue
|
|
60
|
+
self.handlers = handlers or {}
|
|
61
|
+
self.backoff_steps = backoff_steps
|
|
62
|
+
self.out = out or print
|
|
63
|
+
self.worker_id = worker_id
|
|
64
|
+
self.should_quit = False
|
|
65
|
+
|
|
66
|
+
def stop(self) -> None:
|
|
67
|
+
"""Ask the loop to finish the job in hand and exit."""
|
|
68
|
+
|
|
69
|
+
self.should_quit = True
|
|
70
|
+
|
|
71
|
+
async def run(
|
|
72
|
+
self,
|
|
73
|
+
queues: list[str],
|
|
74
|
+
timeout: int = 300,
|
|
75
|
+
sleep: float = 1.0,
|
|
76
|
+
max_jobs: int = 0,
|
|
77
|
+
max_time: int = 0,
|
|
78
|
+
stop_when_empty: bool = False,
|
|
79
|
+
) -> int:
|
|
80
|
+
"""Returns a process exit code: 0 for every ordinary end, 1 only when the queue
|
|
81
|
+
could not be read repeatedly. A failed *job* never changes the exit code - that is
|
|
82
|
+
what the failed table is for."""
|
|
83
|
+
|
|
84
|
+
self.out(f"Worker {self.worker_id} watching {', '.join(queues)}")
|
|
85
|
+
|
|
86
|
+
started = time.monotonic()
|
|
87
|
+
done = 0
|
|
88
|
+
failures = 0
|
|
89
|
+
|
|
90
|
+
while True:
|
|
91
|
+
if self.should_quit:
|
|
92
|
+
self.out("Stopping: asked to shut down")
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
job = await self.queue.reserve(queues, timeout, self.worker_id)
|
|
97
|
+
failures = 0
|
|
98
|
+
except Exception as e:
|
|
99
|
+
failures += 1
|
|
100
|
+
self.out(f"error: could not reserve a job: {e}")
|
|
101
|
+
if failures >= MAX_CONSECUTIVE_FAILURES:
|
|
102
|
+
self.out(f"Stopping: the queue has been unreadable {MAX_CONSECUTIVE_FAILURES} times running")
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
await self._rest(sleep)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if job is None:
|
|
109
|
+
if stop_when_empty:
|
|
110
|
+
self.out("Nothing left to do")
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
reason = self._limit_reached(done, started, max_jobs, max_time)
|
|
114
|
+
if reason:
|
|
115
|
+
self.out(f"Stopping: {reason}")
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
await self._rest(sleep)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
await self._run_job(job, timeout)
|
|
122
|
+
done += 1
|
|
123
|
+
|
|
124
|
+
reason = self._limit_reached(done, started, max_jobs, max_time)
|
|
125
|
+
if reason:
|
|
126
|
+
self.out(f"Stopping: {reason}")
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
self.out(f"Ran {done} job(s)")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
async def run_next(self, queues: list[str], timeout: int = 300) -> bool:
|
|
133
|
+
"""Reserve and run exactly one job. The single-shot primitive, for tests - it
|
|
134
|
+
installs no signal handlers, applies no limits and does not catch reserve failures."""
|
|
135
|
+
|
|
136
|
+
job = await self.queue.reserve(queues, timeout, self.worker_id)
|
|
137
|
+
if job is None:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
await self._run_job(job, timeout)
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
###############
|
|
144
|
+
### Running ###
|
|
145
|
+
###############
|
|
146
|
+
|
|
147
|
+
async def _run_job(self, job: Job, timeout: int) -> None:
|
|
148
|
+
self.out(f"-> {job.name} #{job.id} (attempt {job.attempts}/{job.max_attempts})")
|
|
149
|
+
started = time.monotonic()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Resolution is inside the try on purpose: a handler that cannot be built fails
|
|
153
|
+
# the job through the same release/fail path as one that threw, rather than
|
|
154
|
+
# being a separate outcome nobody handles.
|
|
155
|
+
handler = resolve(job.name, self.handlers)
|
|
156
|
+
async with asyncio.timeout(timeout):
|
|
157
|
+
await handler(job.payload, job)
|
|
158
|
+
except asyncio.CancelledError:
|
|
159
|
+
# A cancellation that is not ours - the process is shutting down. Release the
|
|
160
|
+
# job so it is picked up promptly rather than waiting out its reservation, and
|
|
161
|
+
# let the cancellation continue.
|
|
162
|
+
await self._safe_release(job, 0, "Worker was cancelled mid-job")
|
|
163
|
+
raise
|
|
164
|
+
except Exception as e:
|
|
165
|
+
# QueueError included: a handler that cannot be resolved is a job that cannot
|
|
166
|
+
# run, and it goes through the same release/fail budget as any other failure.
|
|
167
|
+
await self._job_failed(job, e)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if job.was_released():
|
|
171
|
+
await self.queue.release(job, job.release_delay(), "")
|
|
172
|
+
self.out(f" released, back in {job.release_delay()}s")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
await self.queue.delete(job)
|
|
176
|
+
self.out(f" done in {int((time.monotonic() - started) * 1000)}ms")
|
|
177
|
+
|
|
178
|
+
async def _job_failed(self, job: Job, error: BaseException) -> None:
|
|
179
|
+
detail = f"{type(error).__name__}: {error}\n{''.join(traceback.format_exception(error))}"
|
|
180
|
+
|
|
181
|
+
if job.is_last_attempt():
|
|
182
|
+
await self.queue.fail(job, detail)
|
|
183
|
+
self.out(f" failed for good: {error}")
|
|
184
|
+
# Both, deliberately: process output goes to a supervisor log nobody reads,
|
|
185
|
+
# and the application's own logging is where the rest of its problems surface.
|
|
186
|
+
_logger.error("Queue job %s #%s failed permanently: %s", job.name, job.id, error)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
delay = backoff(job.attempts, self.backoff_steps)
|
|
190
|
+
await self.queue.release(job, delay, detail)
|
|
191
|
+
self.out(f" failed, retrying in {delay}s: {error}")
|
|
192
|
+
|
|
193
|
+
async def _safe_release(self, job: Job, delay: int, error: str) -> None:
|
|
194
|
+
try:
|
|
195
|
+
await self.queue.release(job, delay, error)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
# Already unwinding; the reservation will expire on its own.
|
|
198
|
+
_logger.warning("Queue could not release job #%s during shutdown: %s", job.id, e)
|
|
199
|
+
|
|
200
|
+
###############
|
|
201
|
+
### Limits ####
|
|
202
|
+
###############
|
|
203
|
+
|
|
204
|
+
def _limit_reached(self, done: int, started: float, max_jobs: int, max_time: int) -> str:
|
|
205
|
+
if max_jobs > 0 and done >= max_jobs:
|
|
206
|
+
return f"ran {done} jobs"
|
|
207
|
+
|
|
208
|
+
if max_time > 0 and (time.monotonic() - started) >= max_time:
|
|
209
|
+
return f"been going for {int(time.monotonic() - started)}s"
|
|
210
|
+
|
|
211
|
+
return ""
|
|
212
|
+
|
|
213
|
+
async def _rest(self, sleep: float) -> None:
|
|
214
|
+
"""Sleep in short slices so a shutdown request is noticed promptly rather than after
|
|
215
|
+
a full poll interval."""
|
|
216
|
+
|
|
217
|
+
remaining = sleep
|
|
218
|
+
while remaining > 0 and not self.should_quit:
|
|
219
|
+
slice_length = min(0.25, remaining)
|
|
220
|
+
await asyncio.sleep(slice_length)
|
|
221
|
+
remaining -= slice_length
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AppRegistry:
|
|
5
|
+
"""
|
|
6
|
+
Singleton registry holding project-specific classes and configuration
|
|
7
|
+
that the framework needs. Must be configured before the runner starts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
_config: dict[str, Any] = {}
|
|
11
|
+
_users_model_cls: type | None = None
|
|
12
|
+
_api_keys_model_cls: type | None = None
|
|
13
|
+
_redis_channel: str = ""
|
|
14
|
+
_api_key_use_db: bool = False
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def configure(
|
|
18
|
+
cls,
|
|
19
|
+
*,
|
|
20
|
+
config: dict[str, Any],
|
|
21
|
+
users_model: type,
|
|
22
|
+
api_keys_model: type,
|
|
23
|
+
redis_channel: str = "",
|
|
24
|
+
api_key_use_db: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
cls._config = config
|
|
27
|
+
cls._users_model_cls = users_model
|
|
28
|
+
cls._api_keys_model_cls = api_keys_model
|
|
29
|
+
cls._redis_channel = redis_channel
|
|
30
|
+
cls._api_key_use_db = api_key_use_db
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def config(cls) -> dict[str, Any]:
|
|
34
|
+
return cls._config
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def users_model(cls) -> type:
|
|
38
|
+
if cls._users_model_cls is None:
|
|
39
|
+
raise RuntimeError("AppRegistry not configured: users_model is None")
|
|
40
|
+
return cls._users_model_cls
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def api_keys_model(cls) -> type:
|
|
44
|
+
if cls._api_keys_model_cls is None:
|
|
45
|
+
raise RuntimeError("AppRegistry not configured: api_keys_model is None")
|
|
46
|
+
return cls._api_keys_model_cls
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def redis_channel(cls) -> str:
|
|
50
|
+
return cls._redis_channel
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def api_key_use_db(cls) -> bool:
|
|
54
|
+
return cls._api_key_use_db
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Literal, TypedDict, cast
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
|
|
7
|
+
from py_app_runner.registry import AppRegistry
|
|
8
|
+
from py_app_runner.utils import CustomJSONEncoder
|
|
9
|
+
|
|
10
|
+
JwtType = Literal["user", "device", "impersonation"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JwtPayload(TypedDict, total=False):
|
|
14
|
+
sub: str
|
|
15
|
+
typ: JwtType
|
|
16
|
+
iat: int
|
|
17
|
+
exp: int
|
|
18
|
+
jti: str
|
|
19
|
+
imp: str # impersonator public_id (only for typ="impersonation")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthService:
|
|
23
|
+
def __init__(self, logger):
|
|
24
|
+
self.logger = logger
|
|
25
|
+
|
|
26
|
+
def _now(self) -> dt.datetime:
|
|
27
|
+
return dt.datetime.now(dt.UTC)
|
|
28
|
+
|
|
29
|
+
def create_access_jwt(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
subject_public_id: str,
|
|
33
|
+
token_type: JwtType,
|
|
34
|
+
ttl_seconds: int = 15 * 60,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Create short-lived access JWT.
|
|
38
|
+
'sub' MUST be the public_id UUID string of the user/device.
|
|
39
|
+
"""
|
|
40
|
+
now = self._now()
|
|
41
|
+
payload: dict = {
|
|
42
|
+
"sub": str(subject_public_id),
|
|
43
|
+
"typ": token_type,
|
|
44
|
+
"iat": int(now.timestamp()),
|
|
45
|
+
"exp": int((now + dt.timedelta(seconds=ttl_seconds)).timestamp()),
|
|
46
|
+
"jti": str(uuid.uuid4()),
|
|
47
|
+
}
|
|
48
|
+
secret = AppRegistry.config()["jwt"]["secret"]
|
|
49
|
+
return jwt.encode(payload, secret, algorithm="HS256", json_encoder=CustomJSONEncoder) # type: ignore
|
|
50
|
+
|
|
51
|
+
def create_impersonation_jwt(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
target_public_id: str,
|
|
55
|
+
impersonator_public_id: str,
|
|
56
|
+
ttl_seconds: int = 30 * 60,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Create an impersonation JWT.
|
|
60
|
+
sub = target user, imp = superadmin who initiated impersonation.
|
|
61
|
+
"""
|
|
62
|
+
now = self._now()
|
|
63
|
+
payload: dict = {
|
|
64
|
+
"sub": str(target_public_id),
|
|
65
|
+
"typ": "impersonation",
|
|
66
|
+
"imp": str(impersonator_public_id),
|
|
67
|
+
"iat": int(now.timestamp()),
|
|
68
|
+
"exp": int((now + dt.timedelta(seconds=ttl_seconds)).timestamp()),
|
|
69
|
+
"jti": str(uuid.uuid4()),
|
|
70
|
+
}
|
|
71
|
+
secret = AppRegistry.config()["jwt"]["secret"]
|
|
72
|
+
return jwt.encode(payload, secret, algorithm="HS256", json_encoder=CustomJSONEncoder) # type: ignore
|
|
73
|
+
|
|
74
|
+
def verify_access_jwt(self, token: str, *, expected_type: JwtType) -> JwtPayload | None:
|
|
75
|
+
"""
|
|
76
|
+
Verify JWT signature + exp, enforce expected typ.
|
|
77
|
+
Returns payload dict if valid else None.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
payload = cast(
|
|
81
|
+
JwtPayload,
|
|
82
|
+
jwt.decode(
|
|
83
|
+
token,
|
|
84
|
+
AppRegistry.config()["jwt"]["secret"],
|
|
85
|
+
algorithms=["HS256"],
|
|
86
|
+
options={
|
|
87
|
+
"require": ["exp", "iat", "sub", "typ"],
|
|
88
|
+
},
|
|
89
|
+
leeway=10, # seconds clock skew tolerance
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
except jwt.ExpiredSignatureError:
|
|
93
|
+
self.logger.debug("JWT expired")
|
|
94
|
+
return None
|
|
95
|
+
except jwt.InvalidTokenError:
|
|
96
|
+
self.logger.debug("JWT invalid")
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
if payload.get("typ") != expected_type:
|
|
100
|
+
self.logger.debug("JWT type mismatch: expected=%s got=%s", expected_type, payload.get("typ"))
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Validate UUID format for sub/jti (defensive)
|
|
104
|
+
try:
|
|
105
|
+
uuid.UUID(payload["sub"])
|
|
106
|
+
uuid.UUID(payload["jti"])
|
|
107
|
+
except Exception:
|
|
108
|
+
self.logger.debug("JWT payload UUID fields invalid")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# For impersonation tokens, also validate the impersonator UUID
|
|
112
|
+
if payload.get("typ") == "impersonation":
|
|
113
|
+
imp = payload.get("imp")
|
|
114
|
+
if not imp:
|
|
115
|
+
self.logger.debug("Impersonation JWT missing imp claim")
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
uuid.UUID(imp)
|
|
119
|
+
except Exception:
|
|
120
|
+
self.logger.debug("Impersonation JWT imp field invalid")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
return payload
|