| """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 |
|
|
| |
| |
| |
| SHARES_KEY = 'object_shares' |
|
|
| |
| |
| KINDS = ('view', 'folder', 'database') |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 {} |
| |
| |
| |
| 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) |
| 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: |
| |
| |
| |
| 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') |
|
|