From caea2e01cc2743d9d07f865f54f853237f3555d3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 23 Sep 2026 14:44:20 +0200 Subject: [PATCH 1/2] feat(stack): let stacklets sign in through an OIDC provider A stacklet can now use single sign-on from another stacklet without either one naming the other. - A client declares `[oidc]` (name, callbacks) and reads `{oidc_issuer}`, `{oidc_client_id}` and `{oidc_client_secret}`. All three stay empty until a provider has registered it, so the service keeps its own login until then. - A provider declares `[oidc_provider]`. Its hooks list the clients with `ctx.stack.oidc_clients()` and store what the identity service issued with `ctx.stack.store_oidc_client()`. - `stack doctor` warns about a running client that the running provider has not registered, and about a second provider that is ignored. Credentials live in the provider's secret namespace, so destroying the provider switches every client back to its own login. --- docs/stack-reference.md | 56 +++++++++++ lib/stack/cli.py | 7 ++ lib/stack/doctor.py | 44 +++++++++ lib/stack/stack.py | 91 +++++++++++++++++ tests/unit/framework/test_doctor.py | 42 ++++++++ tests/unit/framework/test_oidc.py | 147 ++++++++++++++++++++++++++++ 6 files changed, 387 insertions(+) create mode 100644 tests/unit/framework/test_oidc.py diff --git a/docs/stack-reference.md b/docs/stack-reference.md index d2ad3f43..abd93edb 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -205,6 +205,7 @@ Available template variables: | `{url}`, `{_url}`, `{home_url}` | Public URLs: `http://:` in port mode; `https://.` in domain mode once `[core].dns_provider` is set, `http://` before that | | `{url_host}` | The host of `{url}`: `.` in domain mode, `{ip}` in port mode. For a protocol the stacklet serves under the same name besides HTTP, such as Forgejo's SSH | | `{__}` | A secret from `secrets.toml`, e.g. `{docs__API_TOKEN}`, `{infra__DNS_API_TOKEN}` | +| `{oidc_issuer}`, `{oidc_client_id}`, `{oidc_client_secret}` | Single sign-on for a stacklet with an `[oidc]` table, see [Single Sign-On](#single-sign-on-oidc). All three empty until the provider has registered the stacklet | ### Hints @@ -472,6 +473,61 @@ A stacklet may declare zero, one, or several entries of each kind. Sources flow to every configured target whose engine supports the declared section type. +### Single Sign-On (OIDC) + +A stacklet can let people log in through an OpenID Connect provider that +another stacklet runs. The two sides do not name each other: the client +does not know which provider it gets, and the provider does not know the +client's env. + +A **client** declares `[oidc]` and reads three template variables: + +```toml +[env.defaults] +OIDC_ISSUER = "{oidc_issuer}" +OIDC_CLIENT_ID = "{oidc_client_id}" +OIDC_CLIENT_SECRET = "{oidc_client_secret}" + +[oidc] +name = "Documents" # shown on the provider's consent page; default: the stacklet name +callbacks = ["{url}/accounts/oidc/sso/login/callback/"] +``` + +`callbacks` are rendered against the client's own template variables, so +`{url}` is the client's public URL. The three `{oidc_*}` variables are +set together or not at all: they stay empty while no provider exists, and +until the provider has registered the client. Test one of them in the +compose file to switch the login on, and keep the service's own login as +the fallback. + +A **provider** declares `[oidc_provider]` (an empty table). The issuer is +its public URL, `{url}`. Its hooks do the registration through the +`Stack` instance in `ctx.stack`: + +| Method | What it does | +|---|---| +| `oidc_clients()` | Every stacklet with `[oidc]`: `stacklet`, `name`, rendered `callbacks`, and the stored `client_id` and `client_secret` (None before the first registration) | +| `store_oidc_client(stacklet_id, client_id, client_secret)` | Stores the credentials the provider issued | + +Credentials are kept in the provider's secret namespace +(`__CLIENT__ID` and `_SECRET`). `stack destroy ` +deletes them, and each client falls back to its own login on its next +`stack up`. A client reads new credentials only when its containers are +recreated, so a provider should not rotate a stored secret on every run, +and should tell the admin which client to `stack up` after storing new +credentials. If more than one stacklet declares `[oidc_provider]`, the +first one discovered is used. + +`stack doctor` warns about a running client that a running provider has +not registered, and about a second provider being ignored. A provider +that is present but was never brought up raises nothing. + +A provider may need domain mode with HTTPS, and should refuse to start +without it in `on_install` and `on_start`. A passkey login is the usual +reason: browsers allow WebAuthn only on an `https://` origin. The +framework does not check this, because the requirement belongs to the +provider, not to OIDC. + --- ## Lifecycle diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 2d7d3a77..cbe87d22 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -1083,6 +1083,13 @@ def missing_secrets(stacklet_id): stale=stck.list().get("stale", []), ) + findings += doctor.check_oidc( + [s["id"] for s in discovered if "oidc_provider" in s["manifest"]], + {c["stacklet"]: bool(c["client_id"] and c["client_secret"]) + for c in stck.oidc_clients()}, + running={sid for sid in stacklets if docker.containers_for(sid)}, + ) + # What this instance is running, and whether a release has passed it. # No fetch: doctor is run often, and often when something is # unreachable, so it answers from the tags this clone already has. diff --git a/lib/stack/doctor.py b/lib/stack/doctor.py index 5e26b254..9f1a44ca 100644 --- a/lib/stack/doctor.py +++ b/lib/stack/doctor.py @@ -211,6 +211,50 @@ def check_endpoint(name: str, url: str, reachable: bool) -> Finding | None: ) +def check_oidc(providers: list[str], clients: dict[str, bool], + running: set[str]) -> list[Finding]: + """Single sign-on that is set up on one side only. + + A client's `{oidc_*}` variables render empty both when there is no + provider and when the provider has not registered it yet, and either + way the service shows only its own login. The first is a choice, the + second a gap. `providers` are the stacklets declaring + `[oidc_provider]` in discovery order, `clients` maps each stacklet + declaring `[oidc]` to whether its credentials are stored, `running` + names the stacklets with containers. + + A provider that is not running raises nothing: an extension in the + directory is not yet a decision to use it. + """ + findings: list[Finding] = [] + if len(providers) > 1: + used, ignored = providers[0], ", ".join(providers[1:]) + findings.append(Finding( + level=WARN, + title="more than one stacklet provides single sign-on", + detail=f"{used} is used, {ignored} is ignored. Only the first " + "stacklet that declares [oidc_provider] is used.", + fix=f"remove [oidc_provider] from {ignored}", + )) + if not providers or providers[0] not in running: + return findings + provider = providers[0] + for stacklet, registered in sorted(clients.items()): + if registered or stacklet not in running: + continue + findings.append(Finding( + level=WARN, + title=f"{stacklet} has no single sign-on yet", + detail=f"It declares [oidc] and {provider} provides OpenID " + f"Connect, but {provider} has not registered it, so " + f"{stacklet} shows only its own login. Registration " + f"runs on `stack up {provider}`; then run " + f"`stack up {stacklet}` to apply the credentials.", + fix=f"stack up {provider}", + )) + return findings + + def diagnose(stacklets, expected_env, containers_for, container_env, *, missing_secrets=None, stale=()) -> list[Finding]: """Run every check across the given stacklets. diff --git a/lib/stack/stack.py b/lib/stack/stack.py index 3ed3a922..c9e90251 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -27,6 +27,12 @@ from .secrets import TomlSecretStore +def _oidc_secret_names(stacklet_id: str) -> tuple[str, str]: + """Secret names, in the provider's namespace, for one client stacklet.""" + sid = stacklet_id.upper() + return f"CLIENT_{sid}_ID", f"CLIENT_{sid}_SECRET" + + def _mail_accounts_env(mail_cfg: dict, secret_lookup) -> str: """Build MAIL_ACCOUNTS_JSON from stack.toml [mail] + a secret lookup. @@ -578,6 +584,7 @@ def env(self, stacklet_id: str) -> dict: template_vars = self._build_template_vars() template_vars["stacklet_id"] = stacklet_id template_vars.update(self._own_template_vars(stacklet_id, s.get("port", 0))) + template_vars.update(self._oidc_template_vars(s)) # Render templates — warn on missing vars (typos cause silent failures) import re @@ -616,6 +623,90 @@ def env(self, stacklet_id: str) -> dict: return rendered + # ── Single sign-on (OpenID Connect) ─────────────────────────────── + # + # A client stacklet declares `[oidc]`; a provider stacklet declares + # `[oidc_provider]`. Neither names the other. The provider's hooks + # read `oidc_clients()`, register each client with the identity + # service and hand the credentials to `store_oidc_client()`. The + # client reads them as `{oidc_issuer}`, `{oidc_client_id}` and + # `{oidc_client_secret}`. + # + # Credentials are stored in the provider's secret namespace, so + # `stack destroy ` deletes them and every client falls back + # to its own login on its next `stack up`. + + def oidc_provider(self) -> dict | None: + """The stacklet that declares `[oidc_provider]`, first found wins.""" + for s in self.discover(): + if "oidc_provider" in s.get("manifest", {}): + return s + return None + + def oidc_clients(self) -> list[dict]: + """Every stacklet with an `[oidc]` table, as a provider needs it. + + `callbacks` are rendered against the client's own template vars, + so `{url}` is the client's public URL. `client_id` and + `client_secret` are what is stored now, None before the first + registration. + """ + provider = self.oidc_provider() + template_vars = self._build_template_vars() + clients = [] + for s in self.discover(): + oidc = s.get("manifest", {}).get("oidc") + if oidc is None: + continue + own = collections.defaultdict(str, template_vars) + own.update(self._own_template_vars(s["id"], s.get("port", 0))) + client_id, client_secret = self._oidc_credentials(provider, s["id"]) + clients.append({ + "stacklet": s["id"], + "name": oidc.get("name") or s["name"], + "callbacks": [c.format_map(own) for c in oidc.get("callbacks", [])], + "client_id": client_id, + "client_secret": client_secret, + }) + return clients + + def store_oidc_client(self, stacklet_id: str, client_id: str, + client_secret: str) -> None: + """Store the credentials the provider issued for a client stacklet.""" + provider = self.oidc_provider() + if provider is None: + raise ValueError("No stacklet declares [oidc_provider]") + id_key, secret_key = _oidc_secret_names(stacklet_id) + self.secrets.set(provider["id"], id_key, client_id) + self.secrets.set(provider["id"], secret_key, client_secret) + + def _oidc_credentials(self, provider: dict | None, + stacklet_id: str) -> tuple[str | None, str | None]: + if provider is None: + return None, None + id_key, secret_key = _oidc_secret_names(stacklet_id) + return (self.secrets.get(provider["id"], id_key), + self.secrets.get(provider["id"], secret_key)) + + def _oidc_template_vars(self, stacklet: dict) -> dict: + """`{oidc_*}` for one stacklet: all three set, or all three empty. + + A client without both credentials gets nothing, so a compose file + can switch the login on by testing one variable. + """ + empty = {"oidc_issuer": "", "oidc_client_id": "", "oidc_client_secret": ""} + if "oidc" not in stacklet.get("manifest", {}): + return empty + provider = self.oidc_provider() + client_id, client_secret = self._oidc_credentials(provider, stacklet["id"]) + if provider is None or not (client_id and client_secret): + return empty + return { + "oidc_issuer": self._public_url(provider["id"], provider.get("port", 0)), + "oidc_client_id": client_id, + "oidc_client_secret": client_secret, + } + # ── List ────────────────────────────────────────────────────────── def list(self) -> dict: diff --git a/tests/unit/framework/test_doctor.py b/tests/unit/framework/test_doctor.py index d88a2e76..72ea3f83 100644 --- a/tests/unit/framework/test_doctor.py +++ b/tests/unit/framework/test_doctor.py @@ -396,3 +396,45 @@ def test_summary_counts_and_pluralises(): def test_summary_singular_error(): assert summarise([Finding(ERROR, "a", "", "")]) == "1 error." + + +# ── single sign-on ─────────────────────────────────────────────────────── + +class TestSingleSignOn: + """A client whose login button never appears says nothing on its own. + + Its `{oidc_*}` variables render empty whether there is no provider or + the provider has not registered it yet, and the service simply shows + its own login. Doctor tells the two apart. + """ + + def test_a_running_client_the_running_provider_has_not_registered(self): + from stack.doctor import WARN, check_oidc + [found] = check_oidc(["id"], {"docs": False}, running={"id", "docs"}) + assert found.level == WARN + assert "docs" in found.title + assert found.fix == "stack up id" + + def test_a_registered_client_is_fine(self): + from stack.doctor import check_oidc + assert check_oidc(["id"], {"docs": True}, running={"id", "docs"}) == [] + + def test_no_provider_means_single_sign_on_was_not_chosen(self): + from stack.doctor import check_oidc + assert check_oidc([], {"docs": False}, running={"docs"}) == [] + + def test_a_provider_that_was_never_brought_up_is_not_a_choice_yet(self): + """An extension in the directory is not a decision to use it.""" + from stack.doctor import check_oidc + assert check_oidc(["id"], {"docs": False}, running={"docs"}) == [] + + def test_a_client_that_is_not_running_is_not_reported(self): + from stack.doctor import check_oidc + assert check_oidc(["id"], {"docs": False}, running={"id"}) == [] + + def test_a_second_provider_is_ignored_and_doctor_says_which(self): + """The first provider discovered wins, which is otherwise silent.""" + from stack.doctor import WARN, check_oidc + [found] = check_oidc(["id", "sso"], {}, running={"id"}) + assert found.level == WARN + assert "id" in found.detail and "sso" in found.detail diff --git a/tests/unit/framework/test_oidc.py b/tests/unit/framework/test_oidc.py new file mode 100644 index 00000000..c61fcacb --- /dev/null +++ b/tests/unit/framework/test_oidc.py @@ -0,0 +1,147 @@ +"""Single sign-on between stacklets through an OpenID Connect provider. + +Two sides meet in the framework and never name each other: + +- A client stacklet declares `[oidc]` (a display name and its callback + URLs) and reads `{oidc_issuer}`, `{oidc_client_id}` and + `{oidc_client_secret}` in its `[env.defaults]`. +- A provider stacklet declares `[oidc_provider]`. Its hooks ask the + framework which clients exist, register them with the identity + service, and hand the credentials back to the framework. + +The client never learns which provider it got, and the provider never +learns a client's env schema. Without a provider, or before it has +registered a client, the three variables render empty and the client +keeps its own login. +""" + +from pathlib import Path + + +def _write(root: Path, sid: str, body: str) -> None: + (root / sid).mkdir(parents=True, exist_ok=True) + (root / sid / "stacklet.toml").write_text(f'id = "{sid}"\n{body}') + + +CLIENT = """ +port = 42020 + +[env.defaults] +OIDC_ISSUER = "{oidc_issuer}" +OIDC_CLIENT_ID = "{oidc_client_id}" +OIDC_CLIENT_SECRET = "{oidc_client_secret}" + +[oidc] +name = "Documents" +callbacks = ["{url}/accounts/oidc/sso/login/callback/"] +""" + +PROVIDER = """ +port = 42100 + +[oidc_provider] +""" + + +def _on_domain(stck, domain: str) -> None: + """Switch the instance to domain mode, where every URL is predictable.""" + cfg = stck.instance_dir / "stack.toml" + cfg.write_text(cfg.read_text().replace('domain = ""', f'domain = "{domain}"', 1)) + + +def _login_env(stck, sid="files") -> dict: + env = stck.env(sid) + return {k: env[k] for k in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET")} + + +EMPTY = {"OIDC_ISSUER": "", "OIDC_CLIENT_ID": "", "OIDC_CLIENT_SECRET": ""} + + +class TestClientEnvironment: + """What a client stacklet sees in its rendered env.""" + + def test_without_a_provider_the_login_settings_are_empty(self, make_stack): + """An install without an identity service keeps each service's own + login. Empty values are what compose files test for.""" + stck = make_stack() + _write(stck.root / "stacklets", "files", CLIENT) + assert _login_env(stck) == EMPTY + + def test_a_provider_that_has_not_registered_the_client_changes_nothing( + self, make_stack): + """Between installing the provider and its first provisioning run + the client has no credentials, and a half-configured login would + show a button that cannot work.""" + stck = make_stack() + _write(stck.root / "stacklets", "files", CLIENT) + _write(stck.mounted_extension_dir, "idp", PROVIDER) + assert _login_env(stck) == EMPTY + + def test_registered_client_gets_issuer_and_credentials(self, make_stack): + """The issuer is the provider's public URL, the address browsers + are redirected to and the one the client fetches discovery from.""" + stck = make_stack() + _on_domain(stck, "home.test") + _write(stck.root / "stacklets", "files", CLIENT) + _write(stck.mounted_extension_dir, "idp", PROVIDER) + + stck.store_oidc_client("files", client_id="c-123", client_secret="s-456") + + assert _login_env(stck) == { + "OIDC_ISSUER": "http://idp.home.test", + "OIDC_CLIENT_ID": "c-123", + "OIDC_CLIENT_SECRET": "s-456", + } + + def test_credentials_belong_to_the_provider(self, make_stack): + """Destroying the provider deletes its secrets. The client must then + fall back to its own login instead of pointing at a dead issuer.""" + stck = make_stack() + _write(stck.root / "stacklets", "files", CLIENT) + _write(stck.mounted_extension_dir, "idp", PROVIDER) + stck.store_oidc_client("files", client_id="c-123", client_secret="s-456") + + stck.secrets.clear("idp") + + assert _login_env(stck) == EMPTY + + +class TestProviderView: + """What a provider's hooks read to register clients.""" + + def test_lists_each_client_with_callbacks_on_its_own_url(self, make_stack): + """`{url}` in a callback is the client's public URL, not the + provider's, because the provider redirects back to the client.""" + stck = make_stack() + _on_domain(stck, "home.test") + _write(stck.root / "stacklets", "files", CLIENT) + _write(stck.root / "stacklets", "plain", "port = 42030\n") + _write(stck.mounted_extension_dir, "idp", PROVIDER) + + assert stck.oidc_clients() == [{ + "stacklet": "files", + "name": "Documents", + "callbacks": ["http://files.home.test/accounts/oidc/sso/login/callback/"], + "client_id": None, + "client_secret": None, + }] + + def test_shows_what_is_already_stored(self, make_stack): + """A provider must not rotate a secret on every run: the client + only reads it when its containers are recreated.""" + stck = make_stack() + _write(stck.root / "stacklets", "files", CLIENT) + _write(stck.mounted_extension_dir, "idp", PROVIDER) + stck.store_oidc_client("files", client_id="c-123", client_secret="s-456") + + [client] = stck.oidc_clients() + assert (client["client_id"], client["client_secret"]) == ("c-123", "s-456") + + def test_the_client_name_defaults_to_the_stacklet_name(self, make_stack): + stck = make_stack() + _write(stck.root / "stacklets", "files", + 'name = "Family Files"\nport = 42020\n[oidc]\ncallbacks = []\n') + _write(stck.mounted_extension_dir, "idp", PROVIDER) + + [client] = stck.oidc_clients() + assert client["name"] == "Family Files" From f138f193aefd071b5418d5611ca0815e56f8e70b Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 23 Sep 2026 14:46:59 +0200 Subject: [PATCH 2/2] feat(docs): log in to Paperless through the stack's OIDC provider When a stacklet that provides OpenID Connect is installed and has registered the docs stacklet, Paperless shows a "Sign in with family account" button next to the password form. Without a provider the login is unchanged. - A first login creates the Paperless account; an existing account is linked by verified email. - New accounts join the `all_documents` group, which sees and edits the whole archive. The group and a one-time grant over existing documents are set up on the next `./stack up docs` after the provider exists. - The provider entry keeps the id `pocketid`, which Paperless stores on every linked account. A second entry for the same client breaks the signup step in allauth, so there is exactly one. --- stacklets/docs/docker-compose.yml | 16 ++ stacklets/docs/hooks/on_install_success.py | 10 ++ stacklets/docs/hooks/on_start_ready.py | 10 ++ stacklets/docs/permissions.py | 186 +++++++++++++++++++++ stacklets/docs/stacklet.toml | 12 ++ tests/unit/stacklets/test_docs_sso.py | 82 +++++++++ 6 files changed, 316 insertions(+) create mode 100644 stacklets/docs/permissions.py create mode 100644 tests/unit/stacklets/test_docs_sso.py diff --git a/stacklets/docs/docker-compose.yml b/stacklets/docs/docker-compose.yml index 65f49215..51cf2ad4 100644 --- a/stacklets/docs/docker-compose.yml +++ b/stacklets/docs/docker-compose.yml @@ -56,6 +56,22 @@ services: PAPERLESS_ADMIN_USER: ${ADMIN_USER} PAPERLESS_ADMIN_PASSWORD: ${ADMIN_PASSWORD} PAPERLESS_URL: ${PAPERLESS_URL} + # Single sign-on through the stack's OIDC provider. PAPERLESS_APPS + # is set only when a client id exists, so without a provider none + # of this is active. The provider decides who exists, so a first + # login may create the Paperless account (auto signup). Existing + # local users are linked by verified email (EMAIL_AUTHENTICATION). + # One entry: allauth resolves the app by client id during signup, + # and a second entry with the same client id fails with a server + # error. Paperless stores the provider_id on each linked account, + # and existing accounts are linked as `pocketid`, so that id stays; + # only the button label is neutral. + PAPERLESS_APPS: ${OIDC_CLIENT_ID:+allauth.socialaccount.providers.openid_connect} + PAPERLESS_SOCIALACCOUNT_PROVIDERS: '{"openid_connect":{"EMAIL_AUTHENTICATION":true,"EMAIL_AUTHENTICATION_AUTO_CONNECT":true,"APPS":[{"provider_id":"pocketid","name":"Sign in with family account","client_id":"${OIDC_CLIENT_ID}","secret":"${OIDC_CLIENT_SECRET}","settings":{"server_url":"${OIDC_ISSUER}/.well-known/openid-configuration"}}]}}' + PAPERLESS_SOCIAL_AUTO_SIGNUP: "true" + PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS: "true" + # New accounts join 'all_documents' (permissions.py keeps the group) + PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS: all_documents PAPERLESS_OCR_MODE: skip # 2.x always rejected a re-uploaded identical file. 3.0 made that # conditional on this flag: with it unset the duplicate is consumed diff --git a/stacklets/docs/hooks/on_install_success.py b/stacklets/docs/hooks/on_install_success.py index e1e74f0a..471e90bf 100644 --- a/stacklets/docs/hooks/on_install_success.py +++ b/stacklets/docs/hooks/on_install_success.py @@ -18,6 +18,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 def run(ctx): token = ensure_api_token(ctx) @@ -85,3 +86,12 @@ def _seed_taxonomy(ctx, token): seed_person_tags(url, token, ctx.users, step=ctx.step) language = ctx.env.get("LANGUAGE", "en") seed_taxonomy(url, token, language, step=ctx.step) + + # Document access groups exist only for single sign-on installs: the + # login through the OIDC provider creates accounts that need a group. + # Installs without a provider keep Paperless's permissions untouched. + if ctx.env.get("OIDC_CLIENT_ID"): + if ensure_group(url, token, step=ctx.step): + # first enablement: existing documents become visible to + # the group once; later documents come through the workflow + share_archive(url, token, step=ctx.step) diff --git a/stacklets/docs/hooks/on_start_ready.py b/stacklets/docs/hooks/on_start_ready.py index 3de21d24..2a05de1a 100644 --- a/stacklets/docs/hooks/on_start_ready.py +++ b/stacklets/docs/hooks/on_start_ready.py @@ -20,6 +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 def run(ctx): @@ -33,3 +34,12 @@ def run(ctx): language = ctx.env.get("LANGUAGE", "en") seed_taxonomy(url, token, language, step=ctx.step) + + # Document access groups exist only for single sign-on installs: the + # login through the OIDC provider creates accounts that need a group. + # Installs without a provider keep Paperless's permissions untouched. + if ctx.env.get("OIDC_CLIENT_ID"): + if ensure_group(url, token, step=ctx.step): + # first enablement: existing documents become visible to + # the group once; later documents come through the workflow + share_archive(url, token, step=ctx.step) diff --git a/stacklets/docs/permissions.py b/stacklets/docs/permissions.py new file mode 100644 index 00000000..7c91ed64 --- /dev/null +++ b/stacklets/docs/permissions.py @@ -0,0 +1,186 @@ +"""Document access groups in Paperless. + +Paperless shows a user only what they own or were granted, and a user +without any group may not even load the UI (403 on the settings +endpoint). Accounts that arrive through single sign-on (the OIDC provider) +start with no group. This module keeps one group, named by what it +grants rather than by who is in it: + + all_documents view and change on the whole archive, plus the rights + a member needs to work with it. + +Paperless puts new social accounts into it through +PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS; this module covers accounts +that existed before, and drift. Two more pieces make "all documents" +true: a workflow that grants the group on every document as it is +consumed, and a one-time grant over the documents that already exist +(share_archive). A more restricted group (own_documents) is added when +the first account that must not see everything arrives. + +Superusers (the technical admin) are left alone. Idempotent; runs on +every 'stack up docs'. +""" + +import json +import urllib.error +import urllib.request + +GROUP = "all_documents" +WORKFLOW = "Share new documents with all_documents" + +# What a family member may do. Documents are theirs to add, fix and +# remove. Tags, correspondents, document types and custom fields are +# shared vocabulary that the archivist also classifies against, so a +# member can add and rename entries but not delete one: a delete removes +# it from every document at once. Storage paths decide where files land +# on disk and stay an admin decision, so view only. Workflows are view +# only because this module's own workflow is what keeps "all documents" +# true. Saved views, notes and share links are personal, in full. +# `uisettings` is what the web UI loads first; without it the UI fails +# with 403. `logentry` shows a document's history. +PERMISSIONS = [ + f"{a}_{m}" + for m, actions in { + "document": "view add change delete", + "tag": "view add change", + "correspondent": "view add change", + "documenttype": "view add change", + "storagepath": "view", + "customfield": "view add change", + "savedview": "view add change delete", + "uisettings": "view change", + "note": "view add change delete", + "sharelink": "view add delete", + "paperlesstask": "view", + "workflow": "view", + "logentry": "view", + }.items() + for a in actions.split() +] + + +def _call(url, token, method, path, body=None): + req = urllib.request.Request( + f"{url}/api{path}", method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={"Authorization": f"Token {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + raw = r.read() + return json.loads(raw) if raw else None + + +def _paged(url, token, path): + # Paperless pages every list. `next` is an absolute URL, and `_call` + # takes a path under /api, so only that part is kept. + out, page = [], f"{path}{'&' if '?' in path else '?'}page_size=500" + while page: + d = _call(url, token, "GET", page) + out += d["results"] + nxt = d.get("next") + page = nxt.split("/api", 1)[1] if nxt else None + return out + + +def ensure_workflow(url, token, group_id, step): + """Grant the group view and change on every document at consumption. + + Paperless makes a new document visible to its owner only. A workflow + is the one place that runs for every document, whichever way it + arrives. + + Workflows have no stable key, so the name is the identity. An + existing one is left alone, also when an admin has edited it in the + UI; renaming it there makes this create a second one. + """ + flows = _paged(url, token, "/workflows/") + if any(w["name"] == WORKFLOW for w in flows): + return + _call(url, token, "POST", "/workflows/", { + "name": WORKFLOW, "order": 0, "enabled": True, + # trigger type 1 = consumption started. Sources 1, 2, 3 are the + # consume folder, the API (which the archivist bot uses) and mail. + # Paperless requires a filter; "*" matches every file name + "triggers": [{"type": 1, "sources": [1, 2, 3], "filter_filename": "*"}], + # action type 1 = assignment + "actions": [{"type": 1, "assign_view_groups": [group_id], "assign_change_groups": [group_id]}], + }) + step(f"Paperless workflow '{WORKFLOW}' created") + + +def share_archive(url, token, step=None): + """One-time grant of view and change to the group over every + existing document. + + Documents consumed before the workflow existed have owner-only + permissions. This runs once, when the group is created, and never + again: from then on the workflow covers new documents, and a repeat + would restore grants an admin removed on purpose. `merge` adds the + group and keeps owners and other grants as they are. + """ + step = step or (lambda m: None) + groups = _call(url, token, "GET", "/groups/?page_size=100")["results"] + group = next((g for g in groups if g["name"] == GROUP), None) + if group is None: + step(f"Group '{GROUP}' missing, run ensure_group first") + return + ids = [d["id"] for d in _paged(url, token, "/documents/?fields=id")] + # In batches, so one request stays small on a large archive. + for i in range(0, len(ids), 500): + _call(url, token, "POST", "/documents/bulk_edit/", { + "documents": ids[i:i + 500], "method": "set_permissions", + "parameters": {"set_permissions": {"view": {"users": [], "groups": [group["id"]]}, + "change": {"users": [], "groups": [group["id"]]}}, + "merge": True}, + }) + step(f"{len(ids)} documents shared with '{GROUP}'") + + +def ensure_group(url: str, token: str, step=None) -> bool: + """Keep the group as this module defines it and every member in it. + + Returns True when the group was created in this run, so the caller + can do the one-time archive grant exactly once. + + The group is named by what it grants, so its permissions are part of + this code: an edit in the Paperless UI is reset on the next + `stack up docs`. Output names only what changed, so a run with + nothing to do prints nothing. + + Errors are reported, not raised. A failed permission sync must not + stop `stack up docs`: Paperless and its login still work, and the + next run tries again. + """ + step = step or (lambda m: None) + created = False + try: + groups = _call(url, token, "GET", "/groups/?page_size=100")["results"] + group = next((g for g in groups if g["name"] == GROUP), None) + if group is None: + group = _call(url, token, "POST", "/groups/", {"name": GROUP, "permissions": PERMISSIONS}) + created = True + step(f"Paperless group '{GROUP}' created") + elif sorted(group["permissions"]) != sorted(PERMISSIONS): + _call(url, token, "PATCH", f"/groups/{group['id']}/", {"permissions": PERMISSIONS}) + step(f"Paperless group '{GROUP}' permissions updated") + + ensure_workflow(url, token, group["id"], step) + + # Every account that is not a superuser joins the group. Accounts + # created by the single sign-on login get it from Paperless + # (PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS); this covers local + # accounts from before, which otherwise have no group and cannot + # load the UI. Superusers see everything already, and the + # technical admin stays out of member groups. + # One page: a household has far fewer than 200 accounts. + users = _call(url, token, "GET", "/users/?page_size=200")["results"] + for u in users: + if u.get("is_superuser") or group["id"] in (u.get("groups") or []): + continue + _call(url, token, "PATCH", f"/users/{u['id']}/", {"groups": sorted(set(u.get("groups") or []) | {group["id"]})}) + step(f"Paperless user {u['username']} added to '{GROUP}'") + except urllib.error.HTTPError as e: + step(f"Paperless permissions: {e.code} {e.read().decode()[:160]}") + except urllib.error.URLError as e: + step(f"Paperless permissions skipped: {e}") + return created diff --git a/stacklets/docs/stacklet.toml b/stacklets/docs/stacklet.toml index 11b90502..0fc9f26e 100644 --- a/stacklets/docs/stacklet.toml +++ b/stacklets/docs/stacklet.toml @@ -46,6 +46,18 @@ ADMIN_USER = "{admin_username}" ADMIN_PASSWORD = "{admin_password}" PAPERLESS_URL = "{url}" LANGUAGE = "{language}" +# Single sign-on through the stack's OIDC provider, if one is installed. +# Empty until the provider has registered this stacklet, and then the +# compose file leaves Paperless's login untouched. +OIDC_ISSUER = "{oidc_issuer}" +OIDC_CLIENT_ID = "{oidc_client_id}" +OIDC_CLIENT_SECRET = "{oidc_client_secret}" + +# Declares this stacklet as an OpenID Connect client. The callback path +# carries the provider_id of the entry in the compose file. +[oidc] +name = "Documents" +callbacks = ["{url}/accounts/oidc/pocketid/login/callback/"] [health] url = "http://localhost:42020/api/" diff --git a/tests/unit/stacklets/test_docs_sso.py b/tests/unit/stacklets/test_docs_sso.py new file mode 100644 index 00000000..0c3a1ca0 --- /dev/null +++ b/tests/unit/stacklets/test_docs_sso.py @@ -0,0 +1,82 @@ +"""Paperless login through the stack's OIDC provider. + +The docs stacklet is an OIDC client. Without a provider its login is +Paperless's own. Once a provider has registered it, Paperless offers +the provider's login next to the password form. + +Paperless uses django-allauth, which serves one callback per provider +entry at `/accounts/oidc//login/callback/`. The provider +redirects only to callbacks it was given, so every entry in the compose +file needs its callback in the `[oidc]` table. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(REPO / "lib")) + +from stack import Stack # noqa: E402 + + +@pytest.fixture +def stack(tmp_path): + """The repo's stacklets over a throwaway instance in domain mode.""" + (tmp_path / "stack.toml").write_text( + '[core]\ndomain = "home.example.family"\n' + f'extension_dirs = ["{tmp_path / "ext"}"]\n') + (tmp_path / ".stack").mkdir() + return Stack(root=REPO, data=tmp_path / "data", instance_dir=tmp_path) + + +@pytest.fixture +def with_provider(stack, tmp_path): + """The same instance with an OIDC provider installed as an extension.""" + (tmp_path / "ext" / "idp").mkdir(parents=True) + (tmp_path / "ext" / "idp" / "stacklet.toml").write_text( + 'id = "idp"\nport = 42100\n[oidc_provider]\n') + return stack + + +def _login(env: dict) -> tuple[str, str, str]: + return env["OIDC_ISSUER"], env["OIDC_CLIENT_ID"], env["OIDC_CLIENT_SECRET"] + + +def _compose_provider_ids() -> list[str]: + compose = (REPO / "stacklets" / "docs" / "docker-compose.yml").read_text() + line = next(row for row in compose.splitlines() + if "PAPERLESS_SOCIALACCOUNT_PROVIDERS" in row) + providers = json.loads(line.split(":", 1)[1].strip().strip("'")) + return [app["provider_id"] for app in providers["openid_connect"]["APPS"]] + + +def test_without_a_provider_paperless_keeps_its_own_login(stack): + assert _login(stack.env("docs")) == ("", "", "") + + +def test_a_registered_provider_turns_the_login_on(with_provider): + with_provider.store_oidc_client("docs", client_id="c-1", client_secret="s-1") + assert _login(with_provider.env("docs")) == ( + "http://idp.home.example.family", "c-1", "s-1") + + +def test_every_paperless_provider_entry_has_a_registered_callback(with_provider): + """A missing callback fails only at login time, with a provider error + page, so the two lists are compared here.""" + [docs] = [c for c in with_provider.oidc_clients() if c["stacklet"] == "docs"] + expected = [f"http://docs.home.example.family/accounts/oidc/{pid}/login/callback/" + for pid in _compose_provider_ids()] + assert sorted(docs["callbacks"]) == sorted(expected) + + +def test_one_provider_entry_keeps_linked_accounts_working(): + """allauth resolves the app by client id during signup, so a second + entry with the same client id fails that step with a server error. + Paperless stores the provider_id on each linked account, and existing + accounts are linked as `pocketid`, so that id stays.""" + assert _compose_provider_ids() == ["pocketid"]