(collaboration) show a user the history since they got access

The collaboration server's activity and changeset routes are opened to
the browser, so a document's editing history can be read from where it
actually lives now. What a user may see of it is bounded to the moment
they were given access to the document: joining a document that has been
written for a year does not hand them the year.

That rule is not new. It is the one the version endpoints have always
applied - "only those created after the user got access to the document"
- and the date is the same one: the earliest access the user holds on
the document or on any of its ancestors, so sharing a folder shares its
subtree from that moment. It was computed twice in the backend,
differently, and exposed nowhere. It is now a single annotation,
user_access_since, that the version endpoints and the collaboration
server both read, the latter through the document detail response it
already fetches to authorize a connection.

The bound is applied server-side and silently: a client asks for
whatever range it likes and receives only its own share, so there is no
bound for it to get wrong and none it can widen. It is a stored date
rather than a wall-clock-relative one, which is what keeps it stable
across a websocket re-check, and it is never zero - the one value that
would also unlock a full-history connection.

A reader who reaches a document through its link alone holds no access
and so has no date to bound a history with. They get none, which is why
the backend has always refused them their versions. rollback and prune
stay refused to everyone: restoring a version is a separate decision.

Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-08-28 16:42:00 +02:00
parent ae9e49edcf
commit 66ef99b3b9
11 changed files with 418 additions and 75 deletions
+17 -6
View File
@@ -20,14 +20,25 @@ and this project adheres to
### Added
- ✨(collaboration) grant the browser only the two routes it uses. The
- ✨(collaboration) let a user read the document's editing history from the
moment they were given access to it. The collaboration server's `activity` and
`changeset` routes are opened to the browser, bounded per user to the earliest
access they hold on the document or on one of its ancestors — the same cut-off
the version endpoints have always applied, now computed once in the backend
(`user_access_since`) and applied by the collaboration server as well. The
bound is enforced server-side and silently: a client asks for whatever range it
likes and receives only its own share. A reader who reaches a document through
its link alone holds no access and so has no date to bound a history with, and
gets none — as they never did
- ✨(collaboration) grant the browser only the routes it uses. The
collaboration server now answers what a caller may do with a document as a
permission object, facet by facet, and enforces every facet itself. A browser
is granted the websocket and the document route the http fallback polls, and
nothing else — the history, activity, changeset, rollback and prune routes,
every backend-internal endpoint, and any endpoint a future release adds are
refused to it. `create-ydoc` in particular was reachable by any signed-in
editor and is now the backend's alone. The backend's admin token keeps full
is granted the websocket, the document route the http fallback polls and the
two history routes, and nothing else — rollback, prune, every backend-internal
endpoint, and any endpoint a future release adds are refused to it.
`create-ydoc` in particular was reachable by any signed-in editor and is now
the backend's alone. The backend's admin token keeps full
access, minus the irreversible content erasure that the new version exposes
over http for the first time
+23 -1
View File
@@ -22,7 +22,7 @@ The Django backend reads and writes document content there too, so point it at t
YHUB_API_BASE_URL: http://{yhub-service}:443
```
Prefer the internal service url: the routes the backend calls are not meant to be reachable from the outside. Route `/collaboration/ws/` to the service publicly — that is the one the browsers open — plus the document routes (`/collaboration/ydoc/`, `rollback`, `prune`, `changeset`, `activity`) and `/collaboration/jwks/`, which carries public keys and nothing else. Keep `reset-connections`, `migrate`, `restore-ydoc`, `reset-ydoc` and `create-ydoc` in-cluster.
Prefer the internal service url: the routes the backend calls are not meant to be reachable from the outside. Route `/collaboration/ws/` to the service publicly — that is the one the browsers open — plus `/collaboration/ydoc/` for the http fallback, `/collaboration/activity/` and `/collaboration/changeset/` for the editing history, and `/collaboration/jwks/`, which carries public keys and nothing else. Keep everything else in-cluster: `rollback`, `prune`, `reset-connections`, `migrate`, `restore-ydoc`, `reset-ydoc` and `create-ydoc` are refused to a browser by the permission tables anyway, and an endpoint that cannot be reached cannot be probed.
Both directions are authenticated with short-lived RS256 JWTs rather than a shared secret, and each side verifies the other against the JWKS it publishes — so both need a signing key of their own, and neither needs a copy of the other's:
@@ -109,3 +109,25 @@ fallback request, so a modified or stale client changes nothing. See the access-
Note this is deliberately stricter than the collaboration server's own default, which lets
read-only connections broadcast cursors.
## How much history a user may see
The editing history is bounded per user: **you see the document's history from the moment you were
given access to it, and no further back.** Joining a document that has been written for a year does
not hand you the year — it hands you what happened since you arrived.
This is not a new rule. It is the one the version endpoints have always applied ("only those
created after the user got access to the document"); it now also bounds the collaboration server's
`activity` and `changeset` routes, which are what a history view is built on.
The date is the earliest access you hold on the document **or on one of its ancestors** — share a
folder with someone and they get its whole subtree from that moment, including documents created in
it later. The backend computes it (`user_access_since` on the document endpoint) and the
collaboration server turns it into the start of the history it will serve. The bound is applied
server-side and silently: a client asks for whatever range it likes and receives only its own
share, so there is no bound for it to get wrong and none it can widen.
A reader who reaches a document through its link alone — a public or authenticated-reach document
they hold no access on — gets **no history at all**, not a bounded one. There is no access record
and therefore no date to bound it with, which is the same reason the version endpoints have always
refused them.
+12
View File
@@ -183,6 +183,16 @@ class DocumentSerializer(ListDocumentSerializer):
file = serializers.FileField(
required=False, write_only=True, allow_null=True, max_length=255
)
# When the current user gained access to this document — the earliest access they hold on
# it or on one of its ancestors, `null` when they reach it through its link reach alone.
# It bounds the history they may read: the collaboration server fetches this endpoint to
# authorize a connection and turns this into `history.from`.
#
# Read from the `user_access_since` annotation, which `filter_queryset` applies — so it is
# answered on the retrieve endpoint, the one that is read for it. It falls back to `null`
# on the write responses (create/update), which serialize an instance that never came from
# that queryset; nothing consumes it there.
user_access_since = serializers.DateTimeField(read_only=True, default=None)
class Meta:
model = models.Document
@@ -208,6 +218,7 @@ class DocumentSerializer(ListDocumentSerializer):
"path",
"title",
"updated_at",
"user_access_since",
"user_role",
]
read_only_fields = [
@@ -229,6 +240,7 @@ class DocumentSerializer(ListDocumentSerializer):
"numchild",
"path",
"updated_at",
"user_access_since",
"user_role",
]
+23 -22
View File
@@ -21,7 +21,7 @@ from django.core.validators import URLValidator
from django.db import DatabaseError, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
from django.db.models.functions import Greatest, Left, Length
from django.db.models.functions import Greatest
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
@@ -561,6 +561,7 @@ class DocumentViewSet(
all_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
descendants_serializer_class = serializers.ListDocumentSerializer
favorite_list_serializer_class = serializers.ListDocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
@@ -605,6 +606,9 @@ class DocumentViewSet(
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_user_has_link_trace(user)
# Detail views only — `list` builds its own annotation chain below and does not need
# this one, so the list endpoint keeps its current query cost
queryset = queryset.annotate_user_access_since(user)
return queryset
@@ -1805,16 +1809,14 @@ class DocumentViewSet(
document = self.get_object()
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.Value(document.path), Length("document__path")),
).aggregate(min_date=db.Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return drf.exceptions.PermissionDenied(
# document. `user_access_since` is annotated onto the queryset (see
# `DocumentQuerySet.annotate_user_access_since`) and is the one definition of that
# date — the collaboration server is handed the same value to bound the history it
# serves. It is None for a user who reaches the document through its link reach
# alone: no access, no date, and so no history.
min_datetime = document.user_access_since
if min_datetime is None:
raise drf.exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
@@ -1836,22 +1838,21 @@ class DocumentViewSet(
"""Custom action to retrieve a specific version of a document"""
document = self.get_object()
# Don't let users access versions that were created before they were given access to
# the document — the same cut-off as `versions_list`, from the same annotation.
# Checked before the object is fetched: a caller who may see no version at all should
# not learn from a 404 whether this one exists.
min_datetime = document.user_access_since
if min_datetime is None:
raise drf.exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
try:
response = document.get_content_response(version_id=version_id)
except (FileNotFoundError, ClientError) as err:
raise Http404 from err
# Don't let users access versions that were created before they were given access
# to the document
user = request.user
min_datetime = min(
access.created_at
for access in models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.Value(document.path), Length("document__path")),
)
)
if response["LastModified"] < min_datetime:
raise Http404
+40
View File
@@ -924,6 +924,46 @@ class DocumentQuerySet(MP_NodeQuerySet):
user_roles=models.Value([], output_field=output_field),
)
def annotate_user_access_since(self, user):
"""
Annotate document queryset with the moment the current user gained access to the
document the earliest access they hold on it or on one of its ancestors.
This is the point from which they may see the document's history: the collaboration
server turns it into a `history.from` permission, and the version endpoints filter on
it. `None` when the user reaches the document through its link reach alone, which is
not an access and carries no date those users get no history at all, deliberately
(see the comment in `get_abilities`).
"""
if user.is_authenticated:
# the same ancestor-aware subquery as `annotate_user_roles`: the access's document
# path is a prefix of this one's, so it matches the document and every ancestor
user_access_since_subquery = (
DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(
models.OuterRef("path"), Length("document__path")
),
)
.order_by()
.values("user")
.annotate(min_created_at=models.Min("created_at"))
.values("min_created_at")
)
return self.annotate(
user_access_since=models.Subquery(
user_access_since_subquery,
output_field=models.DateTimeField(),
)
)
return self.annotate(
user_access_since=models.Value(
None, output_field=models.DateTimeField()
),
)
def annotate_user_has_link_trace(self, user):
"""
Annotate document queryset with a boolean to know if the current user
@@ -83,6 +83,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": None,
"user_role": None,
}
@@ -159,6 +160,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": None,
"user_role": None,
}
@@ -268,6 +270,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": None,
"user_role": None,
}
assert (
@@ -351,6 +354,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": None,
"user_role": None,
}
@@ -465,6 +469,9 @@ def test_api_documents_retrieve_authenticated_related_direct():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": access.created_at.isoformat().replace(
"+00:00", "Z"
),
"user_role": access.role,
}
@@ -548,6 +555,9 @@ def test_api_documents_retrieve_authenticated_related_parent():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": access.created_at.isoformat().replace(
"+00:00", "Z"
),
"user_role": access.role,
}
@@ -678,6 +688,14 @@ def test_api_documents_retrieve_authenticated_related_team_members(
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
# the history this user may read starts at the earliest access they hold —
# here through one of their teams
expected_access_since = (
models.DocumentAccess.objects.filter(document=document, team__in=teams)
.earliest("created_at")
.created_at.isoformat()
.replace("+00:00", "Z")
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
@@ -704,6 +722,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": expected_access_since,
"user_role": role,
}
@@ -744,6 +763,14 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
# the history this user may read starts at the earliest access they hold —
# here through one of their teams
expected_access_since = (
models.DocumentAccess.objects.filter(document=document, team__in=teams)
.earliest("created_at")
.created_at.isoformat()
.replace("+00:00", "Z")
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
@@ -770,6 +797,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": expected_access_since,
"user_role": role,
}
@@ -810,6 +838,14 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
# the history this user may read starts at the earliest access they hold —
# here through one of their teams
expected_access_since = (
models.DocumentAccess.objects.filter(document=document, team__in=teams)
.earliest("created_at")
.created_at.isoformat()
.replace("+00:00", "Z")
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
@@ -836,6 +872,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_access_since": expected_access_since,
"user_role": role,
}
@@ -1053,3 +1090,58 @@ def test_api_documents_retrieve_permanently_deleted_related(role, depth):
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_documents_retrieve_user_access_since_is_ancestor_aware():
"""
`user_access_since` is the earliest access the user holds on the document or on any of
its ancestors. It is what bounds the history they may read the collaboration server
turns it into `history.from` so a later access on the document itself must not shorten
what an earlier one on a parent already gave them.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="restricted")
child = factories.DocumentFactory(parent=parent, link_reach="restricted")
ten_days_ago = timezone.now() - timedelta(days=10)
with mock.patch("django.utils.timezone.now", return_value=ten_days_ago):
parent_access = factories.UserDocumentAccessFactory(
document=parent, user=user, role="reader"
)
# granted later, and deliberately the stronger role: recency must not win
factories.UserDocumentAccessFactory(document=child, user=user, role="editor")
response = client.get(f"/api/v1.0/documents/{child.id!s}/")
assert response.status_code == 200
expected = parent_access.created_at.isoformat().replace("+00:00", "Z")
assert response.json()["user_access_since"] == expected
def test_api_documents_retrieve_user_access_since_is_null_without_an_access():
"""
A user who reaches a document through its link reach alone holds no access, so there is
no date to bound their history with and they get none at all. This is the reason the
version endpoints have always refused them, and the collaboration server withholds the
`history` facet on the same grounds.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
# anonymous
assert (
APIClient().get(f"/api/v1.0/documents/{document.id!s}/").json()[
"user_access_since"
]
is None
)
# signed in, but still reaching it only through the link
client = APIClient()
client.force_login(factories.UserFactory())
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json()["user_access_since"] is None
+6
View File
@@ -127,8 +127,14 @@ ingressCollaborationApi:
##
## `jwks` is public on purpose: it carries the public halves of the keys
## yhub signs with, and nothing else.
##
## `activity` and `changeset` carry the editing history, bounded per user to
## the moment they were given access to the document — a user who holds no
## access on it, only its link, is refused both.
paths:
- /collaboration/ydoc/
- /collaboration/activity/
- /collaboration/changeset/
- /collaboration/jwks/
## @param ingressCollaborationApi.hosts Additional host to configure for the Ingress
hosts: []
+50 -28
View File
@@ -80,14 +80,14 @@ It is not a fork of yhub — it is a thin wrapper:
- mirrors the environment conventions used elsewhere in this repository
(`*_FILE` secret indirection, `COLLABORATION_SERVER_ORIGIN` allowlist, …).
Public exposure: the browser needs two routes, the websocket
`/collaboration/ws/` and `/collaboration/ydoc/` for the http fallback, plus
Public exposure: the browser needs the websocket `/collaboration/ws/`,
`/collaboration/ydoc/` for the http fallback, `/collaboration/activity/` and
`/collaboration/changeset/` for the editing history, plus
`/collaboration/jwks/v1`, which carries public keys and nothing else. Every
other route this server serves — `rollback`, `prune`, `changeset`, `activity`,
`reset-connections`, `migrate`, `create-ydoc`, `restore-ydoc`, `reset-ydoc`
is now refused to a browser by the permission tables themselves (see "Access
control" below), so publishing one is no longer the security boundary it was
under yhub 0.7. Keep them off the
other route this server serves — `rollback`, `prune`, `reset-connections`,
`migrate`, `create-ydoc`, `restore-ydoc`, `reset-ydoc` is refused to a browser
by the permission tables themselves (see "Access control" below), so publishing
one is no longer the security boundary it was under yhub 0.7. Keep them off the
public ingress all the same: an endpoint that cannot be reached cannot be
probed. The two probes are not worth publishing either — kubelet calls them from
inside — and the helm chart's ingress lists what it routes rather than what it
@@ -105,21 +105,25 @@ out of `server.js` so they can be read and tested without redis and postgres.
Masks are positional `crud` strings where `-` denies, so `'-r--'` is read-only.
| Facet | Reader | Editor | Admin token |
|---|---|---|---|
| `ydoc` | `-r--` | `-ru-` | `cru-` |
| `awareness` | `-r--` | `-ru-` | `-ru-` |
| `history` | | — | `from: 0` |
| `delete` | — | — | `['soft']` |
| `endpoint.ws` | `-r--` | `-ru-` | `crud` (`'*'`) |
| `endpoint.ydoc` | `-r--` | `-ru-` | `crud` (`'*'`) |
| every other endpoint | — | — | `crud` (`'*'`) |
| Facet | Reader | Editor | Link-only reader | Admin token |
|---|---|---|---|---|
| `ydoc` | `-r--` | `-ru-` | as reader/editor | `cru-` |
| `awareness` | `-r--` | `-ru-` | as reader/editor | `-ru-` |
| `history` | `from: <access date>` | `from: <access date>` | — | `from: 0` |
| `delete` | — | — | — | `['soft']` |
| `endpoint.ws` | `-r--` | `-ru-` | as reader/editor | `crud` (`'*'`) |
| `endpoint.ydoc` | `-r--` | `-ru-` | as reader/editor | `crud` (`'*'`) |
| `endpoint.activity` | `-r--` | `-r--` | — | `crud` (`'*'`) |
| `endpoint.changeset` | `-r--` | `-r--` | — | `crud` (`'*'`) |
| every other endpoint | — | — | — | `crud` (`'*'`) |
Reader and editor are the same document permission, `browserDocumentPermissions`,
switched on the backend's `abilities.update`; `abilities.retrieve` decided
whether there is any access at all before that.
All three browser columns are the same document permission,
`browserDocumentPermissions`, switched on two things the backend sends:
`abilities.update` for reader-vs-editor, and `user_access_since` for whether
there is a history to read. `abilities.retrieve` decided whether there is any
access at all before either.
Four of those cells are decisions rather than transcriptions:
Five of those cells are decisions rather than transcriptions:
- **`awareness: '-r--'` for a reader.** A reader receives presence and never
publishes it — [suitenumerique/docs#2544](https://github.com/suitenumerique/docs/pull/2544),
@@ -131,14 +135,32 @@ Four of those cells are decisions rather than transcriptions:
a feature. The frontend has to know it too: the http fallback provider has no
receive-only setting, so a reader's `HttpProvider` is built with no awareness
instance at all, or its first `PATCH` would take a 403 and close it for good.
- **No `'*'` endpoint fallback for the browser.** Only `ws` and `ydoc` are named,
so everything else is denied — including any endpoint a future yhub release
adds. Under 0.7 this fence was a `purpose != null` check, which `create-ydoc`
slipped through by declaring no purpose.
- **No `history` facet for the browser.** This is what makes yhub refuse a
`gc=false` connection with a 403; Docs users are served the garbage-collected
document, and the full history is the backend's business. It also keeps
`activity` and `changeset` closed even if their endpoints were ever granted.
- **No `'*'` endpoint fallback for the browser.** Only the four routes above are
named, so everything else is denied — including any endpoint a future yhub
release adds. Under 0.7 this fence was a `purpose != null` check, which
`create-ydoc` slipped through by declaring no purpose.
- **`history.from` is the moment the user got access**, not the beginning of the
document. It is the backend's `user_access_since` the earliest access they
hold on the document or on one of its ancestors — and it is the same rule the
version endpoints have always applied ("only those created after the user got
access to the document"). yhub clamps `from` up to it on every
`activity`/`changeset` read, so a client asks for whatever range it likes and
gets back only its own share: the bound is silent, enforced server-side, and a
stale client cannot widen it. Two properties fall out of it and are worth
keeping true:
- a **`gc=false` connection stays refused**, because that requires
`from === 0` exactly and a real access date never is;
- the ray is a **stored** bound (`DocumentAccess.created_at`), not a
wall-clock-relative one, which is what yhub's determinism contract asks for —
it re-derives identically on every websocket recheck instead of flapping the
connection.
- **A reader who holds no access, only the link, gets no history.** There is no
access row and so no date; the backend has always refused those users their
version history for exactly that reason ("we wouldn't know from which date to
allow them anyway"). `activity` and `changeset` are withheld together with the
ray rather than granted alone, which would open a route that answers 403 by
itself. `rollback` and `prune` are withheld from everyone: they are
destructive and are granted by name.
- **`delete: ['soft']` and not `'hard'` for the admin.** yhub 0.8 made
`DELETE /ydoc?hard=true` reachable over REST for the first time. Docs keeps
irreversible erasure programmatic, behind `reset-ydoc` (see "Deletion").
+33 -9
View File
@@ -29,27 +29,51 @@
* `create-ydoc` with the admin token. `u` alone already creates the document on
* first write.
*
* No `history` facet, and that is load-bearing rather than an omission: it is
* what makes yhub refuse a `gc=false` connection with a 403. Docs users are
* served the garbage-collected document; the full history is the backend's.
* `historyFrom` is the moment this user gained access to the document, in unix
* milliseconds the backend's `user_access_since`, which is the earliest access
* they hold on the document or on one of its ancestors. It becomes the start of
* the history they may read, which is the rule Docs has always had rather than a
* new one: the version endpoints have always shown "only those created after the
* user got access to the document". yhub clamps `from` up to this on every
* changeset/activity read, so a client asks for whatever range it likes and gets
* back only its own share it never has to know the bound, and a stale or
* modified one cannot widen it.
*
* `null` for a reader who reaches the document by link alone. There is no access
* row and so no date, and the backend has always refused those users their
* history for exactly that reason: "we wouldn't know from which date to allow
* them anyway" (`Document.get_abilities`). Without the facet, `activity` and
* `changeset` answer 403 on their own, so their endpoint entries are withheld
* together with it rather than granting a route that opens nothing.
*
* A bounded ray is not a wall-clock-relative grant: it comes from a stored
* `created_at`, so it re-derives identically on every websocket recheck, which is
* what yhub's determinism contract asks for. And it never unlocks a `gc=false`
* connection, which requires `from === 0` exactly see the guard in server.js.
*
* No `delete` facet: deleting a document is Django's, through the admin token.
* Deliberately absent too: `rollback` and `prune`, which are destructive and are
* granted by name restoring a version is not something a reader, or an editor,
* does through this grant today.
*
* No `'*'` endpoint fallback, so everything not named here is denied. The browser
* calls exactly two routes the websocket, and `ydoc` for the http fallback.
* `activity`, `changeset`, `rollback`, `prune` and every custom endpoint are
* closed to it. Under 0.7 this fence was a `purpose != null` check in
* `getAccessType`, which `create-ydoc` slipped through by declaring no purpose.
* No `'*'` endpoint fallback, so everything not named here is denied including
* any endpoint a future yhub release adds. Under 0.7 this fence was a
* `purpose != null` check in `getAccessType`, which `create-ydoc` slipped through
* by declaring no purpose.
*/
export const browserDocumentPermissions = (canEdit) => ({
export const browserDocumentPermissions = (canEdit, historyFrom = null) => ({
type: 'permissions:document:v1',
ydoc: canEdit ? '-ru-' : '-r--',
awareness: canEdit ? '-ru-' : '-r--',
...(historyFrom ? { history: { from: historyFrom } } : null),
endpoint: {
// `r` opens the socket, `u` admits document updates over it
ws: canEdit ? '-ru-' : '-r--',
// GET is `r` and PATCH is `u`; DELETE (`d`) stays out — see `delete` above
ydoc: canEdit ? '-ru-' : '-r--',
// the editing timeline, and one point in it — both GET-only, both clamped to
// the ray above
...(historyFrom ? { activity: '-r--', changeset: '-r--' } : null),
},
});
+109 -8
View File
@@ -26,8 +26,22 @@ import {
* not as a snapshot of the objects: a table may be respelled freely, but it may
* not start answering a question differently.
*/
const reader = normalizePermissions(browserDocumentPermissions(false));
const editor = normalizePermissions(browserDocumentPermissions(true));
/**
* When these two were given access to the document. Any positive number would
* do what the assertions care about is that it is the value the ray starts at,
* and that it is not zero.
*/
const ACCESS_SINCE = 1_700_000_000_000;
const reader = normalizePermissions(
browserDocumentPermissions(false, ACCESS_SINCE),
);
const editor = normalizePermissions(
browserDocumentPermissions(true, ACCESS_SINCE),
);
// the same two reaching the document by link alone: no access row, so no date
const linkReader = normalizePermissions(browserDocumentPermissions(false));
const linkEditor = normalizePermissions(browserDocumentPermissions(true));
const admin = normalizePermissions(adminDocumentPermissions);
const grants = (permissions, required) =>
@@ -87,13 +101,104 @@ describe('the http fallback route', () => {
});
});
describe('the history a user may read', () => {
/**
* The rule Docs has always had, now expressed as a permission: a user sees the
* document's history from the moment they were given access to it, and no
* further back. yhub clamps every changeset/activity read up to this, so the
* bound is enforced on the server and the client never has to know it.
*/
it('starts the ray where the user got access', () => {
for (const who of [reader, editor]) {
assert.equal(grants(who, { history: { from: ACCESS_SINCE } }), true);
}
});
it('does not reach back before that', () => {
// a requirement asking for a wider ray than the grant is not contained in it
for (const who of [reader, editor]) {
assert.equal(grants(who, { history: { from: ACCESS_SINCE - 1 } }), false);
assert.equal(grants(who, { history: { from: 0 } }), false);
}
});
it('opens the timeline and one point in it, read only', () => {
for (const who of [reader, editor]) {
for (const name of ['activity', 'changeset']) {
assert.equal(grants(who, { endpoint: { [name]: '-r--' } }), true);
// GET only: neither endpoint has another verb, and granting one would
// be granting a route that does not exist
assert.equal(grants(who, { endpoint: { [name]: '--u-' } }), false);
}
}
});
it('never grants the full ray, which is what would unlock gc=false', () => {
// `gc=false` demands `history.from === 0` exactly. A real access date is
// ~1.8e12, so this can only regress through a bug — assert on the object
// rather than through `hasPermissions`, because it is the literal value
// that matters here
for (const who of [reader, editor]) {
assert.notEqual(who.history, false);
assert.ok(who.history.from > 0);
}
});
it('never grants rollback or prune', () => {
// destructive, granted by name, and restoring a version is not something
// this grant does
for (const who of [reader, editor]) {
assert.equal(
grants(who, { history: { from: ACCESS_SINCE, rollback: true } }),
false,
);
assert.equal(
grants(who, { history: { from: ACCESS_SINCE, prune: true } }),
false,
);
assert.equal(grants(who, { endpoint: { rollback: 'c---' } }), false);
assert.equal(grants(who, { endpoint: { prune: 'c---' } }), false);
}
});
});
describe('a reader who holds no access, only the link', () => {
/**
* There is no access row and so no date. The backend has always refused these
* users their version history for exactly that reason "we wouldn't know from
* which date to allow them anyway" and the grant says the same thing by
* withholding the facet.
*/
it('gets no history at all', () => {
for (const who of [linkReader, linkEditor]) {
assert.equal(who.history, false);
assert.equal(grants(who, { history: { from: 0 } }), false);
assert.equal(grants(who, { history: { from: ACCESS_SINCE } }), false);
}
});
it('cannot reach the timeline either', () => {
// withheld together with the ray: without it these two answer 403 on their
// own, so granting them would open nothing and only muddy the grant
for (const who of [linkReader, linkEditor]) {
for (const name of ['activity', 'changeset']) {
assert.equal(grants(who, { endpoint: { [name]: '-r--' } }), false);
}
}
});
it('still reads and syncs the document like anyone else', () => {
assert.equal(grants(linkReader, { ydoc: '-r--' }), true);
assert.equal(grants(linkEditor, { ydoc: '--u-' }), true);
assert.equal(grants(linkEditor, { endpoint: { ws: '--u-' } }), true);
});
});
describe('everything the browser must not reach', () => {
// there is no '*' fallback in the browser grant, so an endpoint yhub adds in
// a future release is denied until it is named — this is the property that
// replaced 0.7's `purpose != null` check
for (const name of [
'activity',
'changeset',
'rollback',
'prune',
'create-ydoc',
@@ -108,10 +213,6 @@ describe('everything the browser must not reach', () => {
assert.equal(grants(editor, { endpoint: { [name]: 'c---' } }), false);
});
}
it('withholds history, which is what refuses a gc=false connection', () => {
assert.equal(grants(editor, { history: { from: 0 } }), false);
});
});
describe('the admin token', () => {
+13 -1
View File
@@ -450,7 +450,19 @@ const auth = createAuthPlugin({
if (SOFT_MIGRATION) {
await seedFromLegacyStore({ org, docid, branch });
}
return browserDocumentPermissions(doc.abilities.update === true);
// When this caller was given access, which is where the history they may
// read starts. The backend sends ISO-8601 (null for a link-reach reader,
// who holds no access and so has no date); `history.from` is unix ms.
//
// Anything unparseable is *no* history rather than full history, and zero
// is refused with it: `from: 0` is the one value that also unlocks a
// `gc=false` websocket, and no real access date is ever zero, so a zero
// here could only ever be a bug upstream.
const accessSince = Date.parse(doc.user_access_since ?? '');
return browserDocumentPermissions(
doc.abilities.update === true,
Number.isFinite(accessSince) && accessSince > 0 ? accessSince : null,
);
},
async global() {
return publicGlobalPermissions;