velocity-python 0.2.1__py3-none-any.whl → 0.2.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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.2.1"
1
+ __version__ = version = "0.2.2"
2
2
 
3
3
  import importlib as _importlib
4
4
 
velocity/aws/authz.py ADDED
@@ -0,0 +1,428 @@
1
+ """Who may do what, decided in one place.
2
+
3
+ One pure function answers every authorization question::
4
+
5
+ can(principal, action, resource, context) -> Decision
6
+
7
+ * **Deny by default.** Nothing is allowed unless a rule permits it.
8
+ * **A forbid beats any permit.**
9
+ * **An error can only take access away.** A permit rule whose condition
10
+ raises is skipped; a forbid rule whose condition raises forbids. Either
11
+ way the rule is named in ``Decision.errors`` and logged.
12
+ * **No I/O.** A decision is id comparisons and set lookups. Whoever already
13
+ loaded the row passes its owner in; this module never loads data.
14
+
15
+ Rules are data, declared beside the feature they govern and registered once
16
+ at import::
17
+
18
+ from velocity.aws import authz
19
+
20
+ RULES = (
21
+ authz.permit("notes.own", {"notes.read", "notes.write"}, when=authz.owner),
22
+ authz.permit("notes.read_any", "notes.read",
23
+ when=authz.holds("notes.read_any"), audit=True),
24
+ authz.forbid("notes.masquerade_write", "notes.write",
25
+ when=authz.masquerading),
26
+ )
27
+ authz.register(*RULES)
28
+
29
+ Permissions are plain names a principal holds. Code never checks a role:
30
+ a role is a bundle of permissions, and which bundle a user gets is the
31
+ site's decision (``principal_permissions`` on the handler).
32
+
33
+ **On a handler.** :func:`principal_for` builds the principal from the user
34
+ row the handler already loaded; :func:`decide` asks; :func:`require` asks,
35
+ refuses with a 403, and records the use of any rule marked ``audit`` in
36
+ ``sys_security_events`` *before* the access happens (no audit row, no
37
+ access). The handler attributes they read:
38
+
39
+ ========================== ================================================
40
+ ``authz_mode`` ``"off"`` (default), ``"log"`` or ``"enforce"``:
41
+ how the *existing* generic surfaces (the
42
+ DataService table gate) use the decision. New
43
+ actions written against :func:`require` are
44
+ always enforced.
45
+ ``authz_rules`` a handler's own rules, beside the registered ones
46
+ ``principal_permissions`` ``(current_user) -> iterable of names``; the
47
+ default grants none
48
+ ========================== ================================================
49
+
50
+ The request shape and the decision rule are those of AWS's Cedar policy
51
+ language, on purpose: the rules can move there without changing a caller.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import logging
57
+ from dataclasses import dataclass, field
58
+ from fnmatch import fnmatchcase
59
+ from typing import Any, Callable, Optional
60
+
61
+ from velocity.aws import ssm_config
62
+ from velocity.aws.handlers.exceptions import AccessError
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ PERMIT = "permit"
67
+ FORBID = "forbid"
68
+
69
+ OFF = "off"
70
+ LOG = "log"
71
+ ENFORCE = "enforce"
72
+ MODES = (OFF, LOG, ENFORCE)
73
+
74
+ # The action the DataService table gate asks about, and its resource type.
75
+ DATA_ACCESS = "data.access"
76
+ TABLE = "table"
77
+ SYSTEM_PREFIX = "sys_"
78
+
79
+ # The audit sink's ``guard`` value for a use of an ``audit`` rule.
80
+ PRIVILEGED_ACCESS = "privileged_access"
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Principal:
85
+ """Who is asking. ``user_sys_id`` is the row id in the site's user table
86
+ (never an email). ``real_user`` is set in a masquerade session: the
87
+ administrator who is acting as this user."""
88
+
89
+ user_sys_id: Optional[int] = None
90
+ permissions: frozenset = frozenset()
91
+ tenant: Any = None
92
+ real_user: Any = None
93
+
94
+ def holds(self, permission):
95
+ return permission in self.permissions
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Resource:
100
+ """The specific object: its type, id, owner (a user row id) and tenant."""
101
+
102
+ type: str
103
+ id: Any = None
104
+ owner: Optional[int] = None
105
+ tenant: Any = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Rule:
110
+ name: str
111
+ effect: str
112
+ actions: frozenset
113
+ when: Optional[Callable] = None
114
+ audit: bool = False
115
+
116
+ def covers(self, action):
117
+ return any(
118
+ action == pattern or ("*" in pattern and fnmatchcase(action, pattern))
119
+ for pattern in self.actions
120
+ )
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Decision:
125
+ """The answer. ``rule`` is the rule that decided (``None`` when nothing
126
+ permitted); ``audit`` is true when that rule's use must be recorded."""
127
+
128
+ allowed: bool
129
+ action: str
130
+ rule: Optional[str] = None
131
+ audit: bool = False
132
+ errors: tuple = field(default_factory=tuple)
133
+
134
+ def __bool__(self):
135
+ return self.allowed
136
+
137
+
138
+ def _rule(effect, name, actions, when, audit):
139
+ if not isinstance(name, str) or not name:
140
+ raise ValueError("A rule needs a name.")
141
+ if isinstance(actions, str):
142
+ actions = (actions,)
143
+ actions = frozenset(actions or ())
144
+ if not actions or not all(isinstance(a, str) and a for a in actions):
145
+ raise ValueError(f"Rule '{name}' names no action.")
146
+ if when is not None and not callable(when):
147
+ raise ValueError(f"Rule '{name}': 'when' must be callable.")
148
+ return Rule(name, effect, actions, when, bool(audit))
149
+
150
+
151
+ def permit(name, actions, *, when=None, audit=False):
152
+ """Allow ``actions`` (a name, or names; ``*`` is a wildcard) when
153
+ ``when(principal, action, resource, context)`` is true. ``audit=True``
154
+ marks a privileged rule: each use is recorded."""
155
+ return _rule(PERMIT, name, actions, when, audit)
156
+
157
+
158
+ def forbid(name, actions, *, when=None):
159
+ """Refuse ``actions`` when ``when(...)`` is true, whatever permits."""
160
+ return _rule(FORBID, name, actions, when, False)
161
+
162
+
163
+ # ── conditions ─────────────────────────────────────────────────────────────
164
+
165
+
166
+ def owner(principal, action, resource, context):
167
+ """The resource belongs to the principal."""
168
+ return (
169
+ principal.user_sys_id is not None
170
+ and resource is not None
171
+ and resource.owner is not None
172
+ and resource.owner == principal.user_sys_id
173
+ )
174
+
175
+
176
+ def masquerading(principal, action, resource, context):
177
+ """An administrator is acting as this user."""
178
+ return principal.real_user is not None or bool((context or {}).get("masquerade"))
179
+
180
+
181
+ def signed_in(principal, action, resource, context):
182
+ return principal.user_sys_id is not None
183
+
184
+
185
+ def holds(permission):
186
+ """The principal holds the named permission."""
187
+
188
+ def condition(principal, action, resource, context):
189
+ return principal.holds(permission)
190
+
191
+ return condition
192
+
193
+
194
+ def table(*names):
195
+ """The resource is one of these tables."""
196
+ wanted = frozenset(names)
197
+
198
+ def condition(principal, action, resource, context):
199
+ return resource is not None and resource.type == TABLE and resource.id in wanted
200
+
201
+ return condition
202
+
203
+
204
+ def all_of(*conditions):
205
+ def condition(principal, action, resource, context):
206
+ return all(c(principal, action, resource, context) for c in conditions)
207
+
208
+ return condition
209
+
210
+
211
+ def any_of(*conditions):
212
+ def condition(principal, action, resource, context):
213
+ return any(c(principal, action, resource, context) for c in conditions)
214
+
215
+ return condition
216
+
217
+
218
+ def _ordinary_table(principal, action, resource, context):
219
+ return (
220
+ resource is not None
221
+ and resource.type == TABLE
222
+ and isinstance(resource.id, str)
223
+ and not resource.id.startswith(SYSTEM_PREFIX)
224
+ )
225
+
226
+
227
+ # ── the registry ───────────────────────────────────────────────────────────
228
+
229
+ # Until each site declares rules per table, an ordinary table stays reachable
230
+ # through the generic Data actions exactly as the table gate, the rwx hooks
231
+ # and the tenant scope already allow. System tables (``sys_…``) get no such
232
+ # rule: they are refused unless the handler declares one.
233
+ BUILTIN_RULES = (permit("data.ordinary_tables", DATA_ACCESS, when=_ordinary_table),)
234
+
235
+ _REGISTERED = {}
236
+
237
+
238
+ def register(*rules):
239
+ """Add rules to the ones every handler evaluates. Registering the same
240
+ rule again is a no-op; a different rule under a taken name is an error."""
241
+ for rule in rules:
242
+ if not isinstance(rule, Rule):
243
+ raise ValueError("register() takes rules made by permit() or forbid().")
244
+ known = _REGISTERED.get(rule.name)
245
+ if known is not None and known != rule:
246
+ raise ValueError(f"An authorization rule named '{rule.name}' exists.")
247
+ _REGISTERED[rule.name] = rule
248
+
249
+
250
+ def registered():
251
+ """Every rule a handler evaluates before its own."""
252
+ return BUILTIN_RULES + tuple(_REGISTERED.values())
253
+
254
+
255
+ # ── the decision ───────────────────────────────────────────────────────────
256
+
257
+
258
+ def can(principal, action, resource=None, context=None, *, rules=None):
259
+ """Decide. See the module docstring for the rule."""
260
+ rules = registered() if rules is None else tuple(rules)
261
+ context = context or {}
262
+ permitted = None
263
+ errors = []
264
+ if not isinstance(principal, Principal) or not isinstance(action, str):
265
+ return Decision(False, str(action))
266
+ for rule in rules:
267
+ if not rule.covers(action):
268
+ continue
269
+ try:
270
+ matched = rule.when is None or bool(
271
+ rule.when(principal, action, resource, context)
272
+ )
273
+ except Exception:
274
+ logger.warning(
275
+ "authorization rule %s failed on %s", rule.name, action, exc_info=True
276
+ )
277
+ errors.append(rule.name)
278
+ # an error can only take access away
279
+ matched = rule.effect == FORBID
280
+ if not matched:
281
+ continue
282
+ if rule.effect == FORBID:
283
+ return Decision(False, action, rule.name, False, tuple(errors))
284
+ if permitted is None:
285
+ permitted = rule
286
+ if permitted is None:
287
+ return Decision(False, action, None, False, tuple(errors))
288
+ return Decision(True, action, permitted.name, permitted.audit, tuple(errors))
289
+
290
+
291
+ # ── on a handler ───────────────────────────────────────────────────────────
292
+
293
+
294
+ def mode_of(handler):
295
+ """``AuthzMode`` config first, then the handler's ``authz_mode``. Blank
296
+ is ``off``; a value that is not a mode is ``enforce`` (fail closed)."""
297
+ raw = None
298
+ try:
299
+ raw = ssm_config.getenv("AuthzMode", None)
300
+ except Exception:
301
+ raw = None
302
+ raw = str(raw or getattr(handler, "authz_mode", None) or OFF).strip().lower()
303
+ return raw if raw in MODES else ENFORCE
304
+
305
+
306
+ def _session(context):
307
+ try:
308
+ return context.session() or {}
309
+ except Exception:
310
+ return {}
311
+
312
+
313
+ def principal_for(handler, context=None):
314
+ """The principal for the request ``handler`` is serving, from the user
315
+ row it already loaded. No query."""
316
+ user = getattr(handler, "current_user", None) or {}
317
+ try:
318
+ user_sys_id = int(user.get("sys_id"))
319
+ except (TypeError, ValueError, AttributeError):
320
+ user_sys_id = None
321
+ if user_sys_id is not None and user_sys_id <= 0:
322
+ user_sys_id = None
323
+ permissions = ()
324
+ hook = getattr(handler, "principal_permissions", None)
325
+ if callable(hook) and user_sys_id is not None:
326
+ try:
327
+ permissions = hook(user) or ()
328
+ except Exception:
329
+ # no permissions is the safe answer
330
+ logger.warning("principal_permissions failed", exc_info=True)
331
+ permissions = ()
332
+ context = context if context is not None else getattr(handler, "context", None)
333
+ masquerade = _session(context).get("masquerade") if context is not None else None
334
+ real_user = None
335
+ if masquerade:
336
+ real_user = (
337
+ masquerade.get("real_user") if isinstance(masquerade, dict) else None
338
+ ) or True
339
+ return Principal(
340
+ user_sys_id=user_sys_id,
341
+ permissions=frozenset(p for p in permissions if isinstance(p, str)),
342
+ tenant=user.get("tenant") if isinstance(user, dict) else None,
343
+ real_user=real_user,
344
+ )
345
+
346
+
347
+ def rules_for(handler):
348
+ return registered() + tuple(getattr(handler, "authz_rules", None) or ())
349
+
350
+
351
+ def decide(handler, action, resource=None, context=None):
352
+ """The decision for the request ``handler`` is serving."""
353
+ principal = principal_for(handler, context)
354
+ facts = {"masquerade": principal.real_user is not None}
355
+ return can(principal, action, resource, facts, rules=rules_for(handler))
356
+
357
+
358
+ def require(handler, tx, context, action, resource=None):
359
+ """Decide and enforce, whatever ``authz_mode`` says: for actions written
360
+ against the policy. A denial raises :class:`AccessError` (403). A use of
361
+ an ``audit`` rule is recorded first, and refused when it cannot be."""
362
+ decision = decide(handler, action, resource, context)
363
+ if not decision.allowed:
364
+ logger.warning(
365
+ "authorization refused %s on %s (%s)",
366
+ action,
367
+ _describe(resource),
368
+ decision.rule or "no rule permits it",
369
+ )
370
+ raise AccessError("You do not have access to that.")
371
+ if decision.audit and not _record(handler, tx, context, decision, resource):
372
+ raise AccessError("That access could not be recorded, so it was refused.")
373
+ return decision
374
+
375
+
376
+ _WOULD_REFUSE = set()
377
+
378
+
379
+ def gate(handler, action, resource=None):
380
+ """The decision for an EXISTING generic surface, under ``authz_mode``:
381
+ ``off`` allows without asking, ``log`` asks and logs what it would
382
+ refuse (once per container per action and resource) and allows,
383
+ ``enforce`` returns the decision's answer."""
384
+ mode = mode_of(handler)
385
+ if mode == OFF:
386
+ return True
387
+ decision = decide(handler, action, resource)
388
+ if decision.allowed:
389
+ return True
390
+ if mode == LOG:
391
+ key = (action, getattr(resource, "type", None), getattr(resource, "id", None))
392
+ if key not in _WOULD_REFUSE:
393
+ _WOULD_REFUSE.add(key)
394
+ logger.warning(
395
+ "authz (log only) would refuse %s on %s (%s)",
396
+ action,
397
+ _describe(resource),
398
+ decision.rule or "no rule permits it",
399
+ )
400
+ return True
401
+ return False
402
+
403
+
404
+ def _describe(resource):
405
+ if resource is None:
406
+ return "-"
407
+ return f"{resource.type}:{resource.id}"
408
+
409
+
410
+ def _record(handler, tx, context, decision, resource):
411
+ # imported here: the capabilities package pulls in more than a pure
412
+ # decision module should load at import
413
+ from velocity.aws.capabilities.security import record_event
414
+
415
+ session = _session(context)
416
+ actor = (
417
+ (session.get("masquerade") or {}).get("real_user")
418
+ if isinstance(session.get("masquerade"), dict)
419
+ else None
420
+ ) or session.get("email_address")
421
+ return record_event(
422
+ tx,
423
+ PRIVILEGED_ACCESS,
424
+ f"{decision.action} on {_describe(resource)} by rule {decision.rule}",
425
+ aws_event=getattr(handler, "aws_event", None),
426
+ context=context,
427
+ user_email=actor,
428
+ )
@@ -13,7 +13,7 @@ import re
13
13
  from io import BytesIO
14
14
  from types import MappingProxyType
15
15
 
16
- from velocity.aws import dirty_pipeline, ssm_config
16
+ from velocity.aws import authz, dirty_pipeline, ssm_config
17
17
  from velocity.aws import facets
18
18
  from velocity.aws.handlers.exceptions import AccessError, AlertError, BadRequestError
19
19
  from velocity.aws.handlers.mixins.client_input import MODES, STRICT, ClientInputGuard
@@ -72,6 +72,9 @@ def _fk_blocked_delete_message(table, exc):
72
72
 
73
73
 
74
74
  _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
75
+ # Owner-scoped tables already warned about under the legacy client SQL mode
76
+ # (once per container, not once per request).
77
+ _OWNER_SCOPE_WARNED = set()
75
78
 
76
79
 
77
80
  TEXT_TYPES = frozenset(
@@ -695,6 +698,44 @@ class DataServiceMixin:
695
698
  client_denied_tables = frozenset()
696
699
  client_allowed_tables = None
697
700
 
701
+ # The authorization decision point (velocity.aws.authz) at the client
702
+ # table gate. ``"off"`` (default): the gate is as above. ``"log"``: every
703
+ # table is also put to the rules and what they would refuse is logged,
704
+ # nothing is refused. ``"enforce"``: a table no rule permits is refused.
705
+ # The ``AuthzMode`` config key overrides this per environment. Out of the
706
+ # box the rules permit every ordinary table and NO system table
707
+ # (``sys_…``): a handler that serves one in a grid declares it, e.g.
708
+ # authz_rules = (authz.permit("flags.admin", authz.DATA_ACCESS,
709
+ # when=authz.all_of(authz.table("sys_feature_flags"),
710
+ # authz.holds("site.admin"))),)
711
+ # ``principal_permissions(current_user)`` says which permissions the
712
+ # signed-in user holds; the default is none.
713
+ authz_mode = authz.OFF
714
+ authz_rules = ()
715
+
716
+ def principal_permissions(self, current_user):
717
+ return ()
718
+
719
+ # Tables whose every row belongs to ONE signed-in user: ``{table: owner
720
+ # column}``. The owner column holds the user's sign-in email as the
721
+ # session carries it. Each hooked action (query, find, read, write,
722
+ # delete) confines the caller to their own rows, and the direct actions
723
+ # refuse the table — the rule ``tenant_scope`` gives a site, applied by
724
+ # the framework itself and resolved BEFORE the site's ``tenant_scope``,
725
+ # so an override that forgets ``super()`` cannot switch it off.
726
+ #
727
+ # ``user_data`` is here because the framework owns it: per-user settings
728
+ # written by ``store-user-data`` and read back by the browser through the
729
+ # generic ``query``, which until this rule let any signed-in user read or
730
+ # overwrite another user's rows by naming their ``userid``.
731
+ #
732
+ # Enforced under the strict client SQL mode. Under legacy mode the
733
+ # browser can send raw SQL around any row predicate, so nothing here can
734
+ # be a boundary: the rule is skipped and a warning is logged, leaving
735
+ # legacy behaviour exactly as it was. Set the attribute to ``{}`` to opt
736
+ # a handler out.
737
+ owner_scoped_tables = MappingProxyType({"user_data": "userid"})
738
+
698
739
  # How much SQL-shaped input the boundary accepts from the browser
699
740
  # (velocity.aws.handlers.mixins.client_input): "strict" (Contract C — dict
700
741
  # filters, plain columns, structured @@search) or "legacy" (string
@@ -795,7 +836,9 @@ class DataServiceMixin:
795
836
  if table in self.client_denied_tables:
796
837
  return False
797
838
  allowed = self.client_allowed_tables
798
- return allowed is None or table in allowed
839
+ if allowed is not None and table not in allowed:
840
+ return False
841
+ return authz.gate(self, authz.DATA_ACCESS, authz.Resource(authz.TABLE, table))
799
842
 
800
843
  def _assert_client_table(self, table, label="table"):
801
844
  """The table gate every client action passes (SEC 4.2)."""
@@ -914,9 +957,12 @@ class DataServiceMixin:
914
957
  )
915
958
 
916
959
  def _assert_direct_unscoped(self, table, context):
917
- """A tenant-scoped table is never reachable through a direct action
918
- (they cannot apply the scope)."""
919
- if self.tenant_scope(context, table) is not None:
960
+ """A tenant-scoped or owner-scoped table is never reachable through a
961
+ direct action (they cannot apply the scope)."""
962
+ if (
963
+ self._owner_scope(context, table) is not None
964
+ or self.tenant_scope(context, table) is not None
965
+ ):
920
966
  raise AlertError(
921
967
  f"Direct access to '{table}' is not permitted; "
922
968
  "use the standard data actions."
@@ -995,11 +1041,47 @@ class DataServiceMixin:
995
1041
  """
996
1042
  return None
997
1043
 
1044
+ def _owner_scope(self, context, table):
1045
+ """``(column, values)`` confining ``table`` to the signed-in user, or
1046
+ ``None`` when the table is not in :attr:`owner_scoped_tables` — or
1047
+ when this handler runs the legacy client SQL mode, where no row
1048
+ predicate is a boundary (see the attribute's comment).
1049
+
1050
+ The values are the session's email as it stands and lowercased: rows
1051
+ written before emails were normalized keep matching. The first value
1052
+ is the one stamped on a new row. A session without an email is
1053
+ refused."""
1054
+ column = (self.owner_scoped_tables or {}).get(table)
1055
+ if not column:
1056
+ return None
1057
+ if self.client_sql_mode_effective != STRICT:
1058
+ if table not in _OWNER_SCOPE_WARNED:
1059
+ _OWNER_SCOPE_WARNED.add(table)
1060
+ logger.warning(
1061
+ "%s rows are not confined to their owner: this handler "
1062
+ "runs the legacy client SQL mode, under which no row rule "
1063
+ "can be enforced. Move it to strict mode.",
1064
+ table,
1065
+ )
1066
+ return None
1067
+ session = context.session() if hasattr(context, "session") else None
1068
+ email = (session or {}).get("email_address")
1069
+ if not isinstance(email, str) or not email.strip():
1070
+ raise AlertError("Access denied: no signed-in user for this session.")
1071
+ email = email.strip()
1072
+ return column, tuple(dict.fromkeys((email, email.lower())))
1073
+
998
1074
  def _resolve_tenant_scope(self, tx, table, context):
999
1075
  """``(column, values)`` for ``table``, or ``None`` (unscoped). Fails
1000
1076
  closed: a scope under any client SQL mode but strict, a malformed
1001
- scope, an empty value, or a table without the column is refused."""
1002
- scope = self.tenant_scope(context, table)
1077
+ scope, an empty value, or a table without the column is refused.
1078
+
1079
+ A table in :attr:`owner_scoped_tables` takes the framework's owner
1080
+ scope; the site's :meth:`tenant_scope` is not consulted for it."""
1081
+ scope = self._owner_scope(context, table)
1082
+ owned = scope is not None
1083
+ if scope is None:
1084
+ scope = self.tenant_scope(context, table)
1003
1085
  if scope is None:
1004
1086
  return None
1005
1087
  # The predicate is only a boundary when the client cannot send raw
@@ -1030,7 +1112,12 @@ class DataServiceMixin:
1030
1112
  except Exception: # noqa: BLE001 — unknown schema is not scoped schema
1031
1113
  available = set()
1032
1114
  if column.lower() not in available:
1033
- raise AlertError(f"Access denied: {table} is not tenant-scoped.")
1115
+ # An owner-scoped table that does not exist yet (user_data on a
1116
+ # fresh site, before anyone has saved a setting) has no rows to
1117
+ # confine: keep the scope and let the read come back empty, as it
1118
+ # did before. A table that exists WITHOUT the column is refused.
1119
+ if not (owned and not available):
1120
+ raise AlertError(f"Access denied: {table} is not tenant-scoped.")
1034
1121
  return column, values
1035
1122
 
1036
1123
  @staticmethod
@@ -1066,10 +1153,12 @@ class DataServiceMixin:
1066
1153
  f"Access denied: {column} must be one of your own values."
1067
1154
  )
1068
1155
  if not keys and creating:
1069
- if len(values) != 1:
1156
+ if len(values) != 1 and table not in (self.owner_scoped_tables or {}):
1070
1157
  raise AlertError(
1071
1158
  f"Access denied: choose the {column} for this {table} record."
1072
1159
  )
1160
+ # an owner scope's values are one person's email in two spellings:
1161
+ # the first is the session's own
1073
1162
  incoming[column] = values[0]
1074
1163
 
1075
1164
  def _check_tenant_row(self, table, scope, row):
@@ -17,12 +17,17 @@ from abc import ABC
17
17
  from datetime import date, datetime
18
18
  from typing import Any, Dict, List
19
19
 
20
- from velocity.aws.handlers.exceptions import AlertError
20
+ from velocity.aws import authz, user_data
21
+ from velocity.aws.handlers.exceptions import AccessError, AlertError, NotFoundError
21
22
  from velocity.aws.ssm_config import get_project_name as config_get_project_name
22
23
  from velocity.aws.ssm_config import get_stage as config_get_stage
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
 
27
+ # Where store-user-data keeps a user's settings (WebHandler.user_data_mode).
28
+ USER_DATA_ROWS = "rows"
29
+ USER_DATA_DOCUMENT = "document"
30
+
26
31
  _NUMERIC = "NUMERIC(19, 6)" # time.time() floats, as velocity infers them
27
32
 
28
33
  # Tables this mixin writes, declared for velocity.aws.schema.ensure_infrastructure
@@ -119,6 +124,15 @@ class WebHandler(ABC):
119
124
  SYSTEM_LOG_TABLE = "sys_log"
120
125
  LAMBDA_METRICS_TABLE = "lambda_metrics"
121
126
 
127
+ # Where store-user-data keeps a user's settings, and whether get-user-data
128
+ # returns them. "rows" is the store every app started on: one row per
129
+ # (userid, key) in ``user_data``, read back by the browser through the
130
+ # generic query. "document" is one JSONB document per user in
131
+ # ``sys_user_data``, keyed by the user's row id and patched under a row
132
+ # lock (velocity.aws.user_data). A handler moves to "document" in the same
133
+ # release as its frontend starts reading ``controls.user_data``.
134
+ user_data_mode = USER_DATA_ROWS
135
+
122
136
  def __init__(self, *args, **kwargs):
123
137
  super().__init__(*args, **kwargs)
124
138
  self.start_time = None
@@ -566,6 +580,13 @@ Request Details:
566
580
  # admin's real identity alongside the user they are acting as.
567
581
  masquerade = (context.session() or {}).get("masquerade")
568
582
  controls["masquerade"] = masquerade or None
583
+ if self.user_data_mode == USER_DATA_DOCUMENT:
584
+ # The signed-in user's saved settings, in the same response: one
585
+ # read, and no reason for the browser to query the table itself.
586
+ owner = current_user.get("sys_id")
587
+ saved = user_data.load(tx, owner) if owner else {}
588
+ controls["user_data"] = saved.get("data", {})
589
+ controls["user_data_revision"] = saved.get("revision", 0)
569
590
  context.response().update_store(
570
591
  {
571
592
  "controls": controls,
@@ -576,18 +597,48 @@ Request Details:
576
597
  )
577
598
 
578
599
  def OnActionStoreUserData(self, tx, context):
579
- """Persist per-user UI settings posted by omnilib's user-settings store.
580
-
581
- Generic counterpart to :meth:`OnActionGetUserData`. omnilib sends
582
- ``{"userData": {keyPath: fragment, ...}}``; each entry is upserted into
583
- the per-user settings table keyed by ``(userid, key)``. Business-agnostic
584
- — every app shares the same store, so this lives here rather than being
585
- re-implemented per app. App handlers may override to add side effects.
600
+ """Persist per-user UI settings posted by the browser:
601
+ ``{"userData": {key: value, ...}}``.
602
+
603
+ ``user_data_mode`` decides where they go:
604
+
605
+ * ``"rows"`` (the default, and the behaviour before
606
+ :mod:`velocity.aws.user_data`): each entry is upserted into the
607
+ per-user settings table keyed by ``(userid, key)``.
608
+ * ``"document"``: each entry is a patch to the signed-in user's one
609
+ document in ``sys_user_data`` (see :mod:`velocity.aws.user_data`),
610
+ applied under a row lock. The owner is the signed-in user's row,
611
+ taken from the session; nothing in the request can name another
612
+ user. A masquerade session saves nothing: an administrator acting
613
+ as a user sees that user's settings and leaves them as they were.
614
+
615
+ Business-agnostic — every app shares the same store, so this lives
616
+ here rather than being re-implemented per app. App handlers may
617
+ override to add side effects.
586
618
  """
587
619
  userdata = (context.payload() or {}).get("userData")
588
620
  if not userdata:
589
621
  return
590
- email = (context.session() or {}).get("email_address")
622
+ session = context.session() or {}
623
+ email = session.get("email_address")
624
+ if self.user_data_mode == USER_DATA_DOCUMENT:
625
+ owner = (getattr(self, "current_user", None) or {}).get("sys_id")
626
+ decision = authz.decide(
627
+ self, user_data.WRITE, user_data.resource(owner), context
628
+ )
629
+ if decision.rule == user_data.MASQUERADE_WRITE:
630
+ # not an error: the interface saves as it goes, and an
631
+ # administrator acting as a user should not see a failure
632
+ # for every click
633
+ context.response().set_body({"saved": False, "reason": "masquerade"})
634
+ return
635
+ if not decision.allowed:
636
+ raise AccessError("You do not have access to that.")
637
+ result = user_data.apply(
638
+ tx, owner, user_data.patch_from_payload(userdata), by=email
639
+ )
640
+ context.response().set_body({"saved": True, "revision": result["revision"]})
641
+ return
591
642
  table = getattr(context, "USER_DATA_TABLE", "user_data")
592
643
  for key, val in userdata.items():
593
644
  tx.table(table).upsert(
@@ -595,6 +646,61 @@ Request Details:
595
646
  {"userid": email, "key": key},
596
647
  )
597
648
 
649
+ def _other_user_document(self, tx, context):
650
+ """The user row id a ``*-user-document`` request names, checked: the
651
+ actions exist only in ``document`` mode, and the id must be a row of
652
+ this handler's user table."""
653
+ if self.user_data_mode != USER_DATA_DOCUMENT:
654
+ raise NotFoundError("Unknown action")
655
+ named = (context.payload() or {}).get("user_sys_id")
656
+ target = user_data.resource(named)
657
+ user_table = getattr(self, "user_table", None)
658
+ if not user_table or not tx.table(user_table).find({"sys_id": target.id}):
659
+ raise AlertError("That user was not found.")
660
+ return target
661
+
662
+ def OnActionReadUserDocument(self, tx, context):
663
+ """Any user's settings document: ``{"user_sys_id": n}``.
664
+
665
+ The one place a request names a user. Allowed for the document's
666
+ owner, or a caller holding ``user_data.read_any``; that use is
667
+ recorded in ``sys_security_events`` before the document is read, and
668
+ refused when it cannot be recorded."""
669
+ target = self._other_user_document(tx, context)
670
+ authz.require(self, tx, context, user_data.READ, target)
671
+ saved = user_data.load(tx, target.id)
672
+ context.response().set_body(
673
+ {
674
+ "user_sys_id": target.id,
675
+ "data": saved["data"],
676
+ "revision": saved["revision"],
677
+ }
678
+ )
679
+
680
+ def OnActionWriteUserDocument(self, tx, context):
681
+ """Patch any user's settings document:
682
+ ``{"user_sys_id": n, "userData": {path: value}}`` (``null`` removes).
683
+
684
+ Allowed for the document's owner, or a caller holding
685
+ ``user_data.write_any`` (separate from reading), recorded the same
686
+ way; never in a masquerade session."""
687
+ target = self._other_user_document(tx, context)
688
+ authz.require(self, tx, context, user_data.WRITE, target)
689
+ session = context.session() or {}
690
+ result = user_data.apply(
691
+ tx,
692
+ target.id,
693
+ user_data.patch_from_payload((context.payload() or {}).get("userData")),
694
+ by=session.get("email_address"),
695
+ )
696
+ context.response().set_body(
697
+ {
698
+ "user_sys_id": target.id,
699
+ "data": result["data"],
700
+ "revision": result["revision"],
701
+ }
702
+ )
703
+
598
704
  # ------------------------------------------------------------------
599
705
  # Enhanced lifecycle hooks
600
706
  # ------------------------------------------------------------------
velocity/aws/schema.py CHANGED
@@ -47,6 +47,7 @@ SCHEMA_SOURCES: Dict[str, Tuple[str, ...]] = {
47
47
  "velocity.aws.capabilities.provisioning",
48
48
  "velocity.aws.handlers.mixins.web_handler",
49
49
  "velocity.aws.handlers.mixins.session_control",
50
+ "velocity.aws.user_data",
50
51
  "velocity.db.migrations",
51
52
  ),
52
53
  "assets": ("velocity.aws.assets",),
@@ -0,0 +1,404 @@
1
+ """Per-user settings as one document, patched in place.
2
+
3
+ A signed-in user's interface settings — toolbar shortcuts, preferences, each
4
+ grid's layout and filter presets — live in ONE row of ``sys_user_data``: a
5
+ JSONB document keyed by the user's row id in the site's user table, never by
6
+ email. The browser never sends the whole document. Each save is a small patch
7
+ and :func:`apply` applies it to the current server copy under a row lock, so
8
+ two tabs saving different settings both succeed, and removing a setting
9
+ really removes it.
10
+
11
+ from velocity.aws import user_data
12
+
13
+ user_data.apply(tx, user_sys_id, [("set", "settings.rowsPerPage", 50),
14
+ ("unset", "grids.gmail_message.presets")])
15
+ user_data.read(tx, user_sys_id, "settings.workingContext", default={})
16
+
17
+ The document's first level is fixed (``NAMESPACES``); below it each feature
18
+ owns its branch::
19
+
20
+ toolbar.shortcuts the toolbar's links
21
+ settings.<key> preferences (useUserSettings)
22
+ grids.<table>.layout one table's grid layout
23
+ grids.<table>.presets one table's saved filter presets
24
+ state.<path> any other persisted interface state
25
+
26
+ A path is dotted keys. A list is a value: it is replaced whole, never patched
27
+ by index.
28
+
29
+ **The wire format** of ``store-user-data`` is unchanged —
30
+ ``{"userData": {key: value}}`` — and :func:`patch_from_payload` reads each
31
+ entry as a patch: a path under one of the namespaces is a ``set`` (``None``
32
+ is an ``unset``), and the key names the browser used before this module
33
+ (``toolbarShortcuts``, ``userSettings``, ``filter_presets_<table>`` and the
34
+ nested store fragments ``setUserData`` sends) are translated, so a browser
35
+ tab still running an older bundle keeps saving correctly.
36
+
37
+ **Who may touch a document** is decided by :data:`RULES`, on the one
38
+ decision point (:mod:`velocity.aws.authz`): the owner reads and writes their
39
+ own; ``user_data.read_any`` and ``user_data.write_any`` are two separate
40
+ permissions for anyone else's, each use recorded; and nothing is written in a
41
+ masquerade session. :func:`read` and :func:`apply` themselves take the
42
+ owner's row id from the caller and trust it: the handler (``WebHandler``,
43
+ ``user_data_mode = "document"``) asks the decision point first. This module
44
+ is business-agnostic.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import copy
50
+ import json
51
+ import re
52
+
53
+ from velocity.aws import authz
54
+ from velocity.aws.handlers.exceptions import AlertError
55
+ from velocity.db.exceptions import DbDuplicateKeyError
56
+ from velocity.misc.format import to_json
57
+
58
+ TABLE = "sys_user_data"
59
+
60
+ # Declared for velocity.aws.schema.ensure_infrastructure (the step that lets an
61
+ # environment run with the schema locked).
62
+ SCHEMA = {
63
+ TABLE: {
64
+ "columns": {"user_sys_id": "BIGINT", "data": "JSONB", "revision": "BIGINT"},
65
+ # one document per user; also what the first-write race falls back on
66
+ "unique_indexes": [
67
+ {"columns": ["user_sys_id"], "name": "idx__sys_user_data__user_sys_id"}
68
+ ],
69
+ },
70
+ }
71
+
72
+ NAMESPACES = ("toolbar", "settings", "grids", "state")
73
+ MAX_BYTES = 256 * 1024
74
+ MAX_DEPTH = 12
75
+ MAX_OPERATIONS = 200
76
+
77
+ SET = "set"
78
+ UNSET = "unset"
79
+
80
+ # Actions, the resource type, and the two permissions for another user's
81
+ # document. Reading and changing are separate permissions on purpose.
82
+ READ = "user_data.read"
83
+ WRITE = "user_data.write"
84
+ RESOURCE = "user_data"
85
+ READ_ANY = "user_data.read_any"
86
+ WRITE_ANY = "user_data.write_any"
87
+ MASQUERADE_WRITE = "user_data.masquerade_write"
88
+
89
+ RULES = (
90
+ authz.permit("user_data.own", {READ, WRITE}, when=authz.owner),
91
+ authz.permit(READ_ANY, READ, when=authz.holds(READ_ANY), audit=True),
92
+ authz.permit(WRITE_ANY, WRITE, when=authz.holds(WRITE_ANY), audit=True),
93
+ # an administrator acting as a user sees that user's settings and leaves
94
+ # them as they were
95
+ authz.forbid(MASQUERADE_WRITE, WRITE, when=authz.masquerading),
96
+ )
97
+ authz.register(*RULES)
98
+
99
+
100
+ def resource(user_sys_id):
101
+ """A user's document as the decision point sees it."""
102
+ owner = _owner(user_sys_id)
103
+ return authz.Resource(RESOURCE, id=owner, owner=owner)
104
+
105
+
106
+ _SEGMENT = re.compile(r"^[A-Za-z0-9_@-]{1,80}$")
107
+ # The names the browser used before the document, and where each one lives now.
108
+ _LEGACY_KEYS = {"toolbarShortcuts": "toolbar.shortcuts", "userSettings": "settings"}
109
+ _LEGACY_PRESETS = "filter_presets_"
110
+
111
+
112
+ class UserDataError(AlertError, ValueError):
113
+ """A patch that cannot be applied. The message is safe to show."""
114
+
115
+
116
+ # ── paths ──────────────────────────────────────────────────────────────────
117
+
118
+
119
+ def split(path):
120
+ """``"grids.gmail_message.layout"`` as a tuple of segments, or raise.
121
+
122
+ The first segment must be one of :data:`NAMESPACES`; every segment is a
123
+ plain key (letters, digits, ``_``, ``-``, ``@``), so a path can never
124
+ reach outside the document or collide with a JSON operator."""
125
+ if not isinstance(path, str) or not path:
126
+ raise UserDataError("A setting needs a name.")
127
+ parts = tuple(path.split("."))
128
+ if len(parts) > MAX_DEPTH:
129
+ raise UserDataError(f"Settings can be nested at most {MAX_DEPTH} deep.")
130
+ if parts[0] not in NAMESPACES:
131
+ raise UserDataError(
132
+ f"'{parts[0]}' is not a kind of setting ({', '.join(NAMESPACES)})."
133
+ )
134
+ for part in parts:
135
+ if not _SEGMENT.match(part):
136
+ raise UserDataError(f"'{path}' is not a valid setting name.")
137
+ return parts
138
+
139
+
140
+ def get_at(document, path, default=None):
141
+ """The value at ``path``, or ``default`` when nothing is there."""
142
+ node = document
143
+ for part in split(path):
144
+ if not isinstance(node, dict) or part not in node:
145
+ return default
146
+ node = node[part]
147
+ return node
148
+
149
+
150
+ def set_at(document, path, value):
151
+ """``document`` with ``value`` at ``path``; missing parents are created
152
+ and a non-object in the way is replaced. The argument is not changed."""
153
+ parts = split(path)
154
+ out = dict(document or {})
155
+ node = out
156
+ for part in parts[:-1]:
157
+ child = node.get(part)
158
+ child = dict(child) if isinstance(child, dict) else {}
159
+ node[part] = child
160
+ node = child
161
+ node[parts[-1]] = copy.deepcopy(value)
162
+ return out
163
+
164
+
165
+ def unset_at(document, path):
166
+ """``document`` without ``path``; parents left empty are removed too.
167
+ Removing what is not there changes nothing. The argument is not changed."""
168
+ parts = split(path)
169
+ out = dict(document or {})
170
+ trail = [out]
171
+ node = out
172
+ for part in parts[:-1]:
173
+ child = node.get(part)
174
+ if not isinstance(child, dict):
175
+ return out
176
+ child = dict(child)
177
+ node[part] = child
178
+ node = child
179
+ trail.append(child)
180
+ if parts[-1] not in node:
181
+ return out
182
+ del node[parts[-1]]
183
+ for depth in range(len(trail) - 1, 0, -1):
184
+ if trail[depth]:
185
+ break
186
+ del trail[depth - 1][parts[depth - 1]]
187
+ return out
188
+
189
+
190
+ def size(document):
191
+ """The document's size as stored, in bytes."""
192
+ return len(json.dumps(document or {}, separators=(",", ":")).encode("utf-8"))
193
+
194
+
195
+ def apply_operations(document, operations, *, max_bytes=MAX_BYTES):
196
+ """``document`` after ``operations``, in order: ``("set", path, value)``
197
+ and ``("unset", path)``. Refuses a result over ``max_bytes``."""
198
+ operations = list(operations or ())
199
+ if len(operations) > MAX_OPERATIONS:
200
+ raise UserDataError(f"At most {MAX_OPERATIONS} settings can change at once.")
201
+ out = dict(document or {})
202
+ for operation in operations:
203
+ kind = operation[0] if operation else None
204
+ if kind == SET and len(operation) == 3:
205
+ out = set_at(out, operation[1], operation[2])
206
+ elif kind == UNSET and len(operation) == 2:
207
+ out = unset_at(out, operation[1])
208
+ else:
209
+ raise UserDataError("That is not a settings change.")
210
+ if size(out) > max_bytes:
211
+ raise UserDataError(
212
+ f"Your saved settings would be larger than {max_bytes // 1024} KB. "
213
+ "Reset something you no longer need."
214
+ )
215
+ return out
216
+
217
+
218
+ # ── the wire format, old key names included ────────────────────────────────
219
+
220
+
221
+ def _leaves(value, base):
222
+ """``(path, leaf)`` for every non-object value under ``value``."""
223
+ if isinstance(value, dict) and value:
224
+ for key, child in value.items():
225
+ yield from _leaves(child, f"{base}.{key}")
226
+ else:
227
+ yield base, value
228
+
229
+
230
+ def translate(key, value):
231
+ """One ``userData`` entry as operations.
232
+
233
+ * a path under a namespace: ``set`` (``None`` is ``unset``);
234
+ * ``toolbarShortcuts`` / ``userSettings`` / ``filter_presets_<table>``:
235
+ the names used before the document, moved to their branch;
236
+ * anything else is a store fragment from ``setUserData`` — the key is the
237
+ fragment's path and the value the whole nested fragment. Each leaf under
238
+ it becomes its own ``set``, which is the deep merge the browser meant
239
+ (the old row store kept only the last fragment written under a key).
240
+ ``config.<table>…`` lands in ``grids.<table>…``, the rest in ``state``.
241
+ """
242
+ if not isinstance(key, str) or not key:
243
+ raise UserDataError("A setting needs a name.")
244
+ if key in _LEGACY_KEYS:
245
+ path = _LEGACY_KEYS[key]
246
+ elif key.startswith(_LEGACY_PRESETS):
247
+ path = f"grids.{key[len(_LEGACY_PRESETS):]}.presets"
248
+ elif key.split(".", 1)[0] in NAMESPACES:
249
+ path = key
250
+ else:
251
+ return _fragment(key, value)
252
+ return [(UNSET, path)] if value is None else [(SET, path, value)]
253
+
254
+
255
+ def _fragment(key, value):
256
+ parts = key.split(".")
257
+ node = value
258
+ for part in parts: # the value repeats the key path: step inside it
259
+ if isinstance(node, dict) and part in node:
260
+ node = node[part]
261
+ else:
262
+ node = value
263
+ break
264
+ if parts[0] == "config" and len(parts) > 1:
265
+ base = "grids." + ".".join(parts[1:])
266
+ elif parts[0] == "config":
267
+ base = "grids"
268
+ else:
269
+ base = "state." + key
270
+ if node is None:
271
+ return [(UNSET, base)]
272
+ return [(SET, path, leaf) for path, leaf in _leaves(node, base)]
273
+
274
+
275
+ def patch_from_payload(user_data):
276
+ """The ``userData`` object of a ``store-user-data`` request as operations."""
277
+ if not isinstance(user_data, dict):
278
+ raise UserDataError("That is not a settings change.")
279
+ operations = []
280
+ for key, value in user_data.items():
281
+ operations += translate(key, value)
282
+ return operations
283
+
284
+
285
+ # ── the row ────────────────────────────────────────────────────────────────
286
+
287
+
288
+ def _owner(user_sys_id):
289
+ try:
290
+ owner = int(user_sys_id)
291
+ except (TypeError, ValueError):
292
+ owner = 0
293
+ if owner <= 0:
294
+ raise UserDataError("There is no signed-in user to keep settings for.")
295
+ return owner
296
+
297
+
298
+ # Engines whose database is known to have the table (it is never dropped).
299
+ _PRESENT = set()
300
+
301
+
302
+ def _table(tx, *, create=False):
303
+ """The table, or ``None`` when it does not exist yet and ``create`` is
304
+ off: a site that has never saved a setting reads as "no settings" instead
305
+ of a missing-relation error, which would abort the transaction.
306
+
307
+ On the first save the table is created FROM THE DECLARATION, so ``data``
308
+ is JSONB whatever the first value written (velocity's create-on-write
309
+ would infer TEXT from the JSON text). Under a locked schema that raises:
310
+ the table is then the schema step's to create
311
+ (``velocity.aws.schema.ensure_infrastructure``)."""
312
+ table = tx.table(TABLE)
313
+ key = id(getattr(tx, "engine", None) or tx)
314
+ if key in _PRESENT:
315
+ return table
316
+ if not table.exists():
317
+ if not create:
318
+ return None
319
+ table.ensure_schema(**SCHEMA[TABLE])
320
+ _PRESENT.add(key)
321
+ return table
322
+
323
+
324
+ def _find(tx, owner, *, lock=False, create=False):
325
+ table = _table(tx, create=create)
326
+ if table is None:
327
+ return None
328
+ if lock:
329
+ return table.find({"user_sys_id": owner}, lock=True)
330
+ return table.find({"user_sys_id": owner})
331
+
332
+
333
+ def _document(row):
334
+ if row is None:
335
+ return {}, 0
336
+ row = row.to_dict() if hasattr(row, "to_dict") else dict(row)
337
+ data = row.get("data")
338
+ if isinstance(data, (str, bytes, bytearray)):
339
+ try:
340
+ data = json.loads(data)
341
+ except ValueError:
342
+ data = {}
343
+ return (dict(data) if isinstance(data, dict) else {}), int(row.get("revision") or 0)
344
+
345
+
346
+ def read(tx, user_sys_id, path=None, default=None):
347
+ """A user's whole document, or the value at ``path``.
348
+
349
+ For server code that needs a setting (a working context, a default) and
350
+ for the sign-in response. A user with no document yet reads as ``{}``."""
351
+ document, _revision = _document(_find(tx, _owner(user_sys_id)))
352
+ if path is None:
353
+ return document
354
+ return get_at(document, path, default)
355
+
356
+
357
+ def load(tx, user_sys_id):
358
+ """The whole document with its revision, ``{"data", "revision"}``, in one
359
+ read: what the sign-in response sends."""
360
+ document, current = _document(_find(tx, _owner(user_sys_id)))
361
+ return {"data": document, "revision": current}
362
+
363
+
364
+ def revision(tx, user_sys_id):
365
+ """How many times the user's document has changed (0 = never saved)."""
366
+ return _document(_find(tx, _owner(user_sys_id)))[1]
367
+
368
+
369
+ def apply(tx, user_sys_id, operations, *, by=None, max_bytes=MAX_BYTES):
370
+ """Apply ``operations`` to the user's document, under a row lock.
371
+
372
+ Creates the row on the first save. Returns ``{"data", "revision"}``.
373
+ Nothing is written when ``operations`` is empty or changes nothing."""
374
+ owner = _owner(user_sys_id)
375
+ operations = list(operations or ())
376
+ row = _find(tx, owner, lock=True, create=bool(operations))
377
+ if row is None and operations:
378
+ row = _create(tx, owner, by)
379
+ document, current = _document(row)
380
+ if not operations:
381
+ return {"data": document, "revision": current}
382
+ changed = apply_operations(document, operations, max_bytes=max_bytes)
383
+ if changed == document:
384
+ return {"data": document, "revision": current}
385
+ data = {"data": to_json(changed), "revision": current + 1}
386
+ if by:
387
+ data["sys_modified_by"] = by
388
+ tx.table(TABLE).update(data, where={"user_sys_id": owner})
389
+ return {"data": changed, "revision": current + 1}
390
+
391
+
392
+ def _create(tx, owner, by):
393
+ """The user's first row. Two tabs can both find none: the unique index
394
+ lets one insert win, and the other reads the winner's row, locked."""
395
+ table = tx.table(TABLE)
396
+ data = {"user_sys_id": owner, "data": to_json({}), "revision": 0}
397
+ if by:
398
+ data["sys_modified_by"] = by
399
+ try:
400
+ with tx.savepoint():
401
+ table.insert(data)
402
+ except DbDuplicateKeyError:
403
+ pass
404
+ return table.find({"user_sys_id": owner}, lock=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -1,8 +1,9 @@
1
- velocity/__init__.py,sha256=ROwehP7Mjif9OFSrkh6l4SY3F-xpEbiCJ3U_XmKHIoA,362
1
+ velocity/__init__.py,sha256=Y8EFFkNo93OQ4ORDUsf6N52SBq6qZUuDXw5LdDBL4fU,362
2
2
  velocity/cache.py,sha256=pthvw7gDVEXRzkTcQtA5YVf4Q1IxAamWsba4UReT0RE,8933
3
3
  velocity/logging.py,sha256=MAsZap1cDSdOBNRc_9ezQxup5dHOS0ARxU9NI_-UK3E,4037
4
4
  velocity/aws/__init__.py,sha256=XJJ79gnPlhg3aJij713PB8-vjQOt-BkqxYeGCBOxAu8,3460
5
5
  velocity/aws/amplify.py,sha256=N_763LiraJfuLYSllGKG6jfP2oyCJgtrK_wI2UZKv1E,8852
6
+ velocity/aws/authz.py,sha256=Mx8Dmnz_SogLCihqvxaqJKsFt3NxiNTMVoNu3fRQHTI,15230
6
7
  velocity/aws/blobs.py,sha256=2TlTZ3KPxBdXDJ99aVSgDLR11wPQ8O3BbtNbY8mFQ28,9691
7
8
  velocity/aws/dirty_pipeline.py,sha256=DQHsCPQPWwiy_E7nxX8kO3DgvQTFkVDaDOjlBcmblyE,3331
8
9
  velocity/aws/email.py,sha256=CAvXTAUxJqMaT8ylpRwRcVEA4YLFMUuHCrwYLVE4G1E,6243
@@ -15,9 +16,10 @@ velocity/aws/mirror.py,sha256=8Ik1_GVCcD7E2nCe1ci6sp56PEVBMYZF-alkMFlP4O4,8858
15
16
  velocity/aws/ops.py,sha256=re8e3LkkQwgNS1ZjtaZMPnKyPbi4PuUC4YeS0wZwg_Q,18701
16
17
  velocity/aws/queue_defaults.py,sha256=480V3pDvWBBamJ6hIJyVtPGJKMVmnP43otl3E9qnwfs,479
17
18
  velocity/aws/s3.py,sha256=Mn6rlK6sXykx32EjptFK2IRRBQZ9xijl9r33DUnKDzQ,30277
18
- velocity/aws/schema.py,sha256=7KLMjXjblOAhkarMlixPqrnqoHOrXze0MGASEfx65ok,4694
19
+ velocity/aws/schema.py,sha256=QhaPtwECSthtGBb_w4cle4lddFpd2BIgY2rNsB3CYJM,4728
19
20
  velocity/aws/sms.py,sha256=2CdcLlIEy_V5K5Qx_pZ2vrakvEK7IhP7y1JUttBgyG4,16253
20
21
  velocity/aws/ssm_config.py,sha256=xkcBxPuCcdjpeArxdcGnSBiuyG2txlUraSqtCtb_kIo,7987
22
+ velocity/aws/user_data.py,sha256=WFIfy7g-o6nubrzQP4ODAGbEdYVOHA1oYxtGLbGVkyQ,15145
21
23
  velocity/aws/assets/__init__.py,sha256=2R9LUShtaqkyRbMhxj8IL1JMP80H4iBfqaQflK38KXI,1709
22
24
  velocity/aws/assets/backfill.py,sha256=ybbD5vqpmr6vTIue8HDRkNVJwfCdTFHzBeq91nmwtTg,4679
23
25
  velocity/aws/assets/indexing.py,sha256=6sihNC6cPfhdNpPcNppucs9kSaV-HjCH1QsgSK7XFEA,8799
@@ -55,11 +57,11 @@ velocity/aws/handlers/mixins/__init__.py,sha256=rA_0YlKiRL5YU6JOZhVGC_7nY_p96dDG
55
57
  velocity/aws/handlers/mixins/audit_trail.py,sha256=D8BHuBfa2EXAcC4NtV_0Gie22zshhalcscf7L1MEDVw,16873
56
58
  velocity/aws/handlers/mixins/client_errors.py,sha256=0LqIRm1gZRwzHH32ravGHAgcQqJzjh5p6M_ptuL2Tto,20105
57
59
  velocity/aws/handlers/mixins/client_input.py,sha256=UhfwsRiztOYDrJnQPZhQ6J71NAmCyrXjMgxbko2rnfQ,24225
58
- velocity/aws/handlers/mixins/data_service.py,sha256=Q6sLjMZaXyc0ixMFSwqlwk6SbwZdiRqD4oJZQuCzHH8,89016
60
+ velocity/aws/handlers/mixins/data_service.py,sha256=aEdSMpPZw3evhD7QecVT6_xKNrW6BsgebHmwalUpclI,93892
59
61
  velocity/aws/handlers/mixins/job_handler.py,sha256=yIbNcIg5pKXtZeQFNW7B4xqJJTjtjIxI8HRQsbMWixA,8095
60
62
  velocity/aws/handlers/mixins/row_scoped.py,sha256=f7N70RjzlHIVyjDsEI1oImCdjyFsgoP9aIvYXUXD5-Q,12967
61
63
  velocity/aws/handlers/mixins/session_control.py,sha256=w2wGC_XiM-W-tL5mhZvVCt_GiQjE3Gao0Ut7tx6JdLk,12937
62
- velocity/aws/handlers/mixins/web_handler.py,sha256=00h3jL3cIqMX2m54GEhX893DtvlHnsTxZEN7mtjbltw,31482
64
+ velocity/aws/handlers/mixins/web_handler.py,sha256=6hA7GUSj_6y0U2fnhGFvuzHRH73RUOdvjxaH3ZZZ-Qg,36549
63
65
  velocity/aws/tests/__init__.py,sha256=hk-efPTkFb_py5ouA5d1OuPVQzaa5Wa01Wsc1oQ9dhY,19
64
66
  velocity/aws/tests/test_base_handler_error_response.py,sha256=CgnTMzxGkd4ArUHvsuiO_iQsiQMSlZSX_fM0YrMVRrc,2408
65
67
  velocity/aws/tests/test_deploy_least_privilege.py,sha256=ShdsbNwl2qzPaeQp2ZcMqtWRSoIB0DM0GmX0EUGJOUk,15947
@@ -215,9 +217,9 @@ velocity/payment/stripe_adapter.py,sha256=6EmVBZZchg2s3Teya0eQGA_8pvSK_teizr036U
215
217
  velocity/payment/stripe_mirror.py,sha256=6GKJk_PjAgySqFEuxbcuV423OfuGXupVTiGu9QwS5W8,12320
216
218
  velocity/testing/__init__.py,sha256=WtYMDHgCLxOq6mlemcOYV5Y9HqF_2QQgSfuAjFRg69Q,731
217
219
  velocity/testing/fake_db.py,sha256=phgdulk5owf3noUDpEfI7yFsVcKxRGZ82f2BJPYnaiU,53226
218
- velocity_python-0.2.1.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
219
- velocity_python-0.2.1.dist-info/METADATA,sha256=1fdxJ_-DEUkiOBQ2E6DXclQdmLHZS71fsYaXb3GxVrs,17782
220
- velocity_python-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
221
- velocity_python-0.2.1.dist-info/entry_points.txt,sha256=nXUKJHkOGQCSPoagdmMNd_qycrle1LVU-GhYknLGM1M,56
222
- velocity_python-0.2.1.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
223
- velocity_python-0.2.1.dist-info/RECORD,,
220
+ velocity_python-0.2.2.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
221
+ velocity_python-0.2.2.dist-info/METADATA,sha256=AyF5g7y_NQgfh4ywOlwkZX-cxnrmtGFom8Kp7oMznts,17782
222
+ velocity_python-0.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
223
+ velocity_python-0.2.2.dist-info/entry_points.txt,sha256=nXUKJHkOGQCSPoagdmMNd_qycrle1LVU-GhYknLGM1M,56
224
+ velocity_python-0.2.2.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
225
+ velocity_python-0.2.2.dist-info/RECORD,,