queryapigate 0.5.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.
- queryapigate/__init__.py +6 -0
- queryapigate/__main__.py +3 -0
- queryapigate/apikeys.py +535 -0
- queryapigate/app.py +844 -0
- queryapigate/cache.py +61 -0
- queryapigate/cli.py +168 -0
- queryapigate/config.py +268 -0
- queryapigate/cors.py +51 -0
- queryapigate/engine.py +166 -0
- queryapigate/errors.py +8 -0
- queryapigate/formats.py +193 -0
- queryapigate/lib/h2-2.2.224.jar +0 -0
- queryapigate/logging_setup.py +75 -0
- queryapigate/metrics.py +215 -0
- queryapigate/openapi.py +410 -0
- queryapigate/params.py +275 -0
- queryapigate/pool.py +146 -0
- queryapigate/ratelimit.py +88 -0
- queryapigate/runners.py +795 -0
- queryapigate/schema.py +99 -0
- queryapigate/sqltools.py +184 -0
- queryapigate/store.py +433 -0
- queryapigate/ui.py +2371 -0
- queryapigate-0.5.0.dist-info/METADATA +708 -0
- queryapigate-0.5.0.dist-info/RECORD +29 -0
- queryapigate-0.5.0.dist-info/WHEEL +5 -0
- queryapigate-0.5.0.dist-info/entry_points.txt +2 -0
- queryapigate-0.5.0.dist-info/licenses/LICENSE +21 -0
- queryapigate-0.5.0.dist-info/top_level.txt +1 -0
queryapigate/__init__.py
ADDED
queryapigate/__main__.py
ADDED
queryapigate/apikeys.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Per-key API permissions, layered on top of ``QUERYAPIGATE_API_KEY``.
|
|
2
|
+
|
|
3
|
+
``QUERYAPIGATE_API_KEY`` (if set) stays a full-access, unrestricted admin key, unchanged from before this
|
|
4
|
+
module existed. Scoped keys are additive: each is limited to a list of connection names (or every
|
|
5
|
+
connection) and can be denied write access even when the server otherwise allows it. Only the admin key can
|
|
6
|
+
create, change or delete a scoped key, list connections, or manage saved queries - a scoped key can only
|
|
7
|
+
run queries against the connections it was granted.
|
|
8
|
+
|
|
9
|
+
A key also gets an independent ``queries`` grant: a list of saved-query names it may run regardless of
|
|
10
|
+
``connections`` - so an external-client key can be scoped to exactly ``monthly_revenue`` and nothing else,
|
|
11
|
+
with no connection access of its own at all, while an internal key keeps the coarser connection-level grant
|
|
12
|
+
unchanged. The two are additive, not nested: a saved query listed by name is runnable whether or not its
|
|
13
|
+
connection is also in ``connections``, and being granted a connection still allows *every* saved query on
|
|
14
|
+
it as before - ``queries`` only ever adds reach, on top of what ``connections`` already grants, never
|
|
15
|
+
narrows it. A ``queries`` grant covers only that named saved query, never ad-hoc SQL (``POST
|
|
16
|
+
/execute_sql``) against whatever connection sits behind it - a key with no ``connections`` grant of its own
|
|
17
|
+
still cannot run arbitrary SQL, only the specific queries it was named for. See ``can_use_query()`` and
|
|
18
|
+
``app.run_saved()``.
|
|
19
|
+
|
|
20
|
+
A key's *secret* is never stored - only its SHA-256 hash, in ``api_keys.json`` (``QUERYAPIGATE_HOME``). The
|
|
21
|
+
plaintext value is generated by the server and returned exactly once, when the key is created; there is no
|
|
22
|
+
way to recover it afterwards, only to revoke it (``DELETE``) and create a new one.
|
|
23
|
+
|
|
24
|
+
A key can also carry an ``expires_at`` date (YYYY-MM-DD, day granularity - see ``_validate_expiry()``), for
|
|
25
|
+
time-boxed access (a trial integration, a partner engagement with a known end date) that expires on its own
|
|
26
|
+
without anyone having to remember to come back and revoke it. Checked live on every ``authenticate()`` call
|
|
27
|
+
- see ``is_expired()`` - not swept by a background job, so there is nothing to schedule or fail silently.
|
|
28
|
+
|
|
29
|
+
Every successful ``authenticate()`` match also records ``last_used_at`` on the key's stored entry, so an
|
|
30
|
+
admin can tell a stale key that has never actually been called apart from one that just hasn't been used
|
|
31
|
+
today - visibility, not a permission of its own. Throttled to once per ``_USE_RECORD_INTERVAL`` (see
|
|
32
|
+
``_record_use()``) rather than written on every single request, which for a busy key would turn every API
|
|
33
|
+
call into a disk write for no benefit - "was this key used in roughly the last minute" answers the actual
|
|
34
|
+
question ("is this key still alive") just as well as an exact timestamp would.
|
|
35
|
+
|
|
36
|
+
A key can also carry its own ``rate_limit`` (the same ``N/period`` grammar as ``QUERYAPIGATE_RATE_LIMIT`` - see
|
|
37
|
+
``config.parse_rate_limit()``), checked *in addition to* the server-wide, IP-based limit, never instead of
|
|
38
|
+
it - a per-key limit narrows what one caller may do, it is never a way to escape the ceiling every caller
|
|
39
|
+
already sits under. Unset means "no limit of this key's own, just the server-wide one" - the server-wide
|
|
40
|
+
limit being off does not imply a per-key one is pointless, since throttling one specific external caller is
|
|
41
|
+
a reasonable ask on its own. See ``app.check_key_rate_limit()`` and ``ratelimit.KeyRateLimiters``.
|
|
42
|
+
|
|
43
|
+
A key can also be pinned to an ``allowed_ips`` list of IP addresses or CIDR ranges (IPv4 or IPv6, mixed
|
|
44
|
+
freely) - real defense in depth for a key handed to an external party with known, stable infrastructure,
|
|
45
|
+
since even a leaked key then only authenticates from an expected address. Checked in ``authenticate()``
|
|
46
|
+
against the same client address ``QUERYAPIGATE_TRUST_PROXY``/``ProxyFix`` already establish as trustworthy for
|
|
47
|
+
rate limiting (see ``app.resolve_permission()``), not re-derived here. Unset (``None``) means no
|
|
48
|
+
restriction - today's behaviour, unchanged. This is about *who* may use a key at all, independent of
|
|
49
|
+
``rate_limit`` (#15, how much a caller who is allowed may do).
|
|
50
|
+
|
|
51
|
+
A key with write access can also be narrowed to specific ``allowed_write_ops`` - a list of SQL statement
|
|
52
|
+
keywords (``insert``, ``update``, ``delete``, ...) it may actually perform, rather than "any write" once
|
|
53
|
+
``allow_writes`` is on. Unset (``None``, the default) keeps today's behaviour: every write keyword equally
|
|
54
|
+
permitted. Checked in ``sqltools.validate_sql()`` alongside the existing read-only check - a read-only
|
|
55
|
+
statement is always allowed regardless of this list, since it only ever narrows *write* access, never reach
|
|
56
|
+
that already didn't exist. See ``engine.execute_sql()``.
|
|
57
|
+
|
|
58
|
+
A named **role** (``create_role()``/``update_role()``/``delete_role()``, stored separately in
|
|
59
|
+
``roles.json``) is a reusable *template* for the grant fields above (``connections``, ``allow_writes``,
|
|
60
|
+
``queries``, ``rate_limit``, ``allowed_ips``, ``allowed_write_ops``) - not a fourth kind of permission
|
|
61
|
+
object a key references at authentication time. ``create_key(..., role="reporting")`` copies that role's
|
|
62
|
+
fields onto the new key once, at creation; the key's own stored entry is the source of truth for every
|
|
63
|
+
authenticate() call from then on, exactly as if an admin had typed the same fields directly. Editing or
|
|
64
|
+
deleting a role afterward never touches a key already created from it - deliberately, so a role change
|
|
65
|
+
never has a blast radius across keys an admin cannot see without checking each one. A key still records
|
|
66
|
+
which role (if any) it was created from, in ``created_from_role`` - purely informational, never consulted
|
|
67
|
+
by ``authenticate()`` or any permission check. Combining ``role`` with an explicit grant field in the same
|
|
68
|
+
``create_key()`` call is rejected: create from the role, then ``update_key()`` afterward to customize.
|
|
69
|
+
"""
|
|
70
|
+
import hashlib
|
|
71
|
+
import hmac
|
|
72
|
+
import ipaddress
|
|
73
|
+
import json
|
|
74
|
+
import re
|
|
75
|
+
import secrets
|
|
76
|
+
import time
|
|
77
|
+
from collections import namedtuple
|
|
78
|
+
from datetime import datetime
|
|
79
|
+
|
|
80
|
+
from . import config, store
|
|
81
|
+
from .errors import ApiError
|
|
82
|
+
|
|
83
|
+
ALL_CONNECTIONS = '*'
|
|
84
|
+
ALL_QUERIES = '*'
|
|
85
|
+
_NAME_RE = re.compile(r'^[\w .\-]{1,100}$')
|
|
86
|
+
_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
|
87
|
+
_UNSET = object() # distinguishes "not passed to update_key()" from an explicit expires_at=None (clear it)
|
|
88
|
+
_USE_RECORD_INTERVAL = 60.0 # seconds between last_used_at writes for the same key - see _record_use()
|
|
89
|
+
_last_recorded_use: dict[str, float] = {} # key name -> time.monotonic() of the last last_used_at write
|
|
90
|
+
|
|
91
|
+
Permission = namedtuple('Permission', ['name', 'admin', 'connections', 'allow_writes', 'queries', 'rate_limit',
|
|
92
|
+
'allowed_write_ops'])
|
|
93
|
+
|
|
94
|
+
# The unrestricted caller used when the server has no key configured at all (QUERYAPIGATE_API_KEY unset and no
|
|
95
|
+
# scoped keys stored) - today's "open by design" behaviour, unchanged by this module. Its name is None (there
|
|
96
|
+
# is no key to name), unlike the admin key match below, which is named 'admin' so logs and /metrics can tell
|
|
97
|
+
# "no auth configured" apart from "authenticated as the admin key".
|
|
98
|
+
OPEN = Permission(name=None, admin=True, connections=ALL_CONNECTIONS, allow_writes=True, queries=ALL_QUERIES,
|
|
99
|
+
rate_limit=None, allowed_write_ops=None)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def can_use(permission, connection_name):
|
|
103
|
+
return permission.admin or permission.connections == ALL_CONNECTIONS or connection_name in permission.connections
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def can_use_query(permission, query_name):
|
|
107
|
+
"""Whether ``permission`` may run the saved query named ``query_name`` by virtue of its own ``queries``
|
|
108
|
+
grant specifically - independent of (and additive with) whatever ``connections`` already allows; see
|
|
109
|
+
the module docstring. A caller should still fall back to ``can_use()`` against the query's actual
|
|
110
|
+
connection when this returns False, exactly as for a query with no ``queries`` grant at all."""
|
|
111
|
+
return permission.admin or permission.queries == ALL_QUERIES or query_name in permission.queries
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def can_write_query(permission, query_name):
|
|
115
|
+
"""Whether ``permission``'s own ``queries`` grant specifically allows a *write* through ``query_name`` -
|
|
116
|
+
independent of (and additive with) ``allow_writes``/``connections``, the same way ``can_use_query()``
|
|
117
|
+
already is for read reach. Never true for the ``"*"`` wildcard, by design (see ``_normalize_queries()``):
|
|
118
|
+
a write grant must always be spelled out for the specific query it covers, never implied by a broader
|
|
119
|
+
read-access wildcard picked for unrelated reasons. A caller still has to OR this with the key's own
|
|
120
|
+
``allow_writes`` - this only ever adds reach for one named query, it never takes away the blanket grant
|
|
121
|
+
a key might already have."""
|
|
122
|
+
if permission.admin:
|
|
123
|
+
return True
|
|
124
|
+
if permission.queries == ALL_QUERIES:
|
|
125
|
+
return False
|
|
126
|
+
return bool(permission.queries.get(query_name))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _hash(secret):
|
|
130
|
+
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read():
|
|
134
|
+
try:
|
|
135
|
+
with open(config.api_keys_file(), 'r') as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
return {}
|
|
139
|
+
return data.get('keys', {})
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write(keys):
|
|
143
|
+
config.home().mkdir(parents=True, exist_ok=True)
|
|
144
|
+
store.write_json_atomic(config.api_keys_file(), {'keys': keys})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalize_connections(connections):
|
|
148
|
+
if connections in (None, ALL_CONNECTIONS):
|
|
149
|
+
return ALL_CONNECTIONS
|
|
150
|
+
if not isinstance(connections, list) or not all(isinstance(c, str) for c in connections):
|
|
151
|
+
raise ApiError(f'connections must be a list of connection names, or "{ALL_CONNECTIONS}" for all')
|
|
152
|
+
return sorted(set(connections))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _normalize_queries(queries):
|
|
156
|
+
"""Unlike connections, an *unspecified* value here (None - nothing supplied) normalises to no extra
|
|
157
|
+
grant at all (an empty list), not "every query": a freshly created key with no queries named should
|
|
158
|
+
not gain reach through this field, and a key stored before this field existed has nothing recorded for
|
|
159
|
+
it either - reading that back as [] leaves such a key's behaviour exactly as it was (queries are
|
|
160
|
+
additive on top of connections, so granting none here takes nothing away). An *explicit* "*"
|
|
161
|
+
is a real, different, deliberately broader grant an admin can still choose - "any saved query by name,
|
|
162
|
+
but never ad-hoc SQL" - and is preserved as such, not collapsed into the empty-list default. See the
|
|
163
|
+
module docstring and can_use_query().
|
|
164
|
+
|
|
165
|
+
A list entry is either a plain name (read access, the original shape - untouched, so an existing key's
|
|
166
|
+
stored file needs no migration) or ``{"name": ..., "allow_writes": true}`` (write access to that one
|
|
167
|
+
query specifically - see can_write_query()). There is deliberately no way to combine "*" with a write
|
|
168
|
+
grant: wanting write access to a specific query means enumerating the whole list explicitly, the same
|
|
169
|
+
explicit-opt-in shape ``allow_writes`` already has everywhere else in this project - resolved this way
|
|
170
|
+
precisely so a write grant can never hide behind a wildcard an admin picked for unrelated read access.
|
|
171
|
+
"""
|
|
172
|
+
if queries is None:
|
|
173
|
+
return []
|
|
174
|
+
if queries == ALL_QUERIES:
|
|
175
|
+
return ALL_QUERIES
|
|
176
|
+
if not isinstance(queries, list):
|
|
177
|
+
raise ApiError(f'queries must be a list of saved-query names, or "{ALL_QUERIES}" for all')
|
|
178
|
+
seen = set()
|
|
179
|
+
normalized = []
|
|
180
|
+
for entry in queries:
|
|
181
|
+
if isinstance(entry, str):
|
|
182
|
+
name, allow_writes = entry, False
|
|
183
|
+
elif isinstance(entry, dict):
|
|
184
|
+
name = entry.get('name')
|
|
185
|
+
if not isinstance(name, str):
|
|
186
|
+
raise ApiError('Each queries entry must be a saved-query name, or an object like '
|
|
187
|
+
'{"name": "...", "allow_writes": true}')
|
|
188
|
+
extra = set(entry) - {'name', 'allow_writes'}
|
|
189
|
+
if extra:
|
|
190
|
+
raise ApiError(f'Unexpected field(s) in a queries entry: {", ".join(sorted(extra))}')
|
|
191
|
+
allow_writes = bool(entry.get('allow_writes', False))
|
|
192
|
+
else:
|
|
193
|
+
raise ApiError('Each queries entry must be a saved-query name, or an object like '
|
|
194
|
+
'{"name": "...", "allow_writes": true}')
|
|
195
|
+
if name in seen:
|
|
196
|
+
raise ApiError(f'queries lists "{name}" more than once')
|
|
197
|
+
seen.add(name)
|
|
198
|
+
normalized.append({'name': name, 'allow_writes': True} if allow_writes else name)
|
|
199
|
+
normalized.sort(key=lambda e: e if isinstance(e, str) else e['name'])
|
|
200
|
+
return normalized
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _queries_map(queries):
|
|
204
|
+
"""The stored ``queries`` list (plain names and/or ``{"name", "allow_writes"}`` objects - see
|
|
205
|
+
_normalize_queries()) as a ``{name: allow_writes}`` dict for a resolved Permission - a plain-name entry
|
|
206
|
+
absent from a key stored before write curation existed reads back as ``allow_writes=False``, exactly its
|
|
207
|
+
original read-only-only meaning. Dict membership (``name in permission.queries``) behaves identically to
|
|
208
|
+
the frozenset this replaces for every existing read-only check (can_use_query()); can_write_query() is
|
|
209
|
+
the only caller that needs the value, not just the key."""
|
|
210
|
+
return {(e if isinstance(e, str) else e['name']): (False if isinstance(e, str) else e['allow_writes'])
|
|
211
|
+
for e in queries}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _validate_expiry(expires_at):
|
|
215
|
+
"""None means no expiry. Date-only (YYYY-MM-DD), not a full timestamp - the use case (a time-boxed
|
|
216
|
+
trial or partner engagement) only ever needs day granularity, and a bare date is what an admin picks
|
|
217
|
+
from a plain HTML date input with no timezone-conversion surprises to get wrong."""
|
|
218
|
+
if expires_at is None:
|
|
219
|
+
return None
|
|
220
|
+
if not isinstance(expires_at, str) or not _DATE_RE.match(expires_at):
|
|
221
|
+
raise ApiError('expires_at must be a date in YYYY-MM-DD format, or null for no expiry')
|
|
222
|
+
try:
|
|
223
|
+
datetime.strptime(expires_at, '%Y-%m-%d')
|
|
224
|
+
except ValueError:
|
|
225
|
+
raise ApiError('expires_at must be a valid calendar date') from None
|
|
226
|
+
return expires_at
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_rate_limit(rate_limit):
|
|
230
|
+
"""None means no per-key limit of its own (still subject to the server-wide one, unaffected). Reuses
|
|
231
|
+
config.parse_rate_limit()'s "N/period" grammar - one syntax to learn and validate, shared with
|
|
232
|
+
QUERYAPIGATE_RATE_LIMIT - rather than inventing a second format for the same concept."""
|
|
233
|
+
if rate_limit is None:
|
|
234
|
+
return None
|
|
235
|
+
if not isinstance(rate_limit, str):
|
|
236
|
+
raise ApiError("rate_limit must be a string like '100/minute', or null for none")
|
|
237
|
+
try:
|
|
238
|
+
config.parse_rate_limit(rate_limit, label='rate_limit')
|
|
239
|
+
except ValueError as error:
|
|
240
|
+
raise ApiError(str(error)) from None
|
|
241
|
+
return rate_limit
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _validate_allowed_ips(allowed_ips):
|
|
245
|
+
"""None means no restriction - a key matches from any client address (today's behaviour). Otherwise a
|
|
246
|
+
list of IP addresses or CIDR ranges (IPv4 or IPv6, mixed); the stdlib ipaddress module does the parsing
|
|
247
|
+
and later the containment check (_ip_allowed()), rather than a hand-rolled CIDR matcher - easy to get
|
|
248
|
+
subtly wrong by hand, and this needs no new dependency."""
|
|
249
|
+
if allowed_ips is None:
|
|
250
|
+
return None
|
|
251
|
+
if not isinstance(allowed_ips, list) or not all(isinstance(v, str) for v in allowed_ips):
|
|
252
|
+
raise ApiError('allowed_ips must be a list of IP addresses or CIDR ranges, or null for no restriction')
|
|
253
|
+
for value in allowed_ips:
|
|
254
|
+
try:
|
|
255
|
+
ipaddress.ip_network(value, strict=False)
|
|
256
|
+
except ValueError:
|
|
257
|
+
raise ApiError(f"'{value}' is not a valid IP address or CIDR range") from None
|
|
258
|
+
return sorted(set(allowed_ips))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _ip_allowed(entry, client_ip):
|
|
262
|
+
"""Whether client_ip satisfies entry's allowed_ips restriction - True when the key carries no
|
|
263
|
+
restriction at all (the common case), or when client_ip falls inside one of its listed addresses/CIDR
|
|
264
|
+
ranges. A missing or unparseable client_ip fails a *restricted* key closed rather than open: an address
|
|
265
|
+
QueryAPIGate could not resolve should not be treated as trusted just because it's unusual."""
|
|
266
|
+
allowed = entry.get('allowed_ips')
|
|
267
|
+
if not allowed:
|
|
268
|
+
return True
|
|
269
|
+
if not client_ip:
|
|
270
|
+
return False
|
|
271
|
+
try:
|
|
272
|
+
address = ipaddress.ip_address(client_ip)
|
|
273
|
+
except ValueError:
|
|
274
|
+
return False
|
|
275
|
+
return any(address in ipaddress.ip_network(value, strict=False) for value in allowed)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _validate_allowed_write_ops(allowed_write_ops):
|
|
279
|
+
"""None means no restriction - once allow_writes is on, any write keyword is permitted (today's
|
|
280
|
+
behaviour). Otherwise a list of SQL statement keywords (normalised to lowercase, e.g. "insert",
|
|
281
|
+
"delete"), checked against a write statement's own leading keyword by sqltools.validate_sql() - never
|
|
282
|
+
against a read-only one, which this never restricts. No fixed enum of valid keywords: the write-keyword
|
|
283
|
+
space is open-ended (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, MERGE, ...), so a name is
|
|
284
|
+
accepted as given rather than validated against a closed list."""
|
|
285
|
+
if allowed_write_ops is None:
|
|
286
|
+
return None
|
|
287
|
+
if not isinstance(allowed_write_ops, list) or not all(isinstance(v, str) and v for v in allowed_write_ops):
|
|
288
|
+
raise ApiError('allowed_write_ops must be a list of SQL keywords (e.g. "insert", "update"), or null '
|
|
289
|
+
'for no restriction')
|
|
290
|
+
return sorted({v.lower() for v in allowed_write_ops})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def is_expired(entry):
|
|
294
|
+
"""Whether a stored key entry's expires_at date has passed - checked live on every authenticate() call,
|
|
295
|
+
not swept by a background job: an expired key simply stops matching, the same way flipping `active` to
|
|
296
|
+
false already does, just automatic. Valid through the end of its expiry date (23:59:59), not the start
|
|
297
|
+
of it - "expires 2026-10-15" should keep working during the 15th, not from midnight that day."""
|
|
298
|
+
expires_at = entry.get('expires_at')
|
|
299
|
+
if not expires_at:
|
|
300
|
+
return False
|
|
301
|
+
try:
|
|
302
|
+
deadline = datetime.strptime(expires_at, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
|
303
|
+
except ValueError:
|
|
304
|
+
return False # a malformed stored value should not itself lock the key out
|
|
305
|
+
return datetime.now() > deadline
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _record_use(name):
|
|
309
|
+
"""Persist last_used_at for the key named ``name``, throttled to once per _USE_RECORD_INTERVAL - see
|
|
310
|
+
the module docstring. Re-reads under the lock immediately before writing, rather than reusing the
|
|
311
|
+
entry authenticate() already had in hand, so this never clobbers a concurrent admin change (a
|
|
312
|
+
connections/queries update, a revoke) with stale data - same discipline update_key()/delete_key()
|
|
313
|
+
already use. _last_recorded_use is in-process only: it resets on restart, which just means the first
|
|
314
|
+
request after one writes immediately - never a correctness problem, only ever slightly more eager."""
|
|
315
|
+
now = time.monotonic()
|
|
316
|
+
if now - _last_recorded_use.get(name, 0.0) < _USE_RECORD_INTERVAL:
|
|
317
|
+
return
|
|
318
|
+
_last_recorded_use[name] = now
|
|
319
|
+
with store.lock:
|
|
320
|
+
keys = _read()
|
|
321
|
+
if name in keys:
|
|
322
|
+
keys[name]['last_used_at'] = store.now()
|
|
323
|
+
_write(keys)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def any_configured():
|
|
327
|
+
"""True once at least one scoped key exists - QUERYAPIGATE_API_KEY is checked separately."""
|
|
328
|
+
return bool(_read())
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def auth_required():
|
|
332
|
+
return config.api_key() is not None or any_configured()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def list_keys():
|
|
336
|
+
"""Metadata only (name, connections, allow_writes, active, created_at) - never the hash."""
|
|
337
|
+
return {name: {k: v for k, v in entry.items() if k != 'hash'} for name, entry in _read().items()}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def create_key(name, connections=None, allow_writes=None, queries=None, expires_at=None, rate_limit=None,
|
|
341
|
+
allowed_ips=None, allowed_write_ops=None, role=None):
|
|
342
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
343
|
+
raise ApiError("API key name may only contain letters, digits, spaces, '.', '_' and '-'")
|
|
344
|
+
if role is not None:
|
|
345
|
+
given = [field for field, value in (('connections', connections), ('allow_writes', allow_writes),
|
|
346
|
+
('queries', queries), ('rate_limit', rate_limit),
|
|
347
|
+
('allowed_ips', allowed_ips),
|
|
348
|
+
('allowed_write_ops', allowed_write_ops)) if value is not None]
|
|
349
|
+
if given:
|
|
350
|
+
raise ApiError(f"Cannot combine 'role' with explicit {', '.join(given)} - create the key from "
|
|
351
|
+
"the role, then update it afterward to customize.")
|
|
352
|
+
role_entry = _get_role_or_404(role)
|
|
353
|
+
connections = role_entry['connections']
|
|
354
|
+
allow_writes = role_entry['allow_writes']
|
|
355
|
+
queries = role_entry['queries']
|
|
356
|
+
rate_limit = role_entry['rate_limit']
|
|
357
|
+
allowed_ips = role_entry['allowed_ips']
|
|
358
|
+
allowed_write_ops = role_entry['allowed_write_ops']
|
|
359
|
+
secret = 'sk_' + secrets.token_urlsafe(32)
|
|
360
|
+
with store.lock:
|
|
361
|
+
keys = _read()
|
|
362
|
+
if name in keys:
|
|
363
|
+
raise ApiError(f"An API key named '{name}' already exists")
|
|
364
|
+
keys[name] = {
|
|
365
|
+
'hash': _hash(secret),
|
|
366
|
+
'connections': _normalize_connections(connections),
|
|
367
|
+
'allow_writes': bool(allow_writes),
|
|
368
|
+
'queries': _normalize_queries(queries),
|
|
369
|
+
'expires_at': _validate_expiry(expires_at),
|
|
370
|
+
'rate_limit': _validate_rate_limit(rate_limit),
|
|
371
|
+
'allowed_ips': _validate_allowed_ips(allowed_ips),
|
|
372
|
+
'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
|
|
373
|
+
'created_from_role': role,
|
|
374
|
+
'active': True,
|
|
375
|
+
'created_at': store.now(),
|
|
376
|
+
}
|
|
377
|
+
_write(keys)
|
|
378
|
+
return secret
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def update_key(name, connections=None, allow_writes=None, active=None, queries=None, expires_at=_UNSET,
|
|
382
|
+
rate_limit=_UNSET, allowed_ips=_UNSET, allowed_write_ops=_UNSET):
|
|
383
|
+
with store.lock:
|
|
384
|
+
keys = _read()
|
|
385
|
+
if name not in keys:
|
|
386
|
+
raise ApiError(f"API key '{name}' not found", 404)
|
|
387
|
+
entry = keys[name]
|
|
388
|
+
if connections is not None:
|
|
389
|
+
entry['connections'] = _normalize_connections(connections)
|
|
390
|
+
if allow_writes is not None:
|
|
391
|
+
entry['allow_writes'] = bool(allow_writes)
|
|
392
|
+
if active is not None:
|
|
393
|
+
entry['active'] = bool(active)
|
|
394
|
+
if queries is not None:
|
|
395
|
+
entry['queries'] = _normalize_queries(queries)
|
|
396
|
+
if expires_at is not _UNSET: # None here is an explicit "clear the expiry", not "leave unchanged"
|
|
397
|
+
entry['expires_at'] = _validate_expiry(expires_at)
|
|
398
|
+
if rate_limit is not _UNSET: # same as expires_at: None here explicitly clears it
|
|
399
|
+
entry['rate_limit'] = _validate_rate_limit(rate_limit)
|
|
400
|
+
if allowed_ips is not _UNSET: # same as expires_at/rate_limit: None here explicitly clears it
|
|
401
|
+
entry['allowed_ips'] = _validate_allowed_ips(allowed_ips)
|
|
402
|
+
if allowed_write_ops is not _UNSET: # same as the others: None here explicitly clears it
|
|
403
|
+
entry['allowed_write_ops'] = _validate_allowed_write_ops(allowed_write_ops)
|
|
404
|
+
_write(keys)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def delete_key(name):
|
|
408
|
+
with store.lock:
|
|
409
|
+
keys = _read()
|
|
410
|
+
if name not in keys:
|
|
411
|
+
raise ApiError(f"API key '{name}' not found", 404)
|
|
412
|
+
del keys[name]
|
|
413
|
+
_write(keys)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# --------------------------------------------------------------------------------------
|
|
417
|
+
# Roles: reusable *templates* for the grant fields above, copied onto a key once at
|
|
418
|
+
# create_key(..., role=...) time - never consulted again afterward. See the module docstring.
|
|
419
|
+
# --------------------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def _read_roles():
|
|
422
|
+
try:
|
|
423
|
+
with open(config.roles_file(), 'r') as f:
|
|
424
|
+
data = json.load(f)
|
|
425
|
+
except FileNotFoundError:
|
|
426
|
+
return {}
|
|
427
|
+
return data.get('roles', {})
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _write_roles(roles):
|
|
431
|
+
config.home().mkdir(parents=True, exist_ok=True)
|
|
432
|
+
store.write_json_atomic(config.roles_file(), {'roles': roles})
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _get_role_or_404(name):
|
|
436
|
+
roles = _read_roles()
|
|
437
|
+
if name not in roles:
|
|
438
|
+
raise ApiError(f"Role '{name}' not found", 404)
|
|
439
|
+
return roles[name]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def list_roles():
|
|
443
|
+
return _read_roles()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def create_role(name, connections=None, allow_writes=False, queries=None, rate_limit=None, allowed_ips=None,
|
|
447
|
+
allowed_write_ops=None):
|
|
448
|
+
if not isinstance(name, str) or not _NAME_RE.match(name):
|
|
449
|
+
raise ApiError("Role name may only contain letters, digits, spaces, '.', '_' and '-'")
|
|
450
|
+
with store.lock:
|
|
451
|
+
roles = _read_roles()
|
|
452
|
+
if name in roles:
|
|
453
|
+
raise ApiError(f"A role named '{name}' already exists")
|
|
454
|
+
roles[name] = {
|
|
455
|
+
'connections': _normalize_connections(connections),
|
|
456
|
+
'allow_writes': bool(allow_writes),
|
|
457
|
+
'queries': _normalize_queries(queries),
|
|
458
|
+
'rate_limit': _validate_rate_limit(rate_limit),
|
|
459
|
+
'allowed_ips': _validate_allowed_ips(allowed_ips),
|
|
460
|
+
'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
|
|
461
|
+
'created_at': store.now(),
|
|
462
|
+
}
|
|
463
|
+
_write_roles(roles)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def update_role(name, connections=None, allow_writes=None, queries=None, rate_limit=_UNSET, allowed_ips=_UNSET,
|
|
467
|
+
allowed_write_ops=_UNSET):
|
|
468
|
+
with store.lock:
|
|
469
|
+
roles = _read_roles()
|
|
470
|
+
if name not in roles:
|
|
471
|
+
raise ApiError(f"Role '{name}' not found", 404)
|
|
472
|
+
entry = roles[name]
|
|
473
|
+
if connections is not None:
|
|
474
|
+
entry['connections'] = _normalize_connections(connections)
|
|
475
|
+
if allow_writes is not None:
|
|
476
|
+
entry['allow_writes'] = bool(allow_writes)
|
|
477
|
+
if queries is not None:
|
|
478
|
+
entry['queries'] = _normalize_queries(queries)
|
|
479
|
+
if rate_limit is not _UNSET:
|
|
480
|
+
entry['rate_limit'] = _validate_rate_limit(rate_limit)
|
|
481
|
+
if allowed_ips is not _UNSET:
|
|
482
|
+
entry['allowed_ips'] = _validate_allowed_ips(allowed_ips)
|
|
483
|
+
if allowed_write_ops is not _UNSET:
|
|
484
|
+
entry['allowed_write_ops'] = _validate_allowed_write_ops(allowed_write_ops)
|
|
485
|
+
_write_roles(roles)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def delete_role(name):
|
|
489
|
+
with store.lock:
|
|
490
|
+
roles = _read_roles()
|
|
491
|
+
if name not in roles:
|
|
492
|
+
raise ApiError(f"Role '{name}' not found", 404)
|
|
493
|
+
del roles[name]
|
|
494
|
+
_write_roles(roles)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _parsed_rate_limit(entry):
|
|
498
|
+
"""The stored rate_limit string, parsed to (count, period) once here rather than re-parsed by every
|
|
499
|
+
caller - or None, for no per-key limit (unset) or a malformed stored value, which should not itself
|
|
500
|
+
break authentication any more than a malformed expires_at should (see is_expired())."""
|
|
501
|
+
raw = entry.get('rate_limit')
|
|
502
|
+
if not raw:
|
|
503
|
+
return None
|
|
504
|
+
try:
|
|
505
|
+
return config.parse_rate_limit(raw, label='rate_limit')
|
|
506
|
+
except ValueError:
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def authenticate(supplied, client_ip=None):
|
|
511
|
+
"""Resolve the ``X-API-Key`` header value to a Permission, or None if it matches no key at all (wrong
|
|
512
|
+
secret, or a right one that's inactive, expired, or used from outside its allowed_ips - all three fail
|
|
513
|
+
the same way as a plain wrong key, not a distinct error, so a caller learns nothing about *why* a
|
|
514
|
+
supplied key didn't work). ``client_ip`` is only used to check a scoped key's own allowed_ips
|
|
515
|
+
restriction, if it has one; the admin key is never restricted by it."""
|
|
516
|
+
if not supplied:
|
|
517
|
+
return None
|
|
518
|
+
expected = config.api_key()
|
|
519
|
+
if expected and hmac.compare_digest(supplied.encode('utf-8', 'replace'), expected.encode('utf-8')):
|
|
520
|
+
return Permission(name='admin', admin=True, connections=ALL_CONNECTIONS, allow_writes=True,
|
|
521
|
+
queries=ALL_QUERIES, rate_limit=None, allowed_write_ops=None)
|
|
522
|
+
supplied_hash = _hash(supplied)
|
|
523
|
+
for name, entry in _read().items():
|
|
524
|
+
if entry.get('active', True) and not is_expired(entry) and _ip_allowed(entry, client_ip) \
|
|
525
|
+
and hmac.compare_digest(supplied_hash, entry['hash']):
|
|
526
|
+
_record_use(name)
|
|
527
|
+
connections = entry['connections']
|
|
528
|
+
queries = entry.get('queries', []) # absent on a key stored before this field existed
|
|
529
|
+
return Permission(name=name, admin=False,
|
|
530
|
+
connections=connections if connections == ALL_CONNECTIONS else frozenset(connections),
|
|
531
|
+
allow_writes=bool(entry.get('allow_writes', False)),
|
|
532
|
+
queries=queries if queries == ALL_QUERIES else _queries_map(queries),
|
|
533
|
+
rate_limit=_parsed_rate_limit(entry),
|
|
534
|
+
allowed_write_ops=entry.get('allowed_write_ops'))
|
|
535
|
+
return None
|