loopable / platform /core /shares.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
f546440 verified
Raw
History Blame Contribute Delete
12.1 kB
"""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')