| """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
|
|
|
|
|
|
|
|
|
| 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))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _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:
|
| 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":
|
|
|
|
|
|
|
| try:
|
| import core.user_tables as ut
|
| return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
|
| st=session.runtime))
|
| except Exception:
|
| return False
|
| try:
|
| import core.table_store as table_store
|
| except Exception:
|
| 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:
|
| continue
|
| if hit:
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
| 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:
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| "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)
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
| return
|
| sharer = str(session.user.get("name") or session.uname)
|
| for user in fresh:
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 {}
|
|
|
| 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()
|
|
|
|
|
| return (name or ("A view" if kind == "view" else "A folder"),
|
| route, str(oid) if kind == "view" else "")
|
| except Exception:
|
| pass
|
| return ({"view": "A view", "folder": "A folder"}.get(kind, "An item"), None, "")
|
|
|