"""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')