From 934bcbf9ac84b801060841abcab3e4439933c65e Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 2 Oct 2026 12:22:16 +0200 Subject: [PATCH] fix(docs): remove the admin as owner of tags, types and correspondents Paperless checks permissions per object. The archivist and the seed created the vocabulary with the admin's token, so the admin owned every tag, correspondent and document type, and a family member signed in through single sign-on saw each of them as "Private". - New entries are created without an owner, so every member with the model right sees them. - On every `stack up docs` with single sign-on, entries the admin owns lose their owner. Entries a member owns stay theirs, and a run with nothing left to change does nothing. - The admin guide says who sees what in Paperless. Closes: #176 --- docs/admin-guide.md | 2 + stacklets/docs/bot/pipeline.py | 6 +- stacklets/docs/hooks/on_start_ready.py | 3 +- stacklets/docs/permissions.py | 44 ++++++++++ stacklets/docs/seed.py | 9 +- tests/e2e/paperless.py | 26 ++++++ tests/e2e/test_docs_permissions_e2e.py | 117 +++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/test_docs_permissions_e2e.py diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 9f805918..ea4fc7c3 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -390,6 +390,8 @@ The archivist bot creates a `#documents` room in your chat. AI classifies and ta On first setup, famstack seeds Paperless with common document categories and types in your configured language. The LLM picks from these when classifying, so tags stay consistent. See [`stacklets/docs/taxonomy.toml`](../stacklets/docs/taxonomy.toml). +**Who sees what.** When family members sign in to Paperless through single sign-on, every account that is not a superuser joins the `all_documents` group: it sees the whole archive and may add, change and delete documents. The vocabulary (tags, correspondents, document types, storage paths) is shared with every member: entries have no owner, so everyone sees them by name. Members may add and rename tags, correspondents and document types, but not delete them. An entry a member creates in the Paperless UI belongs to that member, and stays private until they share it. + **Memory vault.** With the `code` stacklet up, every filed document and every captured URL or pasted note also lands as a Markdown file in your Forgejo repo `family/memory`. Documents and correspondents live under the shared bucket (`family/` by default, configurable via `stack.toml [core] shared_bucket`). Personal captures route to the sender's own bucket: Homer's pastes end up under `homer/notes/` or `homer/bookmarks/`, Marge's under hers. Paperless stays the canonical store; the memory vault is the browsable, git-versioned human view. Install the [`memory` stacklet](#memory-memory-optional) to seed the ontology and render the vault as the family wiki. | | | diff --git a/stacklets/docs/bot/pipeline.py b/stacklets/docs/bot/pipeline.py index 8ef4416a..c6b608e5 100644 --- a/stacklets/docs/bot/pipeline.py +++ b/stacklets/docs/bot/pipeline.py @@ -453,8 +453,12 @@ async def wait_task(self, task_id: str, timeout: int = 120) -> int | None: # the UI. async def _create_entity(self, endpoint: str, body: dict) -> int | None: + # Tags, types and correspondents are vocabulary the whole family + # shares. Without an explicit null owner Paperless makes the + # token's user (the admin) the owner, and every member then sees + # the entry as "Private". data, _ = await self._req( - "POST", f"/api/{endpoint}/", json_body=body, expect=(201,), + "POST", f"/api/{endpoint}/", json_body={**body, "owner": None}, expect=(201,), ) return data["id"] if isinstance(data, dict) and "id" in data else None diff --git a/stacklets/docs/hooks/on_start_ready.py b/stacklets/docs/hooks/on_start_ready.py index 2a05de1a..a707959d 100644 --- a/stacklets/docs/hooks/on_start_ready.py +++ b/stacklets/docs/hooks/on_start_ready.py @@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from auth import ensure_api_token from seed import seed_person_tags, seed_taxonomy -from permissions import ensure_group, share_archive +from permissions import ensure_group, share_archive, share_vocabulary def run(ctx): @@ -43,3 +43,4 @@ def run(ctx): # first enablement: existing documents become visible to # the group once; later documents come through the workflow share_archive(url, token, step=ctx.step) + share_vocabulary(url, token, step=ctx.step) diff --git a/stacklets/docs/permissions.py b/stacklets/docs/permissions.py index 7c91ed64..35f0440b 100644 --- a/stacklets/docs/permissions.py +++ b/stacklets/docs/permissions.py @@ -17,6 +17,11 @@ (share_archive). A more restricted group (own_documents) is added when the first account that must not see everything arrives. +The vocabulary (tags, correspondents, document types, storage paths) +has no owner, so every member sees it by name; the group still decides +who may add or change it. share_vocabulary clears the admin as owner +on entries created before that was the rule. + Superusers (the technical admin) are left alone. Idempotent; runs on every 'stack up docs'. """ @@ -136,6 +141,45 @@ def share_archive(url, token, step=None): step(f"{len(ids)} documents shared with '{GROUP}'") +# Vocabulary types that carry an owner. Custom fields have none in +# Paperless, so they are visible to every member already. +VOCABULARY = ("tags", "correspondents", "document_types", "storage_paths") + + +def share_vocabulary(url, token, step=None): + """Clear the owner of every vocabulary entry the admin owns. + + Paperless shows a member an entry only if they own it, were granted + it, or it has no owner. The archivist and the seed create entries + with no owner; this covers the ones created before they did, which + belong to the admin behind the token. Entries a member owns stay + theirs, so one a member made private stays private. + + Runs on every start: once nothing is left that the admin owns, it + sends no request and prints nothing. Errors are reported, not + raised, as in ensure_group. + """ + step = step or (lambda m: None) + try: + admin = _call(url, token, "GET", "/ui_settings/")["user"]["id"] + for kind in VOCABULARY: + ids = [o["id"] for o in _paged(url, token, f"/{kind}/") if o.get("owner") == admin] + if not ids: + continue + # `merge: false` with no `permissions` key sets the owner and + # leaves the grants alone. With `merge: true` Paperless never + # clears an owner. + _call(url, token, "POST", "/bulk_edit_objects/", { + "objects": ids, "object_type": kind, + "operation": "set_permissions", "owner": None, "merge": False, + }) + step(f"{len(ids)} {kind.replace('_', ' ')} shared with every member") + except urllib.error.HTTPError as e: + step(f"Paperless vocabulary: {e.code} {e.read().decode()[:160]}") + except urllib.error.URLError as e: + step(f"Paperless vocabulary skipped: {e}") + + def ensure_group(url: str, token: str, step=None) -> bool: """Keep the group as this module defines it and every member in it. diff --git a/stacklets/docs/seed.py b/stacklets/docs/seed.py index 81b04a3e..78484387 100644 --- a/stacklets/docs/seed.py +++ b/stacklets/docs/seed.py @@ -68,13 +68,18 @@ def _fetch_existing(paperless_url: str, headers: dict, endpoint: str) -> set: def _create_entity(paperless_url: str, headers: dict, endpoint: str, body: dict) -> bool: - """Create a single entity via Paperless API. Returns True on success.""" + """Create a single entity via Paperless API. Returns True on success. + + The entity has no owner, so every family member can see it: seeded + tags and types are shared vocabulary, and Paperless would otherwise + make the admin behind the token the owner. + """ import urllib.request try: req = urllib.request.Request( f"{paperless_url}/api/{endpoint}/", - data=json.dumps(body).encode(), + data=json.dumps({**body, "owner": None}).encode(), headers={**headers, "Content-Type": "application/json"}, method="POST", ) diff --git a/tests/e2e/paperless.py b/tests/e2e/paperless.py index d7a8461a..8a105ca0 100644 --- a/tests/e2e/paperless.py +++ b/tests/e2e/paperless.py @@ -79,6 +79,12 @@ def create_tag(self, name: str, color: str = "#9e9e9e") -> dict: "name": name, "color": color, "matching_algorithm": 0, }) + def create_document_type(self, name: str) -> dict: + return self._req("POST", "/api/document_types/", {"name": name, "matching_algorithm": 0}) + + def create_correspondent(self, name: str) -> dict: + return self._req("POST", "/api/correspondents/", {"name": name, "matching_algorithm": 0}) + def delete_tag(self, tag_id: int) -> None: self._req("DELETE", f"/api/tags/{tag_id}/") @@ -91,6 +97,26 @@ def delete_correspondent(self, cid: int) -> None: def delete_document(self, doc_id: int) -> None: self._req("DELETE", f"/api/documents/{doc_id}/") + def create_user(self, username: str, password: str) -> dict: + """A plain account: no superuser, no group of its own.""" + return self._req("POST", "/api/users/", { + "username": username, "password": password, "is_superuser": False, + }) + + def delete_user(self, user_id: int) -> None: + self._req("DELETE", f"/api/users/{user_id}/") + + +def login(url: str, username: str, password: str) -> PaperlessAPI: + """A client that acts as `username`, for asserting what that account + sees rather than what the admin token sees.""" + req = urllib.request.Request( + f"{url.rstrip('/')}/api/token/", method="POST", + data=urllib.parse.urlencode({"username": username, "password": password}).encode(), + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return PaperlessAPI(url=url, token=json.loads(resp.read())["token"]) + class PaperlessError(RuntimeError): """HTTP error from Paperless. Message contains status + body snippet.""" diff --git a/tests/e2e/test_docs_permissions_e2e.py b/tests/e2e/test_docs_permissions_e2e.py new file mode 100644 index 00000000..9051d392 --- /dev/null +++ b/tests/e2e/test_docs_permissions_e2e.py @@ -0,0 +1,117 @@ +"""The archive vocabulary is shared with every family member. + +Paperless checks permissions per object as well as per model. The +``all_documents`` group grants a member the right to view tags, +correspondents and document types, but Paperless still hides an entry +the member does not own and was not granted. An entry with no owner is +the one kind every member with the model right can see. + +The archivist and the seed create the vocabulary with the admin's token, +and Paperless makes the token's user the owner of what it creates. So a +member saw every tag on a document as "Private" (issue #176). These +tests sign in as a plain member and ask Paperless what that account +sees, which is the only answer that counts. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path + +import aiohttp +import pytest + +from tests.e2e.paperless import PaperlessAPI, login + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) + +from permissions import ensure_group, share_vocabulary # noqa: E402 +from pipeline import PaperlessAPI as BotPaperlessAPI # noqa: E402 + + +@dataclass +class Member: + id: int + api: PaperlessAPI + + +@pytest.fixture +def member(paperless, paperless_scope): + """A family member as single sign-on creates one: not a superuser, + in ``all_documents``, with a client signed in as that account.""" + ensure_group(paperless.url, paperless.token) + name, password = f"{paperless_scope.uid}-marge", "marge-test-pw" + user = paperless.create_user(name, password) + paperless_scope.on_cleanup.append(lambda _: paperless.delete_user(user["id"])) + # ensure_group puts every account that is not a superuser in the group + ensure_group(paperless.url, paperless.token) + return Member(user["id"], login(paperless.url, name, password)) + + +def _names(entries: list[dict]) -> set[str]: + return {e["name"] for e in entries} + + +class TestNewVocabulary: + """What the archivist creates while filing is visible to members.""" + + async def test_a_member_sees_what_the_archivist_creates_by_name( + self, paperless, member, paperless_scope, bdd, + ): + bdd.given("the archivist creates a tag, a document type and a correspondent") + tag, doc_type, corr = (paperless_scope.tag(b) for b in ("Tag", "Type", "Sender")) + async with aiohttp.ClientSession() as session: + bot = BotPaperlessAPI(session, paperless.url, paperless.token) + assert await bot.create_tag(tag) + assert await bot.create_doc_type(doc_type) + assert await bot.create_correspondent(corr) + + bdd.then("a member of all_documents lists all three by name") + assert tag in _names(member.api.list_tags()) + assert doc_type in _names(member.api.list_document_types()) + assert corr in _names(member.api.list_correspondents()) + + +class TestExistingVocabulary: + """Entries the admin owns from before are shared on the next start.""" + + def test_admin_owned_entries_become_visible_and_a_members_own_tag_stays_theirs( + self, paperless, member, paperless_scope, bdd, + ): + bdd.given("a tag, a document type and a correspondent the admin owns") + tag = paperless.create_tag(paperless_scope.tag("OldTag"))["name"] + doc_type = paperless.create_document_type(paperless_scope.tag("OldType"))["name"] + corr = paperless.create_correspondent(paperless_scope.tag("OldSender"))["name"] + assert tag not in _names(member.api.list_tags()), "precondition: hidden from the member" + + bdd.and_("a tag the member created, and so owns") + own = member.api.create_tag(paperless_scope.tag("MargesTag")) + + bdd.when("the docs stacklet starts") + share_vocabulary(paperless.url, paperless.token) + + bdd.then("the member sees the admin's entries by name") + assert tag in _names(member.api.list_tags()) + assert doc_type in _names(member.api.list_document_types()) + assert corr in _names(member.api.list_correspondents()) + + bdd.and_("the member's own tag keeps its owner") + mine = next(t for t in paperless.list_tags() if t["id"] == own["id"]) + assert mine["owner"] == member.id + + def test_a_second_start_changes_nothing(self, paperless, member, paperless_scope, bdd): + bdd.given("a start that has already shared the vocabulary") + paperless.create_tag(paperless_scope.tag("Tag")) + share_vocabulary(paperless.url, paperless.token) + before = {t["id"]: t["owner"] for t in paperless.list_tags()} + + bdd.when("the docs stacklet starts again") + said: list[str] = [] + share_vocabulary(paperless.url, paperless.token, step=said.append) + + bdd.then("it reports nothing and no owner moves") + assert said == [] + assert {t["id"]: t["owner"] for t in paperless.list_tags()} == before