From c4fe506860ebfdfb72d75ab94cd2682374c9af10 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Mon, 28 Sep 2026 19:10:47 -0400 Subject: [PATCH] feat(debyltech-cloud): lock customers to read-only, no discovery Customers are admin-created accounts in per-customer groups. They should read what staff share with them and nothing else. Checked against a test customer in the UI, and by running the sharee and contacts-menu search services as that user. - shareapi_exclude_groups=allow, list [admin]: only staff can share. Exclude mode ("yes") only disables sharing for users whose groups are ALL excluded, so it never catches a customer in their own group. - User/group autocomplete off. By default a customer typing "bas" found the owner's account. Staff share by exact group name; LibreSign signers are found by email. - New shares default to View only (shareapi_default_permissions=1). - files default_quota 0 B, so customers get no personal storage. Staff in cloud_debyltech_staff_users are exempted (skipped if not yet created). - No "Leon Green" sample contact in new address books. - The verify script fails the deploy if any isolation setting drifts. - README: staff and customer onboarding checklist. Co-Authored-By: Claude Opus 5.5 --- ansible/roles/podman/README.md | 32 ++++++++++ ansible/roles/podman/defaults/main.yml | 8 +++ .../tasks/containers/debyltech/cloud.yml | 62 +++++++++++++++++++ .../nextcloud/debyltechmail-verify.php.j2 | 16 +++++ 4 files changed, 118 insertions(+) diff --git a/ansible/roles/podman/README.md b/ansible/roles/podman/README.md index 1fe8e69..78fd7c9 100644 --- a/ansible/roles/podman/README.md +++ b/ansible/roles/podman/README.md @@ -154,6 +154,38 @@ LibreSign setup failures and buys nothing at this scale. (LibreSign issue #4872). +## cloud.debyltech.com accounts + +Registration is off, so every account is created by an admin in +Settings → Accounts. The isolation policy in +`tasks/containers/debyltech/cloud.yml` is enforced on every deploy and checked +by the verify script. + +**Staff**: add to the `admin` group (the only group allowed to share) and to +`cloud_debyltech_staff_users` in `defaults/main.yml`, so the next deploy +exempts them from the 0 B default quota. Until then, set the account's quota +to *Unlimited* by hand. + +**Customer**: +1. Create a group per customer, e.g. `Customer - Acme`. Use a consistent + prefix: autocomplete is off, so you share by typing the **exact** group + name. +2. Create the account with only their email filled in and no password, in + that group. Nextcloud sends a branded welcome email with a set-password + link. +3. Share a folder (for example `Customers/Acme`) with the group. It defaults + to **View only**; choose *Allow editing* only if they should upload. + +What a customer gets: +- read-only access to what's shared with them +- no personal storage (0 B quota) +- no sharing or public links +- no view of other accounts or groups: the share search and contacts menu + return nothing + +Signature requests to customers need no account: use LibreSign with their +email address. + ## Logging Every container runs with `log_driver=journald`, so container stdout lands in diff --git a/ansible/roles/podman/defaults/main.yml b/ansible/roles/podman/defaults/main.yml index 4dd3531..c4b9124 100644 --- a/ansible/roles/podman/defaults/main.yml +++ b/ansible/roles/podman/defaults/main.yml @@ -241,6 +241,14 @@ libresign_debyltech_cert_l: Newbury # cloud_debyltech_smtp_user / cloud_debyltech_smtp_pass in the vault. cloud_debyltech_smtp_host: email-smtp.us-east-1.amazonaws.com cloud_debyltech_smtp_port: 465 +# Staff vs customers (see "customer isolation" in containers/debyltech/cloud.yml). +# Only this group may share; everyone else -- customers -- can only read what +# is shared with them. Staff accounts are exempted from the 0 B default quota. +# Accounts listed here that do not exist yet are skipped, not created. +cloud_debyltech_staff_group: admin +cloud_debyltech_staff_users: + - admin + - bastian@debyltech.com # Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security diff --git a/ansible/roles/podman/tasks/containers/debyltech/cloud.yml b/ansible/roles/podman/tasks/containers/debyltech/cloud.yml index 287a899..4c70eb5 100644 --- a/ansible/roles/podman/tasks/containers/debyltech/cloud.yml +++ b/ansible/roles/podman/tasks/containers/debyltech/cloud.yml @@ -468,6 +468,68 @@ insertbefore: '^\);' create: false +# --------------------------------------------------------------------------- +# Customer isolation. Customers are admin-created accounts in a per-customer +# group, and must only READ what staff share with them -- not upload, not +# share onward, and not discover that other customers exist. Verified +# 2026-09-28 against a test customer, both in the UI and with the sharee and +# contacts-menu search services run as that user. +# +# shareapi_exclude_groups=allow + list=[staff] +# Only staff may share. NOT "yes" (exclude mode): that only disables +# sharing for users whose groups are ALL excluded, so a customer in +# their own per-customer group would never be caught by it. +# shareapi_allow_share_dialog_user_enumeration=no +# No partial-match browsing of accounts or groups, for anyone. By +# default a customer typing "bas" found the owner's account. Staff +# share to a customer group by typing its exact name; LibreSign signers +# are found by email and are unaffected. +# shareapi_default_permissions=1 +# New shares default to View only; tick "Allow editing" per share to +# let a customer upload. +# files default_quota=0 B +# No personal storage, so no "+ New" in a customer's own home. Uploads +# into a share granted editing count against the OWNER's quota and still +# work. Staff are exempted by the next task. +# dav enableDefaultContact=false +# No "Leon Green" sample contact in new address books. +- name: set debyltech-cloud customer isolation policy + become: true + become_user: "{{ podman_user }}" + ansible.builtin.command: > + podman exec -u www-data debyltech-cloud + php occ config:app:set {{ item.app }} {{ item.k }} --value={{ item.v | quote }} + {{ ('--type=' ~ item.t) if item.t is defined else '' }} + register: debyltech_isolation + changed_when: "'is now set to' in debyltech_isolation.stdout" + loop: + - {app: core, k: shareapi_exclude_groups, v: allow} + - {app: core, k: shareapi_exclude_groups_list, v: "{{ [cloud_debyltech_staff_group] | to_json }}"} + - {app: core, k: shareapi_allow_share_dialog_user_enumeration, v: "no"} + - {app: core, k: shareapi_default_permissions, v: "1"} + - {app: files, k: default_quota, v: "0 B"} + - {app: dav, k: enableDefaultContact, v: "0", t: boolean} + loop_control: + label: "{{ item.app }}.{{ item.k }}" + +- name: exempt debyltech-cloud staff from the zero default quota + become: true + become_user: "{{ podman_user }}" + ansible.builtin.shell: | + set -o pipefail + occ() { podman exec -u www-data debyltech-cloud php occ "$@"; } + occ user:info {{ item | quote }} >/dev/null 2>&1 || exit 0 + cur=$(occ user:setting {{ item | quote }} files quota) || cur='' + if [ "$cur" != none ]; then + occ user:setting {{ item | quote }} files quota none + echo CHANGED + fi + args: + executable: /bin/bash + register: debyltech_staff_quota + changed_when: "'CHANGED' in debyltech_staff_quota.stdout" + loop: "{{ cloud_debyltech_staff_users }}" + # --------------------------------------------------------------------------- # Branding: debyltechmail, cloned from skudakmail. custom_apps is a persisted # bind mount, so only enabling and config need reasserting. diff --git a/ansible/roles/podman/templates/nextcloud/debyltechmail-verify.php.j2 b/ansible/roles/podman/templates/nextcloud/debyltechmail-verify.php.j2 index 65720f8..2479455 100644 --- a/ansible/roles/podman/templates/nextcloud/debyltechmail-verify.php.j2 +++ b/ansible/roles/podman/templates/nextcloud/debyltechmail-verify.php.j2 @@ -149,6 +149,22 @@ if (!$emailOn) { . 'cannot be added as signers ("No signers.")'; } +// Customer isolation (see the policy task in containers/debyltech/cloud.yml). +// A stray click in Settings > Sharing can undo any of these, and each one +// quietly re-exposes customers to one another or lets them share onward. +$isolation = [ + 'shareapi_exclude_groups' => 'allow', + 'shareapi_exclude_groups_list' => json_encode(['{{ cloud_debyltech_staff_group }}']), + 'shareapi_allow_share_dialog_user_enumeration' => 'no', + 'shareapi_default_permissions' => '1', +]; +foreach ($isolation as $key => $want) { + $have = \OC::$server->get(\OCP\IConfig::class)->getAppValue('core', $key, ''); + if ($have !== $want) { + $failures[] = "core $key is \"$have\" -- expected \"$want\" (customer isolation)"; + } +} + $identDocs = $appConfig->getValueString('libresign', 'identification_documents', ''); if ($identDocs !== '0') { $failures[] = 'libresign identification_documents is "' . $identDocs