Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

| | |
Expand Down
6 changes: 5 additions & 1 deletion stacklets/docs/bot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion stacklets/docs/hooks/on_start_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
44 changes: 44 additions & 0 deletions stacklets/docs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
"""
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 7 additions & 2 deletions stacklets/docs/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
26 changes: 26 additions & 0 deletions tests/e2e/paperless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}/")

Expand All @@ -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."""
Expand Down
117 changes: 117 additions & 0 deletions tests/e2e/test_docs_permissions_e2e.py
Original file line number Diff line number Diff line change
@@ -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
Loading