loopable / api /routes_shares.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
22.6 kB
"""routes_shares.py β€” the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
`kind` ∈ view | folder | database. Roles are `view` | `edit` β€” the same two words the view rail
already speaks, now extended to folders and databases so there is ONE vocabulary in the UI
(R10: "the same picker views use").
β›” **RE-SHARING IS THE OWNER'S, AND THAT IS ENFORCED HERE, NOT IN THE CLIENT.** `PUT` requires
`shares.may_administer` (owner or admin). A collaborator with `edit` may change an object's
CONTENT and may not change who else can reach it β€” otherwise anyone you shared a view with could
widen it to everyone, or grant themselves ownership and lock you out. The client greys the editor
for non-administrators; that is a courtesy, and this check is the wall.
⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL β€” ON A GOVERNED MODULE.** `*` ("everyone") means
every account that can already open the surface: `require_session` plus the topic's own gate run
first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
could already reach the data ([[aios-permissioning]]).
β›”β›” **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
DECLARED for a `ut_*` database** β€” `routes_tables.py` makes zero `perm_scope` calls and passes
`hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
by: **this registry IS the wall.** So a `database` grant is 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; what was wrong was a docstring promising a second
wall that does not exist for this kind. Scoping user tables is booked, not done
(`waves/wave32/sharing-audit.md` S-8).
⚠ **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
β€” the view's own `permissions` β€” is what actually decides who may OPEN a view.** A grant here
whose object is invisible under that one is a row in a list that opens a refusal, which is what
made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
the two vocabularies are still two.
"""
from fastapi import APIRouter, Body, Depends
import core.shares as shares
import core.users as users
from deps import Session, err, require_session
# ⭐ W32-T28 (C3) β€” the SHARE notification's topic word, imported from the module that CLASSIFIES
# it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
# reader agreeing about one string is the whole difference between an Inbox row that opens the
# shared database and one that is quietly unclickable.
from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
router = APIRouter(prefix="/api/v1")
def _kind_or_400(raw):
try:
return shares._check_kind(raw)
except ValueError as e:
raise err(400, "bad_kind", str(e))
# ── ⭐⭐ WAVE 32 Β· T26 (owner item 18, ruling R12) β€” THE WALL THIS FILE SAID IT HAD ─────────────
#
# `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
# means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
# strings off the URL and the only dependency was `require_session`, so any signed-in account
# could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
# an object with no grant record skipped the check entirely and the caller was stamped OWNER β€”
# sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
# argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
#
# ⚠ AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
# "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
# victim next opens the dialog.
#: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
#: route is not told which topic β€” so resolving one means asking each.
_BUILTIN_TOPICS = ("customer", "product")
def _topics(session):
"""Every topic whose workspace could hold a view or folder for this tenant.
⚠ `all_defs`, never `all_tables` β€” the latter is the whole 28.6 MB row payload (~703 ms on
tenant #0) to answer a question about KEYS (D-185).
"""
try:
import core.user_tables as ut
return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
except Exception: # noqa: BLE001
return _BUILTIN_TOPICS
def _owns_object(session, kind, oid):
"""May this caller CLAIM an object that has no grant record yet β€” i.e. do they own it?
β›” THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
each topic's workspace in turn, which is N store reads; making every share call pay that
would put a loop on a route the manage-access dialog opens. The dangerous path is the one
where a caller is about to be stamped OWNER of something nobody owns β€” so the resolution runs
exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
"""
if session.admin:
return True
if kind == "database":
# ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
# admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
# here would be the second definition this wave keeps finding.
try:
import core.user_tables as ut
return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
st=session.runtime))
except Exception: # noqa: BLE001
return False
try:
import core.table_store as table_store
except Exception: # noqa: BLE001
return False
for topic in _topics(session):
try:
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
except Exception: # noqa: BLE001
continue
if hit:
# `find_view`/`find_folder` answer `(owner_username, …)`. The claim belongs to the
# person whose personal stratum holds it β€” anybody else reaching this line is
# exactly the case S-1 describes.
return str(hit[0]) == str(session.uname)
return False
def _can_see_object(session, kind, oid):
"""May this caller READ an object's grant list β€” i.e. can they reach the object at all?
β›”β›” THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
`permissions.edit = "collaborative"` and no grant record yet β€” a view bob **can open and edit
in the grid** β€” answered `404` when bob opened its manage-access dialog. Measured before
fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
`404 no_object`.
⚠ THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
`permissions` (system B) β€” which is the one that actually decides who may OPEN it.
⚠ And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β€”
'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
legitimate collaborator their view does not exist.
β›” THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
owner β€” that is S-1, and widening this predicate onto `put_share` would re-open it.
"""
if _owns_object(session, kind, oid):
return True
if kind != "view":
# A folder carries no per-object visibility flag of its own, and a database's `may_open`
# (inside `_owns_object`) already admits grantees. Nothing wider to ask.
return False
try:
import core.table_store as table_store
for topic in _topics(session):
hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
if hit:
return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
session.uname, is_admin=session.admin))
except Exception: # noqa: BLE001
return False
return False
def _entries_or_400(session, entries):
"""Validate a grant list against the tenant's REAL, ACTIVE accounts β€” and refuse BY NAME.
β›” `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
of a string and the role word β€” never that the user EXISTS, is ACTIVE, or is in this tenant**,
so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
sharer believes the person has access. That is item 18's plain reading.
⚠ The correct population is computed THREE FUNCTIONS BELOW and served to the picker
(`_people`). One route, two populations, and the write door was the permissive one.
⚠ `*` (everyone) is not a user and is admitted deliberately β€” it is R10's vocabulary for
"every account that can already open the surface".
"""
known = {p["username"].strip().lower() for p in _people(session.tenant)}
unknown = []
for e in entries or ():
if not isinstance(e, dict):
continue
user = str(e.get("user") or "").strip().lower()
if user and user != shares.EVERYONE and user not in known:
unknown.append(user)
if unknown:
raise err(400, "unknown_people",
"no active account in this workspace is named "
+ ", ".join(sorted(set(unknown)))
+ " β€” nothing was shared. Pick people from the list rather than typing a name.")
@router.get("/share/mine")
def my_shares(session: Session = Depends(require_session)):
"""Everything shared WITH me, by kind β€” the "Shared with me" rail section (R10).
Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
that is not malformed at all.
"""
return shares.shared_with(session.uname, st=session.runtime)
@router.get("/share/{kind}/{oid}")
def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
kind = _kind_or_400(kind)
rec = shares.grants(kind, oid, st=session.runtime)
role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
st=session.runtime)
# ⭐ W32-T26 (audit S-3) β€” A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
# who owns it, everyone it is granted to, and the tenant's whole username↔name directory β€”
# to any signed-in session, about objects it cannot open. Now a caller with no role on an
# object must prove they can reach it, and gets a 404 otherwise: the same answer a
# non-existent id gives, so the route cannot be used to probe which ids are real.
# ⚠ `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
# caller who has no relationship with the object at all.
if role is None and not _can_see_object(session, kind, oid):
raise err(404, "no_object", "no such item, or it is not shared with this account")
return {
**rec,
"role": role,
"mayAdminister": may_admin,
# ⚠ WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
# them. `assignable_people` serves bare display names because `user`-kind CELLS store
# display names β€” that list's shape cannot change without migrating cell values β€” so
# this route serves objects of its own. Existing grants that were written as lowercased
# display names are normalised by the wave-21 cleanup script.
# ⭐ W32-T26 (audit S-3) β€” the roster is the EDITOR's data, so it rides only for a caller
# who may open the editor. A read-only grantee gets the grant list (their fair question is
# "who else has this?") and not a directory of every account in the workspace.
"people": _people(session.tenant) if may_admin else [],
}
def _people(tenant):
"""[{username, name}] for this tenant β€” same population as `assignable_people`, with the
BINDING identity alongside the display one."""
try:
reg = users.registry() or {}
except Exception:
return []
want = str(tenant or '').strip().lower()
out = []
for uname, u in reg.items():
if not isinstance(u, dict) or u.get('active') is False:
continue
if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
continue
out.append({"username": str(uname), "name": str(u.get('name') or uname)})
return sorted(out, key=lambda p: p["name"].lower())
@router.put("/share/{kind}/{oid}")
def put_share(kind: str, oid: str, body: dict = Body(default=None),
session: Session = Depends(require_session)):
kind = _kind_or_400(kind)
body = body or {}
rec = shares.grants(kind, oid, st=session.runtime)
# An object with NO grant record yet has no owner β€” the first person to share it claims it.
# That is safe because reaching this route at all means passing the surface's own wall, and
# the alternative (refusing until somebody seeds an owner) would make a brand-new folder
# unshareable by the person who just made it.
if rec["owner"]:
if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
st=session.runtime):
raise err(403, "not_owner",
"only the owner of this item (or an administrator) can change who it is "
"shared with")
# β›”β›” W32-T26 (audit S-1) β€” THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
# is still claimed by the first person to share it β€” that rule is right, and refusing until
# somebody seeds an owner would make a brand-new folder unshareable by the person who just
# made it. What was missing is the half the old comment ASSERTED and the code never did: the
# claimant has to be able to reach the object. Without this, any signed-in account could
# stamp itself owner of an id it had never seen and lock the real creator out for good.
elif not _owns_object(session, kind, oid):
raise err(404, "no_object", "no such item, or it is not shared with this account")
entries = body.get("entries")
if not isinstance(entries, list):
raise err(400, "bad_entries",
"entries must be a list of {user, role} β€” send [] to un-share, which is how "
"revoking is expressed")
_entries_or_400(session, entries)
out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
st=session.runtime)
_notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
return out
def _notify_new_grantees(session, kind, oid, before, after):
"""⭐⭐ W32-T28 (owner item 18's last clause, contract C3) β€” tell the RECEIVER, in their Inbox.
Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
silent: the grant landed in a rail section the receiver had to notice on their own, which is
why "I shared it with you" and "I never saw it" were both true.
β›” WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
because an alert is a live question about rows; a share is an EVENT that happened once, and
polling for it would mean re-deriving "was this new?" on every inbox open β€” the diff below
only exists here, at the moment the set changes.
⚠ ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
so every save re-sends everyone who was already there. Diffing against `before` is what stops
a rename or a role change from ringing the bell for people whose access did not change.
⚠ `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
⚠ IT NEVER RAISES. A notification that fails must not fail the share that triggered it β€” the
grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
"""
try:
was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
fresh = [str(e.get("user")) for e in (after or ())
if isinstance(e, dict) and e.get("user") not in was
and e.get("user") != shares.EVERYONE]
if not fresh:
return
import core.alerts as alerts
label, route, view_id = _object_ref(session, kind, oid)
if not route:
# β›” NO ROUTE, NO NOTIFICATION β€” the receiver would get a row that opens nothing, and
# `notification_view` would have to invent a target. Silence is the honest answer
# here; the rail still shows the grant under "Shared with me".
return
sharer = str(session.user.get("name") or session.uname)
for user in fresh:
# ⚠ THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
# agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
# `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
# from there rather than typed again β€” one vocabulary, one owner.
alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
detail=f"{sharer} shared this with you", st=session.runtime)
except Exception: # noqa: BLE001
return
def _object_ref(session, kind, oid):
"""`(label, route, view_id)` β€” what to CALL the shared thing, and where it OPENS.
β›” THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
`target: {module: "database", id: "view_42"}` β€” an instruction to open a database named
`view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
travel. Caught by looking at the notification the driver actually produced, not by reading
the code back.
⚠ `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
receiver nothing they can act on, and the id is already in the target.
⚠ An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
a row that opens nowhere.
"""
try:
if kind == "database":
import core.user_tables as ut
defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
# A user table IS its own route key in both vocabularies (`route_for_topic`).
return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
import core.table_store as table_store
from routes_alerts import route_for_topic
for topic in _topics(session):
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
if not hit:
continue
route = route_for_topic(topic)
if not route:
break
row = hit[1] if len(hit) > 1 else {}
name = str((row or {}).get("name") or "").strip()
# ⚠ Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
# the grid and stops there rather than naming a view the receiver did not get.
return (name or ("A view" if kind == "view" else "A folder"),
route, str(oid) if kind == "view" else "")
except Exception: # noqa: BLE001
pass
return ({"view": "A view", "folder": "A folder"}.get(kind, "An item"), None, "")