velocity-python 0.3.0__py3-none-any.whl → 0.3.2__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.
- velocity/__init__.py +1 -1
- velocity/aws/capabilities/security.py +118 -43
- velocity/aws/handlers/lambda_handler.py +12 -4
- velocity/aws/settings.py +1104 -0
- velocity/db/audit.py +9 -0
- velocity/db/core/engine.py +35 -9
- velocity/db/core/transaction.py +16 -2
- {velocity_python-0.3.0.dist-info → velocity_python-0.3.2.dist-info}/METADATA +1 -1
- {velocity_python-0.3.0.dist-info → velocity_python-0.3.2.dist-info}/RECORD +13 -12
- {velocity_python-0.3.0.dist-info → velocity_python-0.3.2.dist-info}/WHEEL +0 -0
- {velocity_python-0.3.0.dist-info → velocity_python-0.3.2.dist-info}/entry_points.txt +0 -0
- {velocity_python-0.3.0.dist-info → velocity_python-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.3.0.dist-info → velocity_python-0.3.2.dist-info}/top_level.txt +0 -0
velocity/__init__.py
CHANGED
|
@@ -19,7 +19,8 @@ rate limits, payload caps, and an audit trail of what was refused and why.
|
|
|
19
19
|
Attach :class:`SecurityMixin` FIRST in a handler's bases; it runs in
|
|
20
20
|
``beforeAction`` ahead of auth and every action, and is a no-op unless the
|
|
21
21
|
site's ``sys_capabilities`` row for ``security`` is enabled. A refusal raises
|
|
22
|
-
:class:`AccessError` (a 403) after
|
|
22
|
+
:class:`AccessError` (a 403) after recording a ``sys_security_events`` row
|
|
23
|
+
(committed on its own transaction, so the 403's rollback keeps it).
|
|
23
24
|
|
|
24
25
|
``sys_security_events`` itself is a CORE table (``SCHEMA``, in
|
|
25
26
|
``velocity.aws.schema``'s ``core`` group), not the capability's: it is
|
|
@@ -39,6 +40,7 @@ import logging
|
|
|
39
40
|
|
|
40
41
|
from velocity.aws import feature_flags
|
|
41
42
|
from velocity.cache import memo, read_cache
|
|
43
|
+
from velocity.db import _pgddl, audit
|
|
42
44
|
|
|
43
45
|
from .base import (
|
|
44
46
|
CIDR_LIST,
|
|
@@ -314,7 +316,13 @@ def enforce(tx, aws_event, context, *, now=None):
|
|
|
314
316
|
guard, reason = refusal
|
|
315
317
|
if truthy(config.get("security_block_logging_enabled")):
|
|
316
318
|
record_event(
|
|
317
|
-
tx,
|
|
319
|
+
tx,
|
|
320
|
+
guard,
|
|
321
|
+
reason,
|
|
322
|
+
aws_event=aws_event,
|
|
323
|
+
context=context,
|
|
324
|
+
user_email=user,
|
|
325
|
+
actor=GUARD_AUDIT_ACTOR,
|
|
318
326
|
)
|
|
319
327
|
log.warning("security guard %s refused %s: %s", guard, facts["ip"], reason)
|
|
320
328
|
# imported here, not at module top: the capabilities package must not
|
|
@@ -326,6 +334,15 @@ def enforce(tx, aws_event, context, *, now=None):
|
|
|
326
334
|
return config
|
|
327
335
|
|
|
328
336
|
|
|
337
|
+
# A second connection waiting on a lock the caller's own transaction holds
|
|
338
|
+
# would wait forever (the database can't see it as a deadlock): bound it, so
|
|
339
|
+
# the event fails (and an audited decision is refused) instead of hanging.
|
|
340
|
+
EVENT_LOCK_TIMEOUT = "5s"
|
|
341
|
+
# The audit actor of a guard refusal: it is recorded in beforeAction, before
|
|
342
|
+
# any request actor is set.
|
|
343
|
+
GUARD_AUDIT_ACTOR = "system:security_guard"
|
|
344
|
+
|
|
345
|
+
|
|
329
346
|
def record_event(
|
|
330
347
|
tx,
|
|
331
348
|
guard,
|
|
@@ -337,58 +354,116 @@ def record_event(
|
|
|
337
354
|
user_sub=None,
|
|
338
355
|
detail=None,
|
|
339
356
|
occurred_at=None,
|
|
357
|
+
actor=None,
|
|
340
358
|
):
|
|
341
|
-
"""Append one ``sys_security_events`` row
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
359
|
+
"""Append one ``sys_security_events`` row on its own short transaction.
|
|
360
|
+
The one audit sink for the framework: the request guards, masquerade
|
|
361
|
+
grants, sign-ins, privileged decisions (``authz.require``), and anything
|
|
362
|
+
else security-relevant write here. ``user_sub`` is the identity provider's
|
|
363
|
+
subject; ``detail`` a JSON-able dict of facts that have no column;
|
|
364
|
+
``occurred_at`` when it happened, if not now (an event relayed through a
|
|
365
|
+
queue). Silent when the table is not provisioned in this site, and an
|
|
366
|
+
audit failure never breaks the request. Returns True only once the row is
|
|
367
|
+
committed.
|
|
368
|
+
|
|
369
|
+
``tx`` is the caller's transaction and is only used for its engine: the
|
|
370
|
+
row is written and committed through ``tx.engine.side_transaction()``
|
|
371
|
+
(another connection, which never waits for the pool), so it survives whatever the caller does next (raise,
|
|
372
|
+
roll back) and the caller's transaction is left exactly as it was: its
|
|
373
|
+
writes stay uncommitted, and its ``SET LOCAL`` settings (the audit actor),
|
|
374
|
+
row locks and transaction-level advisory locks are kept. A ``tx`` with no
|
|
375
|
+
engine (a test double) gets the row written on itself, uncommitted. On
|
|
376
|
+
PostgreSQL the event transaction's audit actor (``velocity.actor``, for a
|
|
377
|
+
``sys_security_events`` under the audit trigger's ``require_actor``) is
|
|
378
|
+
``actor`` when given, else the one the caller set with
|
|
379
|
+
:func:`velocity.db.audit.set_audit_actor`. Callers that record before any
|
|
380
|
+
actor is set (the request guards, the masquerade grant) pass their own.
|
|
350
381
|
|
|
351
382
|
A table created before ``user_sub``/``detail`` existed (a site whose schema
|
|
352
383
|
lane has not run since) still records the row without them: on a locked
|
|
353
384
|
schema an unknown column would otherwise lose the whole event."""
|
|
385
|
+
facts = dict(
|
|
386
|
+
aws_event=aws_event,
|
|
387
|
+
context=context,
|
|
388
|
+
user_email=user_email,
|
|
389
|
+
user_sub=user_sub,
|
|
390
|
+
detail=detail,
|
|
391
|
+
occurred_at=occurred_at,
|
|
392
|
+
)
|
|
354
393
|
try:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
try:
|
|
377
|
-
have = {c.lower() for c in table.sys_columns()}
|
|
378
|
-
except Exception:
|
|
379
|
-
# can't tell which newer columns exist: the event still lands
|
|
380
|
-
# with the columns every version of the table has
|
|
381
|
-
log.warning("security event %s: columns unknown", guard, exc_info=True)
|
|
382
|
-
have = set()
|
|
383
|
-
row.update({k: v for k, v in extra.items() if k in have})
|
|
384
|
-
table.insert(row)
|
|
385
|
-
tx.commit()
|
|
386
|
-
return True
|
|
394
|
+
open_transaction = getattr(
|
|
395
|
+
getattr(tx, "engine", None), "side_transaction", None
|
|
396
|
+
)
|
|
397
|
+
if not callable(open_transaction):
|
|
398
|
+
return _write_event(tx, guard, reason, **facts)
|
|
399
|
+
postgres = "postgres" in str(_pgddl.server_name(tx) or "").lower()
|
|
400
|
+
if postgres and not actor:
|
|
401
|
+
actor = _caller_actor(tx)
|
|
402
|
+
# the row is committed when the block exits; a commit failure raises
|
|
403
|
+
# out of it, so True is never returned for a row that isn't durable
|
|
404
|
+
with open_transaction() as event_tx:
|
|
405
|
+
if postgres:
|
|
406
|
+
# one round trip: the bounded lock wait, and the caller's audit
|
|
407
|
+
# actor (a sys_security_events under the audit trigger's
|
|
408
|
+
# require_actor still records)
|
|
409
|
+
event_tx.execute(
|
|
410
|
+
"SELECT set_config('lock_timeout', %s, true), "
|
|
411
|
+
"set_config(%s, %s, true)",
|
|
412
|
+
(EVENT_LOCK_TIMEOUT, audit.ACTOR_SETTING, actor or ""),
|
|
413
|
+
)
|
|
414
|
+
return _write_event(event_tx, guard, reason, **facts)
|
|
387
415
|
except Exception:
|
|
388
416
|
log.warning("security event %s not recorded", guard, exc_info=True)
|
|
389
417
|
return False
|
|
390
418
|
|
|
391
419
|
|
|
420
|
+
def _caller_actor(tx):
|
|
421
|
+
"""The audit actor :func:`velocity.db.audit.set_audit_actor` set on the
|
|
422
|
+
caller's transaction, or None. Read from the transaction object only:
|
|
423
|
+
running a statement on the caller's connection here could hit a dead
|
|
424
|
+
connection, and swallowing that error would lose the caller's
|
|
425
|
+
mid-transaction failure (R31) — its next write would land on a fresh
|
|
426
|
+
connection."""
|
|
427
|
+
value = getattr(tx, "_audit_actor", None)
|
|
428
|
+
return value if isinstance(value, str) and value else None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _write_event(
|
|
432
|
+
tx, guard, reason, *, aws_event, context, user_email, user_sub, detail, occurred_at
|
|
433
|
+
):
|
|
434
|
+
table = tx.table(EVENTS_TABLE)
|
|
435
|
+
if not table.exists():
|
|
436
|
+
return False
|
|
437
|
+
facts = request_facts(aws_event)
|
|
438
|
+
row = {
|
|
439
|
+
"guard": guard,
|
|
440
|
+
"reason": reason,
|
|
441
|
+
"ip": facts["ip"],
|
|
442
|
+
"country": facts["country"],
|
|
443
|
+
"path": facts["path"],
|
|
444
|
+
"action": _action(context) if context is not None else "",
|
|
445
|
+
"user_email": user_email,
|
|
446
|
+
"user_agent": facts["user_agent"][:512],
|
|
447
|
+
}
|
|
448
|
+
extra = {
|
|
449
|
+
"user_sub": user_sub,
|
|
450
|
+
"detail": detail,
|
|
451
|
+
"occurred_at": occurred_at or datetime.datetime.now(datetime.timezone.utc),
|
|
452
|
+
}
|
|
453
|
+
extra = {k: v for k, v in extra.items() if v is not None}
|
|
454
|
+
if extra:
|
|
455
|
+
try:
|
|
456
|
+
have = {c.lower() for c in table.sys_columns()}
|
|
457
|
+
except Exception:
|
|
458
|
+
# can't tell which newer columns exist: the event still lands
|
|
459
|
+
# with the columns every version of the table has
|
|
460
|
+
log.warning("security event %s: columns unknown", guard, exc_info=True)
|
|
461
|
+
have = set()
|
|
462
|
+
row.update({k: v for k, v in extra.items() if k in have})
|
|
463
|
+
table.insert(row)
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
|
|
392
467
|
def _user_email(context):
|
|
393
468
|
try:
|
|
394
469
|
session = context.session() or {}
|
|
@@ -8,7 +8,7 @@ from typing import Optional
|
|
|
8
8
|
|
|
9
9
|
from velocity.aws import ssm_config
|
|
10
10
|
from velocity.aws.handlers.base_handler import BaseHandler
|
|
11
|
-
from velocity.aws.handlers.exceptions import UserNotProvisionedError
|
|
11
|
+
from velocity.aws.handlers.exceptions import AccessError, UserNotProvisionedError
|
|
12
12
|
from velocity.aws.handlers.context_factory import ContextFactory
|
|
13
13
|
from velocity.aws.handlers.response import Response
|
|
14
14
|
from velocity.logging import configure_logging
|
|
@@ -116,11 +116,12 @@ class LambdaHandler(BaseHandler):
|
|
|
116
116
|
)
|
|
117
117
|
# Persist the audit the docstring promises: who acted as whom,
|
|
118
118
|
# from where. Goes to the framework's one security audit sink
|
|
119
|
-
# (sys_security_events, the Security Events page)
|
|
120
|
-
#
|
|
119
|
+
# (sys_security_events, the Security Events page), on its own
|
|
120
|
+
# transaction: this request's tx stays open. Fail-closed, as
|
|
121
|
+
# authz.require is: no audit row, no masquerade.
|
|
121
122
|
from velocity.aws.capabilities.security import record_event
|
|
122
123
|
|
|
123
|
-
record_event(
|
|
124
|
+
recorded = record_event(
|
|
124
125
|
tx,
|
|
125
126
|
"masquerade",
|
|
126
127
|
f"{grant['real_user']} acting as {grant['effective_user']} "
|
|
@@ -128,7 +129,14 @@ class LambdaHandler(BaseHandler):
|
|
|
128
129
|
aws_event=self.aws_event,
|
|
129
130
|
context=context,
|
|
130
131
|
user_email=grant["real_user"],
|
|
132
|
+
# recorded before any request actor is set; the actor
|
|
133
|
+
# format AuditTrailMixin uses under masquerade
|
|
134
|
+
actor=f"{grant['effective_user']} as {grant['real_user']}",
|
|
131
135
|
)
|
|
136
|
+
if not recorded:
|
|
137
|
+
raise AccessError(
|
|
138
|
+
"That masquerade could not be recorded, so it was refused."
|
|
139
|
+
)
|
|
132
140
|
|
|
133
141
|
if masquerade or auth_mode == "none":
|
|
134
142
|
# In masquerade mode the caller's own Cognito token is for a
|