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.
@@ -0,0 +1,6 @@
1
+ """QueryAPIGate - expose SQL databases as a REST API."""
2
+ __version__ = '0.5.0'
3
+
4
+ from .app import create_app # noqa: E402 (app imports __version__)
5
+
6
+ __all__ = ['create_app', '__version__']
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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