Skip to content
2 changes: 1 addition & 1 deletion .agents/skills/write-gatekeeper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {

Strategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C).

- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox).
- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: the workspace then refuses sensitive observations while any unverified collaborator has access (with strategy A that is every collaborator) and latches into a restricted mode that blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers.
- **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings.
- **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings.
- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services).
Expand Down
184 changes: 141 additions & 43 deletions docs/observers.md

Large diffs are not rendered by default.

29 changes: 17 additions & 12 deletions docs/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Authorization is capability-based: `open()` computes the caller's effective role

There are two ways to grant someone collaborator access:

**Direct add.** The owner or an existing collaborator enters a username (email address) in the Share modal. The system looks up the corresponding user account; if it exists, a collaborator record is created. The target user does not receive an in-product notification -- the sharer is expected to send them a link or tell them out of band.
**Direct add.** The owner or an existing collaborator enters a username (an email address on OAuth/CF Access deployments; a normalized alphanumeric handle on password deployments) in the Share modal. The system looks up the corresponding user account; if it exists, a collaborator record is created. The target user does not receive an in-product notification -- the sharer is expected to send them a link or tell them out of band.

**Share link.** Any collaborator (or the owner) can create a share link, which encodes a secret key in the URL as a `#share=<key>` fragment. Anyone who opens this link is automatically added as a collaborator. A link is a durable handle that owns one or more keys: creating it mints its first key, and "copying" the link later mints another key for the same link. The raw key is shown to the creator only once at mint time and is never stored server-side, so re-copying can't reproduce an old key -- it mints a new one. Any of a link's keys can be redeemed by multiple people, or the same person multiple times, until the link is revoked, which invalidates every key minted for it.

Expand All @@ -43,6 +43,8 @@ Storage shape: a link is its first key. The `shareKeys` table holds one row per

Share key redemption and gadget opening happen atomically in a single RPC call (`openGadget(id, shareKey)`), which allows subsequent calls to be pipelined on the returned `Overseer` stub without waiting for a separate redemption step.

Redemption is **pending until verified**: redeeming a key writes the recipient's `shareKey` edge with a `pending` flag, which grants no authority to anyone -- the recipient is invisible to `listCollaborators`, to everyone's effective roles, and to the sensitive-observation coverage guard. Only the open() that performed the redemption counts the edge (to compute the role it must verify the recipient for); the edge is confirmed when that open's observer verification succeeds and severed when it fails, so a refused (or still-verifying) recipient never holds authority. A stale pending edge left by a crashed open is inert and settles on the next redemption of the same link.

### Home page behavior

A shared gadget does not appear on a collaborator's home page until they first open it. At that point, a record is created in the collaborator's user account (via `UserDurableObject.recordSharedGadgetOpen()`), storing a cached copy of the gadget's title and the owner's profile. The `lastActive` timestamp is updated each time they open the gadget.
Expand Down Expand Up @@ -97,22 +99,21 @@ This does mean removed collaborators and revoked links accumulate in storage. Li

### Effective-role algorithm

The core is a **fixed-point role-propagation computation** implemented in `SharingManager.computeEffectiveRoles()`. It computes the effective role of every collaborator (given an optional hypothetical change), returning a map from profile ID to effective role (absence from the map means no access). It is the single source of truth: `open()`, `hasAnyShares()`, the listing RPCs, and the preview methods all derive from it.
The core is a **fixed-point role-propagation computation** implemented in `SharingManager.computeEffectiveRoles()`. It computes the effective role of every collaborator (given an optional hypothetical change), returning a map from profile ID to effective role (absence from the map means no access). It is the single source of truth: `open()`, the listing RPCs, and the preview methods all derive from it.

Inputs (all optional; used to model a hypothetical change in preview):
- `removedUser` -- a profile ID to treat as removed (excluded from the graph).
Inputs (all optional; used to model a hypothetical change):
- `removedUser` -- a profile ID to treat as removed (excluded from the graph). Used by preview.
- `removedEdge` -- a single user edge (`{target, sharer}`) to treat as removed. Used to preview a non-owner removing only their own edge.
- `revokedLinkId` -- a share link ID to treat as revoked.
- `overrides` -- profile IDs pinned to at least a given role regardless of their edges.
- `revokedLinkId` -- a share link ID to treat as revoked. Used by preview.
- `assumePendingLink` -- a `{profileId, linkId}` pair whose pending edge is counted as though it were confirmed. Unlike the removal-shaped preview inputs, this models a hypothetical *grant*: the open() that just redeemed the link counting its own edge to compute the role it must verify for. Every other pending edge contributes nothing.

The algorithm:

1. **Build the candidate set.** Load all collaborators except the (hypothetically) removed user.
2. **Collect share-link metadata.** Build a map from link ID to `{creator, role}`, skipping links that are `revoked` (or the hypothetical `revokedLinkId`).
3. **Initialize** the role map with any `overrides`.
4. **Iterate to fixed point.** Repeatedly scan all collaborators. For each edge, compute the role it grants -- `min(edge role, sharer's effective role)`, where the sharer (or share link creator) is the owner (always `build`) or another collaborator's current effective role -- and raise the collaborator's role to the maximum across their valid edges. Raising one collaborator's role may unlock or raise others on the next pass.
5. **Converge.** Roles only ever increase, so the loop terminates when a full pass changes nothing.
6. **Return the role map.** Collaborators absent from the map have no access; collaborators present with a lower role than before have been downgraded.
3. **Iterate to fixed point.** Repeatedly scan all collaborators. For each edge, compute the role it grants -- `min(edge role, sharer's effective role)`, where the sharer (or share link creator) is the owner (always `build`) or another collaborator's current effective role -- and raise the collaborator's role to the maximum across their valid edges. Pending `shareKey` edges are skipped (except the one named by `assumePendingLink`). Raising one collaborator's role may unlock or raise others on the next pass.
4. **Converge.** Roles only ever increase, so the loop terminates when a full pass changes nothing.
5. **Return the role map.** Collaborators absent from the map have no access; collaborators present with a lower role than before have been downgraded.

This handles arbitrary graph shapes: diamonds (a user reachable via two independent paths), cycles (mutual adds), and deep chains.

Expand Down Expand Up @@ -150,13 +151,17 @@ Authorization is enforced at `open()`: the method computes the caller's effectiv

Because the role is recomputed from the graph on every `open()`, the live computation is the *sole* source of truth for access -- there is no eager cleanup whose bugs could grant access to an unreachable user. This is what makes lazy revocation safe: severing an edge is enough to deny access, even though the unreachable records linger in storage.

A share-key redemption goes through the same gate without ever granting interim authority: the redeemed edge is written pending (see "Adding collaborators"), the redeeming open() verifies the recipient against the role the edge *would* grant, and only a successful verification confirms the edge. After confirming, the role is re-derived from the live graph and re-checked -- which is what denies a redeemer whose link was revoked while their verification was in flight -- and capped at the role verification covered: a role raised mid-verification (say, an owner grant landing while the recipient sat on the configuration modal) takes effect at their next open, which verifies at the wider role's scope. Confirming additionally requires the workspace's connection/binding topology to be unchanged since verification began: any change (a connection or gadget binding added or removed while the recipient sat on the modal) denies the open and reverts the redemption, and a retry re-verifies against the new topology.

### Terminating live sessions on revocation

Authorization is only checked at `open()`, so a session that is *already* open is not re-checked per message. Without intervention, a collaborator who was just removed or downgraded could keep using their live session until something else disconnected them. To close this gap, `removeCollaborator`/`revokeShareLink` proactively restart the gadget's Overseer DO via `ctx.abort()` whenever the change actually removed or downgraded someone (i.e. the returned `AffectedCollaborator[]` is non-empty; pure no-op removals don't restart). Aborting forcibly disconnects every client; each reconnects and re-runs `open()`, which re-evaluates the now-changed permission graph -- sending removed users to the terminal access-denied page and handing downgraded users their reduced capability (the editor swaps to the `use` view automatically based on `metadata.role`). Since removals are rare (and DOs restart unpredictably anyway, so reconnects are already cheap), the disruption is acceptable.

Two precautions surround the abort (`OverseerImpl.scheduleRevocationRestart`): the severed edge is flushed with `ctx.storage.sync()` first (because `ctx.abort()` does not respect the output gate, a restart could otherwise come back with the change lost), and the abort is delayed ~100ms so the triggering RPC's response reaches the caller -- typically the owner, who is also connected -- before their own connection drops. The disconnect reaches the browser through the existing `notifyClosed` plumbing: when the Overseer DO aborts, the per-session `notifyClosed` stub is disposed without being called, which `AuthenticatedApiImpl` treats as a lost connection and reacts to by killing the browser WebSocket, forcing a reconnect.
Two precautions surround the abort (`OverseerImpl.scheduleRevocationRestart`): the severed edge is flushed with `ctx.storage.sync()` first (because `ctx.abort()` does not respect the output gate, a restart could otherwise come back with the change lost), and the abort is delayed ~100ms so the triggering RPC's response reaches the caller -- typically the owner, who is also connected -- before their own connection drops. The disconnect reaches the browser through the existing `notifyClosed` plumbing: when the Overseer DO aborts, the per-session `notifyClosed` stub is disposed without being called, which `AuthenticatedApiImpl` treats as a lost connection and reacts to by killing the browser WebSocket, forcing a reconnect. The client discards its retained share key on the first successful open, so this forced reconnect after a removal is keyless and lands the removed collaborator on the access-denied page rather than silently re-redeeming the still-active link (which would undo the removal and break the assumption stated above). The residual is unchanged: the *link* itself survives a collaborator removal under the lazy model, so a recipient who kept the URL can still re-redeem it manually until the owner revokes it -- the discard removes only the client's automatic re-grant. The same residual bounds the one write-side race in this area: a removal landing while a redemption is mid-verification can sever the pending edge, which the verifying open's confirm then re-adds. That race is narrower than it looks -- a pending-only recipient is invisible to `listCollaborators`, so the removal UI cannot target one mid-verification; a racing removal was necessarily aimed at an edge some earlier open had already confirmed. And the re-add grants no incremental authority: the recipient holds the live link and could simply re-redeem it. In every one of these cases, revoking the link is the durable exclusion (`computeEffectiveRoles` skips revoked links, so even a re-added or re-redeemed edge of a revoked link grants nothing).

Note this is only needed for removals/downgrades. Granting or raising access never strands anyone, and `containsRestrictedData` cannot strand a session either: an observation carrying that flag is *blocked* (rather than applied) unless every current collaborator *in whose verification scope the producing gatekeeper falls* is already a verified observer of it (coverage is held to each collaborator's role scope; see docs/observers.md edge case 4), so no live session ever belongs to someone the flag would newly exclude. Verification, though, is a *live* check re-run at each open: a collaborator whose provider-side access is later revoked may still hold an older session opened while they passed. That is why a failed re-verification scrubs the failed gatekeeper from their persisted observer record (docs/observers.md edge case 3) — from that point the coverage guard blocks that producer's restricted observations, including to the stale session, until a successful re-open.

Note this is only needed for removals/downgrades. Granting or raising access never strands anyone, and `prohibitAllSharing` cannot strand a session either: an observation that would set that flag is *blocked* (rather than applied) if the gadget is already shared, so the flag only ever flips to true on a gadget with no other sessions to evict.
One party the restart deliberately does not cover is a redeemer whose share link is revoked *while their first open is still verifying them*: their edge is pending, so it is invisible to the revocation's affected-set computation and no restart targets them. They are denied anyway -- after verification confirms the edge, `authorizeCollaborator` re-derives the role from the live graph, where the revoked link contributes nothing.

## Future work

Expand Down
6 changes: 3 additions & 3 deletions packages/gatekeeper-google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,9 @@ Such a connection names **two** projects, because a public project can be read b
That split is the point. The session's scope is the *public* project, so the existing
`referencedTables` check rejects any query touching the billing project: the connection spends the
user's money but cannot read a byte of their data. Because it provably reads only data anyone
signed in to Google can already read, its observations do **not** set `prohibitAllSharing` — so a
workspace built on one stays shareable and can still perform actions, rather than dropping into the
lockdown mode that reading a private BigQuery dataset triggers.
signed in to Google can already read, its observations are **not** marked as restricted data — so a
workspace built on one stays shareable and can still fetch from the web, unlike one that has read a
private BigQuery dataset.

Costs still apply: public tables are large and the queries bill the user's project. The default
100 GB `maximumBytesBilled` cap and the mandatory dry-run gate apply exactly as they do to a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ describe("public resource URLs", () => {
}
});

// The check the "reads only public data" label -- and so the sharing exemption -- rests on.
// Without it a binding could be pointed at a private project the user's token can reach.
// The check the "reads only public data" label -- and so the restricted-data exemption -- rests
// on. Without it a binding could be pointed at a private project the user's token can reach.
it("rejects a data project outside the allowlist", () => {
expect(() => parse(`https://${BIGQUERY_PUBLIC_HOST}/my-project/my-private-project`))
.toThrow(/not a known public BigQuery project/);
Expand Down
6 changes: 3 additions & 3 deletions packages/gatekeeper-google/src/bigquery-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ export type PublicDataProject = {
* The projects a public-data binding is allowed to read.
*
* An allowlist rather than free text, because it is what makes a public binding's "reads only
* public data" label true: that label is why such a binding is exempted from `prohibitAllSharing`
* (see `BigQuerySessionImpl`), and free text would let a binding titled "public data" read
* whatever private project the user's own token happens to reach.
* public data" label true: that label is why such a binding is exempted from
* `containsRestrictedData` (see `BigQuerySessionImpl`), and free text would let a binding titled
* "public data" read whatever private project the user's own token happens to reach.
*
* Every entry grants `roles/bigquery.dataViewer` to `allAuthenticatedUsers`. Adding one is a
* one-line data change; removing one correctly disables existing bindings, since the allowlist is
Expand Down
5 changes: 2 additions & 3 deletions packages/gatekeeper-google/src/bigquery-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,8 @@ export type BigQueryQueryOptions = {
* projects (e.g. `bigquery-public-data`), which any Google account can read, and separately names
* one of the user's own projects to bill the query jobs to. Only the public project is readable:
* a query referencing a table in the billing project — or in any other project — is rejected, so a
* join between a public table and the user's own data is not possible through this connection.
* Reading through it does not lock the workspace down or prohibit sharing it, since anyone signed
* in to Google could read the same rows.
* join between a public table and the user's own data is not possible through this connection. Its
* results are not treated as restricted data, since anyone signed in to Google could read them.
*
* BigQuery uses Google Standard SQL by default. Legacy SQL is not supported.
*/
Expand Down
Loading
Loading