mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-23 10:05:10 +02:00
✨(collaboration) build the version history from the activity api
The version history has been dead since the migration. It listed S3 object
versions of the legacy `{pk}/file` key, and nothing writes that key any more,
so every document's list has been frozen at its migration date; restoring one
was a stub that closed the modal and did nothing, while still promising that
the document would be replaced.
It now reads the collaboration server, which is what keeps the history: the
list comes from `activity`, a selected version is previewed from `changeset`
as the document stood at that moment, and restoring one is a `rollback`.
A version is a minute of editing — changes less than a minute apart become
one, and none spans more than a minute. The collaboration server groups only
changes by the same author, so the browser merges what is left across authors:
a version is a moment in the document, not a moment in one person's editing.
Both are needed, and both use the same rule.
This grants `history.rollback` to editors, which is the first time a browser
may change the past rather than read it, and publishes the rollback route.
A reader is refused it twice over — the collaboration server treats it as a
dead grant without document write access, and the endpoint is withheld as well.
Mutations refuse where reads clamp, so a rollback reaching further back than
the history a user was granted is rejected rather than trimmed: nobody can
undo work that predates their own access, and a rollback with no bound at all
is refused outright. `prune`, which erases, stays granted to nobody. Restoring
is not destructive: it appends a change that undoes another, so what it
replaced stays in the history and can be restored again.
The backend's version endpoints are untouched and now have no caller. They are
marked deprecated with the condition for removing them, since until a document
has been replayed by `migrate_documents` they hold the only record of what it
looked like before it moved.
Also fixes the e2e helper that waited for the removed content endpoint, so it
never returned, and the three version tests that hung behind it.
Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
+46
-20
@@ -82,14 +82,14 @@ It is not a fork of yhub — it is a thin wrapper:
|
||||
|
||||
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`, `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
|
||||
`/collaboration/changeset/` for the editing history, `/collaboration/rollback/`
|
||||
for restoring a document to a point in it, plus `/collaboration/jwks/v1`, which
|
||||
carries public keys and nothing else. Every other route this server serves —
|
||||
`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
|
||||
hides, so they stay in-cluster on their own.
|
||||
|
||||
@@ -109,12 +109,13 @@ Masks are positional `crud` strings where `-` denies, so `'-r--'` is read-only.
|
||||
|---|---|---|---|---|
|
||||
| `ydoc` | `-r--` | `-ru-` | as reader/editor | `cru-` |
|
||||
| `awareness` | `-r--` | `-ru-` | as reader/editor | `-ru-` |
|
||||
| `history` | `from: <access date>` | `from: <access date>` | — | `from: 0` |
|
||||
| `history` | `from: <access date>` | `from: <access date>`, `rollback` | — | `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` (`'*'`) |
|
||||
| `endpoint.rollback` | — | `c---` | — | `crud` (`'*'`) |
|
||||
| every other endpoint | — | — | — | `crud` (`'*'`) |
|
||||
|
||||
All three browser columns are the same document permission,
|
||||
@@ -123,7 +124,7 @@ All three browser columns are the same document permission,
|
||||
there is a history to read. `abilities.retrieve` decided whether there is any
|
||||
access at all before either.
|
||||
|
||||
Five of those cells are decisions rather than transcriptions:
|
||||
Six 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),
|
||||
@@ -135,7 +136,7 @@ Five 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 the four routes above are
|
||||
- **No `'*'` endpoint fallback for the browser.** Only the 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.
|
||||
@@ -159,8 +160,24 @@ Five of those cells are decisions rather than transcriptions:
|
||||
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.
|
||||
itself — and `rollback` with them.
|
||||
- **`history.rollback` for an editor.** `POST /rollback` undoes every change in
|
||||
a window, and it is what the version history's restore button calls: **any
|
||||
user who may edit a document may restore it to an earlier state.** Four things
|
||||
bound that.
|
||||
- A reader cannot, twice over. yhub normalizes `rollback` to `false` unless
|
||||
`ydoc` carries `u` — it is a dead grant without the write it rides on — and
|
||||
the requirement side mirrors it. Docs additionally withholds the endpoint,
|
||||
so a reader is refused at the door rather than inside the handler.
|
||||
- Nobody can undo what happened before they arrived. Mutations *refuse* where
|
||||
reads clamp: a rollback demands a ray reaching back to its own `from`
|
||||
instead of having it moved forward silently. Every moment a user can name is
|
||||
one the timeline showed them, and that timeline starts at their access date
|
||||
— so the bound holds without trusting the client, and a rollback with no
|
||||
`from` at all, which asks to undo all of history, is refused outright.
|
||||
- Nothing is destroyed. A rollback appends an update that undoes another; what
|
||||
it undid stays in the history and can be restored again from the same panel.
|
||||
- `prune`, which does erase, stays withheld from everyone.
|
||||
- **`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").
|
||||
@@ -601,10 +618,16 @@ Operational notes:
|
||||
## Full migration (`POST /collaboration/migrate/v1/{org}/{docid}`)
|
||||
|
||||
The media bucket is versioned, so `{docid}/file` keeps every snapshot Django
|
||||
ever wrote — that is the version history the backend exposes at
|
||||
`/documents/{id}/versions/`. The lazy seed above replays only the newest one, so
|
||||
a soft-migrated document lands in yhub as a single `system` change stamped with
|
||||
the migration time and its past is gone.
|
||||
ever wrote — that is the version history the backend used to expose at
|
||||
`/documents/{id}/versions/`, and which nothing writes to any more. The lazy seed
|
||||
above replays only the newest one, so a soft-migrated document lands in yhub as a
|
||||
single `system` change stamped with the migration time and its past is gone.
|
||||
|
||||
This is what makes the backfill user-visible rather than housekeeping. The
|
||||
frontend's version history is built from `activity`, so until a document has been
|
||||
migrated in full its history begins at the moment it reached yhub: the snapshots
|
||||
are still in S3, but nothing reads them. Running the backfill is what gives those
|
||||
documents their past back.
|
||||
|
||||
`migrate` replays the whole history instead. It lists the object's versions and
|
||||
applies them, oldest first, to a single `Y.Doc({ gc: false })`; after each one
|
||||
@@ -612,9 +635,12 @@ it credits the ids that version introduced (and the ones it deleted) with
|
||||
**that version's own S3 timestamp**. `GET
|
||||
/collaboration/activity/v1/{org}/{docid}?group=false` then reports one entry per
|
||||
S3 version, at the same timestamps the backend's version listing reports as
|
||||
`last_modified` — which is what lines the two up. (Pass `group=false`: the
|
||||
default grouping merges changes by the same author less than a second apart,
|
||||
which would fold versions saved in quick succession into one entry.)
|
||||
`last_modified` — which is what lines the two up, and what makes the two lists
|
||||
comparable when checking a backfill. (Pass `group=false`: the default grouping
|
||||
merges changes by the same author less than a second apart, which would fold
|
||||
versions saved in quick succession into one entry. The frontend asks for the
|
||||
opposite — a minute of grouping — because it wants a readable history rather
|
||||
than a faithful one; use `group=false` to compare, not what the browser sends.)
|
||||
`gc: false` is what preserves content that later versions deleted — most of
|
||||
what makes a history worth keeping.
|
||||
|
||||
|
||||
@@ -52,9 +52,30 @@
|
||||
* 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.
|
||||
*
|
||||
* `history.rollback` is granted to an editor, and it is the one thing here that
|
||||
* lets a browser change the past rather than read it: `POST /rollback` undoes
|
||||
* every change in a window, which is what the version history's "restore" button
|
||||
* is. Four things bound it.
|
||||
*
|
||||
* A reader never gets it, twice over. yhub normalizes `rollback` to `false`
|
||||
* unless `ydoc` carries `u` — it is a dead grant without the write it rides on —
|
||||
* and the requirement side mirrors that, so a reader would be refused even if
|
||||
* this table said otherwise. `canEdit` is belt to those braces, and withholds
|
||||
* the endpoint with it so a reader is refused once, at the door, instead of
|
||||
* halfway through the handler.
|
||||
*
|
||||
* Nobody can undo what happened before they arrived. Mutations refuse where
|
||||
* reads clamp: `POST /rollback` demands a ray reaching back to its own `from`,
|
||||
* rather than quietly moving it forward the way `activity` does. Every moment a
|
||||
* user can name is one they were shown, and everything they were shown is inside
|
||||
* their ray — so the bound holds without the client being trusted to respect it,
|
||||
* and a rollback with no `from` at all, which would ask to undo all of history,
|
||||
* is refused outright.
|
||||
*
|
||||
* `prune` stays absent. Rollback is additive — it appends an update that undoes
|
||||
* another, and what it undid is still in the history, still restorable by the
|
||||
* same route. Prune erases, and no browser needs that.
|
||||
*
|
||||
* 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
|
||||
@@ -65,15 +86,24 @@ export const browserDocumentPermissions = (canEdit, historyFrom = null) => ({
|
||||
type: 'permissions:document:v1',
|
||||
ydoc: canEdit ? '-ru-' : '-r--',
|
||||
awareness: canEdit ? '-ru-' : '-r--',
|
||||
...(historyFrom ? { history: { from: historyFrom } } : null),
|
||||
...(historyFrom
|
||||
? { history: { from: historyFrom, ...(canEdit && { rollback: true }) } }
|
||||
: 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),
|
||||
// the ray above — and, for an editor, the route that undoes a window of it.
|
||||
// `c---` because rollback is a POST; there is no other verb on it
|
||||
...(historyFrom
|
||||
? {
|
||||
activity: '-r--',
|
||||
changeset: '-r--',
|
||||
...(canEdit && { rollback: 'c---' }),
|
||||
}
|
||||
: null),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -144,24 +144,82 @@ describe('the history a user may read', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never grants rollback or prune', () => {
|
||||
// destructive, granted by name, and restoring a version is not something
|
||||
// this grant does
|
||||
it('never grants prune', () => {
|
||||
// erasure, granted by name, and nothing a browser does needs it
|
||||
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('undoing a stretch of history', () => {
|
||||
/**
|
||||
* `POST /rollback` appends an update that undoes every change in a window: it
|
||||
* is what the version history's restore button does. An editor may, a reader
|
||||
* may not, and neither may reach back past the moment they arrived.
|
||||
*/
|
||||
it('lets an editor undo a window of its own history', () => {
|
||||
assert.equal(
|
||||
grants(editor, { history: { from: ACCESS_SINCE, rollback: true } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(grants(editor, { endpoint: { rollback: 'c---' } }), true);
|
||||
});
|
||||
|
||||
it('refuses an editor a window wider than its ray', () => {
|
||||
// the difference between a read and a mutation: `activity` would clamp this
|
||||
// silently, `rollback` refuses it. `from: 0` is what a rollback with no
|
||||
// bound at all asks for, so this is also what stops "undo everything"
|
||||
assert.equal(
|
||||
grants(editor, { history: { from: 0, rollback: true } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
grants(editor, { history: { from: ACCESS_SINCE - 1, rollback: true } }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a reader undo anything', () => {
|
||||
assert.equal(
|
||||
grants(reader, { history: { from: ACCESS_SINCE, rollback: true } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(grants(reader, { endpoint: { rollback: 'c---' } }), false);
|
||||
});
|
||||
|
||||
it('is a dead grant without the write it rides on', () => {
|
||||
// yhub's own rule, asserted here because it is what makes the reader case
|
||||
// safe even if this policy ever spelled it wrong: `rollback` normalizes to
|
||||
// false unless `ydoc` carries `u`
|
||||
const readerWithRollback = normalizePermissions({
|
||||
...browserDocumentPermissions(false, ACCESS_SINCE),
|
||||
history: { from: ACCESS_SINCE, rollback: true },
|
||||
});
|
||||
assert.equal(readerWithRollback.history.rollback, false);
|
||||
});
|
||||
|
||||
it('is withheld from a browser that holds no access', () => {
|
||||
for (const who of [linkReader, linkEditor]) {
|
||||
assert.equal(
|
||||
grants(who, { history: { from: ACCESS_SINCE, rollback: true } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(grants(who, { endpoint: { rollback: 'c---' } }), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('is not granted to the admin token', () => {
|
||||
// 0.8 stopped implying it from write access, and the backend does not
|
||||
// restore versions — the browser does
|
||||
assert.equal(grants(admin, { history: { from: 0, rollback: true } }), 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
|
||||
@@ -199,7 +257,6 @@ describe('everything the browser must not reach', () => {
|
||||
// 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 [
|
||||
'rollback',
|
||||
'prune',
|
||||
'create-ydoc',
|
||||
'migrate',
|
||||
@@ -235,9 +292,8 @@ describe('the admin token', () => {
|
||||
assert.equal(grants(admin, { delete: ['hard'] }), false);
|
||||
});
|
||||
|
||||
it('is not granted rollback or prune', () => {
|
||||
// 0.8 stopped implying them from write access; Docs does not use them
|
||||
assert.equal(grants(admin, { history: { from: 0, rollback: true } }), false);
|
||||
it('is not granted prune', () => {
|
||||
// 0.8 stopped implying it from write access; Docs does not use it
|
||||
assert.equal(grants(admin, { history: { from: 0, prune: true } }), false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user