From e4a3d138703e78754e1175f49abf10e6595a5f49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:32:03 -0500 Subject: [PATCH] Add a scoped newsletter API user for the scheduled campaign task Token is generated once and kept in Secret listmonk-newsletter-api. Tested: create, test-send and schedule work; subscribers/settings 403. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++++++ defaults/main.yml | 10 +++++++++ tasks/main.yml | 40 ++++++++++++++++++++++++++++++++++ templates/configure.sql.j2 | 44 +++++++++++++++++++++++++++----------- tests/render.yml | 1 + 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 614a0c9..4d87d9e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,15 @@ The eom.dev playbook maps them from the Vault names `listmonk_postgresql_passwor 1. In LDAP, create the posix account `news` with `mail: news@eom.dev` and add it to `cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev`. Its password is `listmonk_smtp_password`. 2. Add the `listmonk` client to Dex (`ericomeehan.dex/playbooks/deploy-eom.yml`) and run that play. +## API users + +| User | Can do | Token | +| --- | --- | --- | +| `membership` | Read and manage subscribers on `eom.dev` (signup opt-in, profile toggle) | `listmonk_api_token` (Vault); same value as `LISTMONK_API_TOKEN` in the www `membership` Secret | +| `newsletter` | Create, edit, test-send and schedule campaigns to `eom.dev`; no subscriber data or settings | Generated by the role, stored in `Secret/listmonk-newsletter-api` (`url`, `username`, `token`) | + +Neither user can read settings or other lists. To rotate the newsletter token, delete its Secret and run the play again. + ## Embedding a signup form listmonk accepts a plain HTML POST, so any eom.dev page can include a form: diff --git a/defaults/main.yml b/defaults/main.yml index 9b91a55..04ee5c0 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -81,6 +81,16 @@ listmonk_api_role: Membership sync listmonk_api_lists: - eom.dev +# API user for the scheduled newsletter task: create, edit and schedule +# campaigns to listmonk_newsletter_api_lists; no subscriber data or settings. +# Its token is generated once and kept in Secret -newsletter-api +# (keys: url, username, token) for the task to read with kubectl. +listmonk_newsletter_api_enabled: true +listmonk_newsletter_api_user: newsletter +listmonk_newsletter_api_role: Newsletter automation +listmonk_newsletter_api_lists: + - eom.dev + # Drop the sample lists, subscribers and campaign created by --install. listmonk_remove_sample_data: true diff --git a/tasks/main.yml b/tasks/main.yml index 2086063..abffff2 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -53,6 +53,46 @@ wait: true wait_timeout: "{{ listmonk_wait_timeout }}" +- name: Read the newsletter automation credentials + kubernetes.core.k8s_info: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + kind: Secret + namespace: "{{ listmonk_namespace }}" + name: "{{ listmonk_name }}-newsletter-api" + register: _listmonk_newsletter_secret + no_log: true + when: listmonk_newsletter_api_enabled | bool + +# Generated once, then reused; delete the Secret to rotate the token. +- name: Use or generate the newsletter automation token + ansible.builtin.set_fact: + listmonk_newsletter_api_token: >- + {{ (_listmonk_newsletter_secret.resources[0].data.token | b64decode) + if _listmonk_newsletter_secret.resources | length > 0 + else lookup('ansible.builtin.password', '/dev/null', chars=['ascii_letters', 'digits'], length=48) }} + no_log: true + when: listmonk_newsletter_api_enabled | bool + +- name: Store the newsletter automation credentials + kubernetes.core.k8s: + kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}" + context: "{{ listmonk_kube_context | default(omit, true) }}" + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ listmonk_name }}-newsletter-api" + namespace: "{{ listmonk_namespace }}" + type: Opaque + stringData: + url: "{{ listmonk_root_url }}" + username: "{{ listmonk_newsletter_api_user }}" + token: "{{ listmonk_newsletter_api_token }}" + no_log: true + when: listmonk_newsletter_api_enabled | bool + # Settings, Dex admins and lists live in listmonk's database, not its config. - name: Converge database-held configuration kubernetes.core.k8s_exec: diff --git a/templates/configure.sql.j2 b/templates/configure.sql.j2 index 20e219b..0bc1cfc 100644 --- a/templates/configure.sql.j2 +++ b/templates/configure.sql.j2 @@ -103,41 +103,59 @@ WITH upd AS ( SELECT count(*) FROM upd; {% endfor %} -{% if listmonk_api_token | length > 0 %} -{#- API user for the membership app: subscribers on listmonk_api_lists only. #} +{#- Scoped API users: the membership app (subscribers) and the newsletter + automation (campaigns). Each gets a user role and a list role limited to + its lists. listmonk caches API users at startup; the play restarts it. #} +{%- set api_users = [] %} +{%- if listmonk_api_token | length > 0 %} +{%- set _ = api_users.append({'username': listmonk_api_user, 'token': listmonk_api_token, 'role': listmonk_api_role, + 'permissions': ['subscribers:get', 'subscribers:manage'], 'lists': listmonk_api_lists}) %} +{%- endif %} +{%- if listmonk_newsletter_api_token | default('') | length > 0 %} +{%- set _ = api_users.append({'username': listmonk_newsletter_api_user, 'token': listmonk_newsletter_api_token, 'role': listmonk_newsletter_api_role, + 'permissions': ['campaigns:get', 'campaigns:manage', 'campaigns:send', 'templates:get'], 'lists': listmonk_newsletter_api_lists}) %} +{%- endif %} +{% for api in api_users %} WITH ins AS ( INSERT INTO roles (type, name, permissions) - SELECT 'user', {{ q(listmonk_api_role) }}, ARRAY['subscribers:get', 'subscribers:manage'] - WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'user' AND name = {{ q(listmonk_api_role) }}) + SELECT 'user', {{ q(api.role) }}, ARRAY[{% for p in api.permissions %}{{ q(p) }}{{ ',' if not loop.last }}{% endfor %}] + WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'user' AND name = {{ q(api.role) }}) RETURNING id ) SELECT count(*) FROM ins; +WITH upd AS ( + UPDATE roles SET permissions = ARRAY[{% for p in api.permissions %}{{ q(p) }}{{ ',' if not loop.last }}{% endfor %}], updated_at = NOW() + WHERE type = 'user' AND name = {{ q(api.role) }} + AND permissions IS DISTINCT FROM ARRAY[{% for p in api.permissions %}{{ q(p) }}{{ ',' if not loop.last }}{% endfor %}] + RETURNING id +) +SELECT count(*) FROM upd; WITH ins AS ( INSERT INTO roles (type, name) - SELECT 'list', {{ q(listmonk_api_role) }} - WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'list' AND name = {{ q(listmonk_api_role) }} AND parent_id IS NULL) + SELECT 'list', {{ q(api.role) }} + WHERE NOT EXISTS (SELECT 1 FROM roles WHERE type = 'list' AND name = {{ q(api.role) }} AND parent_id IS NULL) RETURNING id ) SELECT count(*) FROM ins; WITH parent AS ( - SELECT id FROM roles WHERE type = 'list' AND name = {{ q(listmonk_api_role) }} AND parent_id IS NULL + SELECT id FROM roles WHERE type = 'list' AND name = {{ q(api.role) }} AND parent_id IS NULL ), ins AS ( INSERT INTO roles (type, parent_id, list_id, permissions) SELECT 'list', parent.id, l.id, ARRAY['list:get', 'list:manage'] FROM parent, lists l - WHERE l.name IN ({% for n in listmonk_api_lists %}{{ q(n) }}{{ ',' if not loop.last }}{% endfor %}) + WHERE l.name IN ({% for n in api.lists %}{{ q(n) }}{{ ',' if not loop.last }}{% endfor %}) ON CONFLICT (parent_id, list_id) DO NOTHING RETURNING id ) SELECT count(*) FROM ins; WITH ur AS ( - SELECT id FROM roles WHERE type = 'user' AND name = {{ q(listmonk_api_role) }} + SELECT id FROM roles WHERE type = 'user' AND name = {{ q(api.role) }} ), lr AS ( - SELECT id FROM roles WHERE type = 'list' AND name = {{ q(listmonk_api_role) }} AND parent_id IS NULL + SELECT id FROM roles WHERE type = 'list' AND name = {{ q(api.role) }} AND parent_id IS NULL ), up AS ( INSERT INTO users (username, password_login, password, email, name, type, user_role_id, list_role_id, status) - SELECT {{ q(listmonk_api_user) }}, false, {{ q(listmonk_api_token | hash('sha256')) }}, - {{ q(listmonk_api_user ~ '@api') }}, {{ q(listmonk_api_user) }}, 'api', ur.id, lr.id, 'enabled' + SELECT {{ q(api.username) }}, false, {{ q(api.token | hash('sha256')) }}, + {{ q(api.username ~ '@api') }}, {{ q(api.username) }}, 'api', ur.id, lr.id, 'enabled' FROM ur, lr ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, user_role_id = EXCLUDED.user_role_id, @@ -147,7 +165,7 @@ WITH ur AS ( RETURNING id ) SELECT count(*) FROM up; -{% endif %} +{% endfor %} {% if listmonk_remove_sample_data | bool %} WITH del AS ( diff --git a/tests/render.yml b/tests/render.yml index 884e181..2be100b 100644 --- a/tests/render.yml +++ b/tests/render.yml @@ -18,6 +18,7 @@ listmonk_smtp_password: render-only listmonk_oidc_client_secret: render-only listmonk_api_token: render-only-api-token + listmonk_newsletter_api_token: render-only-newsletter-token - name: Output directory ansible.builtin.file: path: "{{ out }}"