File size: 12,068 Bytes
ea2c336 f546440 ea2c336 ef68ae0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | """core/shares.py β ONE grant registry for every shareable object (wave 20, owner ruling R10).
WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
that can add or revoke people later, on any of the three.
WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
(`core/table_store.py`) and that stays β moving it would rewrite every stored view for no gain.
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
definition; giving each its own grant field would put the same three-line permission decision in
three files owned by two sessions, which is how the three drift. One registry, one predicate,
three callers.
shares.set_grants(kind, oid, entries, owner=β¦, st=β¦) # replaces the whole grant set
shares.grants(kind, oid, st=β¦) # -> {'owner': str, 'entries': [...]}
shares.role_for(kind, oid, user, is_admin=β¦, st=β¦) # -> 'owner'|'edit'|'view'|None
shares.shared_with(user, kind=β¦, st=β¦) # -> [oid] this user was granted
THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
`edit` = may also change the object's CONTENT. Neither ever means "may re-share": changing grants
is the OWNER's (or an admin's), which is `table_store._may_administer`'s existing rule promoted to
every kind. A collaborator who could rewrite grants could grant themselves sole ownership of
somebody else's object, or quietly widen a users-scoped share to everyone.
β AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β junk in the bucket, a missing
owner, an unknown role string all resolve to None rather than to a default that opens something.
[[aios-permissioning]]: no fail-open defaults, ever.
β THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
(`core.store`) is tenant #0 and exists for the same reason it does everywhere else β the ~28
callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
"""
import core.store as store
#: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
#: is a question across kinds β the "Shared with me" folder (R10) is exactly that query, and
#: three separate buckets would make it three reads that can disagree about what a user can see.
SHARES_KEY = 'object_shares'
#: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
#: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
KINDS = ('view', 'folder', 'database')
#: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
#: Spelled as a single character so it can never collide with a username (usernames are lower-case
#: and non-empty by `core/users.py`, and are checked against this explicitly below).
#:
#: ββ **AND WHAT THAT MEANS DEPENDS ON THE KIND β THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
#: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
#: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
#: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
#: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
#: whole point:
#: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β
#: the sentence holds. `require_session` plus the topic's own gate run first, and the
#: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
#: merged, so a grant can only narrow-or-equal what that account could already reach.
#: * `kind='database'` on a `ut_*` table β **THE SENTENCE IS FALSE AND THIS REGISTRY IS THE
#: ONLY WALL.** `routes_admin._PERM_MODULES` is `("customer_data", "product_data")` and
#: `_clean_perms` 400s anything else, so no row filter and no hidden field can even be
#: DECLARED for a user table; `routes_tables.py` makes zero `perm_scope` calls and passes
#: `hidden_keys=frozenset()`. A `database` grant is therefore ALL-OR-NOTHING β every row,
#: every column β and an `*` database grant admits every account in the tenant to all of it.
#:
#: β THAT IS A REAL CAPABILITY, DELIBERATELY KEPT, not a hole to plug in passing. What was wrong
#: was a docstring promising a second wall that does not exist for this kind; scoping user tables
#: is booked (S-8), not done. `routes_shares.py`'s module docstring says the same thing at the
#: OTHER door β the audit's fix is "say so at both", and one door saying it is how the next reader
#: gets the confident half [[one-question-two-normalizers]].
EVERYONE = '*'
ROLES = ('view', 'edit')
def _st(st):
return st if st is not None else store
def _check_kind(kind):
k = str(kind or '').strip().lower()
if k not in KINDS:
raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)} β '
f'refusing to invent a namespace from a typo.')
return k
def _clean_entries(entries):
"""Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
that just listed the people it is about to grant, so a rejected row must not abort the whole
save β but an entry with an unknown ROLE must not be stored as something else's default
either. Dropped, never coerced.
"""
out, seen = [], set()
for e in entries or ():
if not isinstance(e, dict):
continue
user = str(e.get('user') or '').strip().lower()
role = str(e.get('role') or '').strip().lower()
if not user or role not in ROLES or user in seen:
continue
seen.add(user)
out.append({'user': user, 'role': role})
return out
def grants(kind, oid, st=None):
"""`{'owner': str|None, 'entries': [{'user','role'}]}` β never raises on a junk bucket."""
kind = _check_kind(kind)
try:
bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
rec = bucket.get(str(oid)) or {}
except Exception:
return {'owner': None, 'entries': []}
if not isinstance(rec, dict):
return {'owner': None, 'entries': []}
return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
'entries': _clean_entries(rec.get('entries'))}
def set_grants(kind, oid, entries, owner=None, st=None):
"""REPLACE the grant set for one object. Returns the stored record.
β REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
manage-access editor R10 asks for is exactly "here is the list now".
"""
kind = _check_kind(kind)
oid = str(oid)
clean = _clean_entries(entries)
owner_l = str(owner).strip().lower() if owner else None
def _apply(data):
by_kind = dict(data.get(kind) or {})
prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
# The owner is STICKY: set once, and a later save that omits it must not orphan the
# object. An ownerless grant record cannot answer "who may re-share this", so every
# administer check would fail closed and the object would become unmanageable.
keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
if prior.get('owner') else None)
if not clean and not keep_owner:
by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
else:
by_kind[oid] = {'owner': keep_owner, 'entries': clean}
data[kind] = by_kind
return data
_st(st).update(SHARES_KEY, _apply, flush='async')
return grants(kind, oid, st=st)
def role_for(kind, oid, user, is_admin=False, st=None):
"""`'owner'` | `'edit'` | `'view'` | `None` β the caller's effective role, fail-closed.
An ADMIN reads as `'owner'`: an admin who could not administer an object could not
administer the tenant either, which is `table_store._may_administer`'s existing rule and is
kept identical here so the two cannot disagree about the same view.
"""
user = str(user or '').strip().lower()
if not user:
return None
rec = grants(kind, oid, st=st)
if is_admin or (rec['owner'] and rec['owner'] == user):
return 'owner'
best = None
for e in rec['entries']:
if e['user'] == user or e['user'] == EVERYONE:
# The STRONGER of the two wins when both a personal and an everyone grant exist:
# naming somebody explicitly is how you RAISE them above the room, so an
# everyone-view + alice-edit pair must leave alice editing.
if e['role'] == 'edit':
return 'edit'
best = best or 'view'
return best
def may_see(kind, oid, user, is_admin=False, st=None):
return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
def may_edit(kind, oid, user, is_admin=False, st=None):
return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
def may_administer(kind, oid, user, is_admin=False, st=None):
"""Only the owner or an admin may change grants or delete. See the module note on why this
is deliberately narrower than `may_edit`."""
return role_for(kind, oid, user, is_admin=is_admin, st=st) == 'owner'
def shared_with(user, kind=None, st=None):
"""Every object id this user has been granted (excluding what they own).
This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
you made is not something shared *with* you, and listing it there would make the system
folder a duplicate of the rail above it.
"""
user = str(user or '').strip().lower()
if not user:
return {}
try:
data = _st(st).get(SHARES_KEY) or {}
except Exception:
return {}
out = {}
for k in ([_check_kind(kind)] if kind else KINDS):
hits = []
for oid, rec in (data.get(k) or {}).items():
if not isinstance(rec, dict):
continue
owner = str(rec.get('owner') or '').strip().lower()
if owner == user:
continue
for e in _clean_entries(rec.get('entries')):
if e['user'] in (user, EVERYONE):
hits.append(str(oid))
break
out[k] = sorted(hits)
return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
def drop_objects(pairs, st=None):
"""Remove whole grant RECORDS, owner husk included β wave 21, item 6a (C3).
A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
transaction for the whole sweep β a table delete drops its database grant plus a view
grant per view that lived in its bucket."""
want = {}
for kind, oid in pairs or ():
want.setdefault(_check_kind(kind), set()).add(str(oid))
if not want:
return
def _apply(data):
for kind, oids in want.items():
by_kind = data.get(kind)
if isinstance(by_kind, dict):
for oid in oids:
by_kind.pop(oid, None)
return data
_st(st).update(SHARES_KEY, _apply, flush='async')
|