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 +1 -1
- velocity/aws/authz.py +428 -0
- velocity/aws/handlers/mixins/data_service.py +98 -9
- velocity/aws/handlers/mixins/web_handler.py +115 -9
- velocity/aws/schema.py +1 -0
- velocity/aws/user_data.py +404 -0
- {velocity_python-0.2.1.dist-info → velocity_python-0.2.2.dist-info}/METADATA +1 -1
- {velocity_python-0.2.1.dist-info → velocity_python-0.2.2.dist-info}/RECORD +12 -10
- {velocity_python-0.2.1.dist-info → velocity_python-0.2.2.dist-info}/WHEEL +0 -0
- {velocity_python-0.2.1.dist-info → velocity_python-0.2.2.dist-info}/entry_points.txt +0 -0
- {velocity_python-0.2.1.dist-info → velocity_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.2.1.dist-info → velocity_python-0.2.2.dist-info}/top_level.txt +0 -0
velocity/__init__.py
CHANGED
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
|
-
|
|
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
|
|
918
|
-
(they cannot apply the scope)."""
|
|
919
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
``
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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,8 +1,9 @@
|
|
|
1
|
-
velocity/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
219
|
-
velocity_python-0.2.
|
|
220
|
-
velocity_python-0.2.
|
|
221
|
-
velocity_python-0.2.
|
|
222
|
-
velocity_python-0.2.
|
|
223
|
-
velocity_python-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|