From 955f1b874f7f2db174fa5382e47b27e60474253e Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 16:56:57 +0200 Subject: [PATCH] Deliver the processing agreement, and hold the proof it was accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Art. 28(3) GDPR wants a contract wherever personal data is processed on somebody else's behalf, which is the whole of what this product does. "In writing" there includes electronic form (Art. 28(9)), so a document the customer can read plus a recorded acceptance is enough — no signature on paper. The website already promises "AV-Vertrag inklusive", which means it has to be obtainable without asking us for it. It was not obtainable at all. **The text is never this application's.** An operator uploads the document their lawyer wrote, names the version, and publishes it; the measures ride along as a second file, because they are an annex to the agreement and "which measures applied when this customer accepted" has to have one answer. Inventing the text here would have been worse than having none. **Uploading and publishing are two acts.** Acceptance is per version, so publishing leaves every customer who accepted the previous one outstanding again — correct, and far too expensive to trigger by dropping a file on a form. It goes through a confirmation modal that says exactly that (R23). **The customer's side** is a card in the contract tab: read the agreement, read the measures, one press to conclude it. What that press records is what makes it evidence rather than a flag — the version, the moment, the address it came from, and the login that pressed. Pressing twice is one agreement (unique index, not a check somebody can forget), and a superseded acceptance is kept rather than overwritten: it was true when it was made, and the history is the point. Nothing renders until a version is in force. A card offering an agreement that does not exist is worse than the silence. The files live on the private disk and are served through routes that check who is asking — an agreement is not a public asset, and a guessable URL to one would be a list of who our customers are. The customer route takes no version parameter: which document applies is ours to say. `dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps the platform running does not thereby decide what every customer is asked to agree to — and a capability written under `web` since the 2026-07-29 move lands in a guard nothing authenticates against, which is how this one first shipped answering 403 to a role that visibly had it. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/ConfirmPublishDpa.php | 45 ++++ app/Livewire/Admin/ProcessingAgreements.php | 129 +++++++++++ app/Livewire/Settings.php | 32 +++ app/Models/DpaAcceptance.php | 42 ++++ app/Models/DpaVersion.php | 60 +++++ app/Services/Legal/ProcessingAgreement.php | 78 +++++++ app/Support/Navigation.php | 4 + ...ocessing_agreement_and_who_accepted_it.php | 76 ++++++ ...1_101000_add_the_dpa_manage_permission.php | 40 ++++ lang/de/admin.php | 1 + lang/de/dpa.php | 17 ++ lang/de/dpa_admin.php | 35 +++ lang/en/admin.php | 1 + lang/en/dpa.php | 17 ++ lang/en/dpa_admin.php | 35 +++ .../admin/confirm-publish-dpa.blade.php | 15 ++ .../admin/processing-agreements.blade.php | 151 ++++++++++++ resources/views/livewire/settings.blade.php | 63 +++++ routes/admin.php | 20 ++ routes/web.php | 19 ++ tests/Feature/Admin/RbacMoveTest.php | 14 +- tests/Feature/ProcessingAgreementTest.php | 216 ++++++++++++++++++ 22 files changed, 1104 insertions(+), 6 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmPublishDpa.php create mode 100644 app/Livewire/Admin/ProcessingAgreements.php create mode 100644 app/Models/DpaAcceptance.php create mode 100644 app/Models/DpaVersion.php create mode 100644 app/Services/Legal/ProcessingAgreement.php create mode 100644 database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php create mode 100644 database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php create mode 100644 lang/de/dpa.php create mode 100644 lang/de/dpa_admin.php create mode 100644 lang/en/dpa.php create mode 100644 lang/en/dpa_admin.php create mode 100644 resources/views/livewire/admin/confirm-publish-dpa.blade.php create mode 100644 resources/views/livewire/admin/processing-agreements.blade.php create mode 100644 tests/Feature/ProcessingAgreementTest.php diff --git a/app/Livewire/Admin/ConfirmPublishDpa.php b/app/Livewire/Admin/ConfirmPublishDpa.php new file mode 100644 index 0000000..64ce619 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPublishDpa.php @@ -0,0 +1,45 @@ +authorize('dpa.manage'); + + $version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + + $this->uuid = $uuid; + $this->version = $version->version; + } + + public function confirm(): void + { + $this->authorize('dpa.manage'); + $this->dispatch('dpa-publish-confirmed', uuid: $this->uuid); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-publish-dpa'); + } +} diff --git a/app/Livewire/Admin/ProcessingAgreements.php b/app/Livewire/Admin/ProcessingAgreements.php new file mode 100644 index 0000000..a3fb285 --- /dev/null +++ b/app/Livewire/Admin/ProcessingAgreements.php @@ -0,0 +1,129 @@ +authorize('dpa.manage'); + } + + public function upload(): void + { + $this->authorize('dpa.manage'); + + $data = $this->validate([ + 'version' => 'required|string|max:64|unique:dpa_versions,version', + 'note' => 'nullable|string|max:2000', + // PDF only, and a real size limit: this is handed to customers as + // the contract between us, not as an editable file. + 'agreement' => 'required|file|mimes:pdf|max:10240', + 'measures' => 'nullable|file|mimes:pdf|max:10240', + ]); + + // The private disk. An agreement is not a public asset, and a guessable + // URL to one would be a list of who our customers are. + $version = DpaVersion::query()->create([ + 'version' => $data['version'], + 'note' => $data['note'] ?: null, + 'agreement_path' => $this->agreement->store('dpa', 'local'), + 'measures_path' => $this->measures?->store('dpa', 'local'), + 'created_by' => Auth::guard('operator')->id(), + ]); + + $this->reset('version', 'note', 'agreement', 'measures'); + + $this->dispatch('notify', message: __('dpa_admin.uploaded', ['version' => $version->version])); + } + + /** + * Put a version in force. + * + * Deliberately separate from the upload: every customer who had accepted the + * previous one is outstanding again from this moment, and that is not + * something to do by dropping a file on a form. + */ + #[On('dpa-publish-confirmed')] + public function publish(string $uuid): void + { + $this->authorize('dpa.manage'); + + $version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + + if ($version->isPublished()) { + return; + } + + $version->update(['published_at' => now()]); + + $this->dispatch('notify', message: __('dpa_admin.published', ['version' => $version->version])); + } + + public function render() + { + $this->authorize('dpa.manage'); + + $current = app(ProcessingAgreement::class)->current(); + + // How many customers are outstanding on the version in force. Counted + // against customers who are actually still customers — a closed record + // is not somebody we are waiting on. + $customers = Customer::query()->whereNull('closed_at')->count(); + $accepted = $current === null ? 0 : $current->acceptances()->count(); + + return view('livewire.admin.processing-agreements', [ + 'versions' => DpaVersion::query()->withCount('acceptances')->latest('id')->get(), + 'current' => $current, + 'customers' => $customers, + 'accepted' => $accepted, + 'outstanding' => max(0, $customers - $accepted), + ]); + } + + /** Whether a stored file is actually there, for the list. */ + public function exists(?string $path): bool + { + return $path !== null && Storage::disk('local')->exists($path); + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 1842afb..dd4c3eb 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -10,6 +10,7 @@ use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Services\Billing\CustomDomainAccess; use App\Services\Billing\WithdrawalRight; +use App\Services\Legal\ProcessingAgreement; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -396,6 +397,32 @@ class Settings extends Component * never seen this card can still name the method — so the refusal is made * where the mutation is, once, in the customer's own words. */ + /** + * Accept the processing agreement in force. + * + * One press, no dialogue: this is an agreement, not a deletion, and putting + * a confirmation in front of it would only make the record harder to obtain. + * The evidence — the moment, the address, the login — is taken by the + * service, so nothing here can record an acceptance from an address it + * invented. + */ + public function acceptProcessingAgreement(): void + { + $c = $this->requireCustomer(); + + if ($c === null) { + return; + } + + if (app(ProcessingAgreement::class)->accept($c) === null) { + // Nothing published yet. The card does not render in that case, so + // reaching this line means somebody posted to it directly. + return; + } + + $this->dispatch('notify', message: __('dpa.accepted_notice')); + } + #[On('withdrawal-confirmed')] public function withdraw(): void { @@ -482,6 +509,11 @@ class Settings extends Component // thing somebody can choose. An unrecorded customer sees neither // selected, which is the truth about their record. 'customerTypes' => Customer::TYPES, + // The processing agreement in force and this customer's acceptance + // of THAT version — an acceptance of a superseded one is history, + // not an answer (see ProcessingAgreement). + 'dpa' => ($dpaService = app(ProcessingAgreement::class))->current(), + 'dpaAcceptance' => $dpaService->acceptanceOf($c), ]); } } diff --git a/app/Models/DpaAcceptance.php b/app/Models/DpaAcceptance.php new file mode 100644 index 0000000..b715121 --- /dev/null +++ b/app/Models/DpaAcceptance.php @@ -0,0 +1,42 @@ + 'datetime']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function version(): BelongsTo + { + return $this->belongsTo(DpaVersion::class, 'dpa_version_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/DpaVersion.php b/app/Models/DpaVersion.php new file mode 100644 index 0000000..16bc583 --- /dev/null +++ b/app/Models/DpaVersion.php @@ -0,0 +1,60 @@ + 'datetime']; + } + + /** + * The version in force: the newest published one. + * + * Newest by publication, not by id — a version prepared earlier and + * published later is the later one, and the id says nothing about that. + */ + public static function current(): ?self + { + return static::query() + ->whereNotNull('published_at') + ->where('published_at', '<=', now()) + ->latest('published_at') + ->first(); + } + + public function isPublished(): bool + { + return $this->published_at !== null && $this->published_at->isPast(); + } + + public function acceptances(): HasMany + { + return $this->hasMany(DpaAcceptance::class); + } + + public function operator() + { + return $this->belongsTo(Operator::class, 'created_by'); + } +} diff --git a/app/Services/Legal/ProcessingAgreement.php b/app/Services/Legal/ProcessingAgreement.php new file mode 100644 index 0000000..b8e8e68 --- /dev/null +++ b/app/Services/Legal/ProcessingAgreement.php @@ -0,0 +1,78 @@ +current(); + + if ($customer === null || $current === null) { + return null; + } + + return DpaAcceptance::query() + ->where('customer_id', $customer->id) + ->where('dpa_version_id', $current->id) + ->first(); + } + + public function accepted(?Customer $customer): bool + { + return $this->acceptanceOf($customer) !== null; + } + + /** + * Record an acceptance. + * + * firstOrCreate over the pair the unique index covers: pressing twice is not + * two agreements, and a double click must not become a duplicate-key error + * in front of somebody agreeing to a contract. + * + * The address is read here rather than passed in, so no caller can record an + * acceptance as coming from somewhere it invented. + */ + public function accept(Customer $customer, ?string $ip = null): ?DpaAcceptance + { + $current = $this->current(); + + if ($current === null) { + return null; + } + + return DpaAcceptance::query()->firstOrCreate( + ['customer_id' => $customer->id, 'dpa_version_id' => $current->id], + [ + 'user_id' => Auth::id(), + 'ip' => $ip ?? request()->ip(), + 'accepted_at' => now(), + ], + ); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index d77f9b2..79d027e 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -76,6 +76,10 @@ final class Navigation // it is sent from, not under Betrieb: both are about how mail // leaves this installation. ['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'], + // The processing agreement. Under System with the other things + // that are true of the whole installation rather than of one + // customer. + ['admin.dpa', 'file-text', 'dpa', 'dpa.manage'], // System, not Betrieb: the tunnel is how the console reaches // the estate at all. Nothing is provisioned, monitored or // updated through anything else, so it belongs beside the other diff --git a/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php new file mode 100644 index 0000000..61f93f2 --- /dev/null +++ b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php @@ -0,0 +1,76 @@ +id(); + $table->uuid()->unique(); + // What the operator calls it — "1.0", "2026-08", whatever the + // document itself says. Unique, because two documents with one name + // makes every acceptance ambiguous. + $table->string('version', 64)->unique(); + $table->string('agreement_path'); + $table->string('measures_path')->nullable(); + $table->text('note')->nullable(); + // Null while it is being prepared. Only a published version is + // shown to anybody, and only the newest published one is current. + $table->timestamp('published_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('operators')->nullOnDelete(); + $table->timestamps(); + + $table->index('published_at'); + }); + + Schema::create('dpa_acceptances', function (Blueprint $table) { + $table->id(); + $table->uuid()->unique(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->foreignId('dpa_version_id')->constrained('dpa_versions')->cascadeOnDelete(); + // The login that pressed it, kept as evidence even when that user is + // later removed — the acceptance was still made. + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('ip', 45)->nullable(); + $table->timestamp('accepted_at'); + $table->timestamps(); + + // One acceptance per customer per version. Pressing twice is not two + // agreements, and the unique index is what makes that true rather + // than a check somebody can forget. + $table->unique(['customer_id', 'dpa_version_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('dpa_acceptances'); + Schema::dropIfExists('dpa_versions'); + } +}; diff --git a/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php new file mode 100644 index 0000000..42f4213 --- /dev/null +++ b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php @@ -0,0 +1,40 @@ +forgetCachedPermissions(); + + $permission = Permission::findOrCreate('dpa.manage', 'operator'); + + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'operator')->givePermissionTo($permission); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::where('name', 'dpa.manage')->where('guard_name', 'operator')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index e6fd38b..c8ed3fd 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'dpa' => 'AV-Vertrag', 'mail_preview' => 'E-Mail-Vorschau', 'inbox' => 'Posteingang', 'mail_log' => 'Postausgang', diff --git a/lang/de/dpa.php b/lang/de/dpa.php new file mode 100644 index 0000000..2760de6 --- /dev/null +++ b/lang/de/dpa.php @@ -0,0 +1,17 @@ + 'AV-Vertrag & TOM', + 'sub' => 'Der Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO samt Dokumentation der technischen und organisatorischen Maßnahmen — für Ihre eigene Dokumentation und Prüfung.', + 'state_accepted' => 'Abgeschlossen', + 'state_open' => 'Offen', + 'read_agreement' => 'AV-Vertrag ansehen', + 'read_measures' => 'TOM ansehen', + 'version' => 'Fassung :version', + 'accept_hint' => 'Bitte lesen Sie den Vertrag und schließen Sie ihn ab. Wir halten dazu Zeitpunkt, Fassung und IP-Adresse fest; eine Unterschrift auf Papier ist nach Art. 28 Abs. 9 DSGVO nicht nötig.', + 'accept_cta' => 'Zur Kenntnis genommen und abgeschlossen', + 'accepted_on' => 'Fassung :version abgeschlossen am :when. Bei einer neuen Fassung melden wir uns und bitten Sie erneut um Ihre Zustimmung.', + 'accepted_notice' => 'AV-Vertrag abgeschlossen. Sie finden ihn jederzeit hier.', +]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php new file mode 100644 index 0000000..4ac6ec3 --- /dev/null +++ b/lang/de/dpa_admin.php @@ -0,0 +1,35 @@ + 'Rechtliches', + 'title' => 'AV-Vertrag & TOM', + 'subtitle' => 'Das Dokument, das Ihre Kunden nach Art. 28 DSGVO abschließen. Den Text laden Sie hoch — geschrieben wird er von Ihrer Rechtsberatung, nicht von dieser Anwendung.', + 'upload_title' => 'Neue Fassung hochladen', + 'upload_sub' => 'PDF, höchstens 10 MB. Die TOM sind eine Anlage zum Vertrag und werden mit derselben Fassung geführt.', + 'version' => 'Bezeichnung der Fassung', + 'version_hint' => 'Wie das Dokument selbst sie nennt — „1.0", „2026-08".', + 'note' => 'Interne Notiz', + 'note_hint' => 'Optional, nur für die Konsole.', + 'file_agreement' => 'AV-Vertrag (PDF)', + 'file_measures' => 'TOM (PDF)', + 'upload_cta' => 'Hochladen', + 'uploaded' => 'Fassung :version hochgeladen. Sie ist noch nicht in Kraft.', + 'current_title' => 'In Kraft', + 'none_yet' => 'Noch keine Fassung veröffentlicht. Solange nichts in Kraft ist, sehen Ihre Kunden den Punkt nicht.', + 'in_force_since' => 'seit :when', + 'accepted_count' => ':accepted von :total Kunden haben abgeschlossen.', + 'outstanding' => ':count offen', + 'versions_title' => 'Fassungen', + 'no_versions' => 'Noch nichts hochgeladen.', + 'col_version' => 'Fassung', + 'col_state' => 'Status', + 'col_accepted' => 'Abschlüsse', + 'col_files' => 'Dateien', + 'state_current' => 'In Kraft', + 'state_superseded' => 'Abgelöst', + 'state_draft' => 'Entwurf', + 'publish' => 'In Kraft setzen', + 'published' => 'Fassung :version ist in Kraft.', + 'publish_title' => 'Fassung :version in Kraft setzen?', + 'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index e008984..7b5bbd6 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'dpa' => 'Processing agreement', 'mail_preview' => 'Mail preview', 'inbox' => 'Inbox', 'mail_log' => 'Mail sent', diff --git a/lang/en/dpa.php b/lang/en/dpa.php new file mode 100644 index 0000000..c99e0e4 --- /dev/null +++ b/lang/en/dpa.php @@ -0,0 +1,17 @@ + 'Processing agreement & TOMs', + 'sub' => 'The data-processing agreement under Art. 28 GDPR together with the documentation of technical and organisational measures — for your own records and audits.', + 'state_accepted' => 'Concluded', + 'state_open' => 'Outstanding', + 'read_agreement' => 'Read the agreement', + 'read_measures' => 'Read the measures', + 'version' => 'Version :version', + 'accept_hint' => 'Please read the agreement and conclude it. We record the moment, the version and the IP address; a signature on paper is not required (Art. 28(9) GDPR).', + 'accept_cta' => 'Read and concluded', + 'accepted_on' => 'Version :version concluded on :when. When a new version is issued we will tell you and ask again.', + 'accepted_notice' => 'Agreement concluded. You can find it here at any time.', +]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php new file mode 100644 index 0000000..5a97e5e --- /dev/null +++ b/lang/en/dpa_admin.php @@ -0,0 +1,35 @@ + 'Legal', + 'title' => 'Processing agreement & TOMs', + 'subtitle' => 'The document your customers conclude under Art. 28 GDPR. You upload the text — it is written by your lawyer, not by this application.', + 'upload_title' => 'Upload a new version', + 'upload_sub' => 'PDF, at most 10 MB. The measures are an annex to the agreement and are versioned with it.', + 'version' => 'Version label', + 'version_hint' => 'What the document itself calls it — "1.0", "2026-08".', + 'note' => 'Internal note', + 'note_hint' => 'Optional, console only.', + 'file_agreement' => 'Agreement (PDF)', + 'file_measures' => 'Measures (PDF)', + 'upload_cta' => 'Upload', + 'uploaded' => 'Version :version uploaded. It is not in force yet.', + 'current_title' => 'In force', + 'none_yet' => 'No version published yet. While nothing is in force, customers do not see the section.', + 'in_force_since' => 'since :when', + 'accepted_count' => ':accepted of :total customers have concluded it.', + 'outstanding' => ':count outstanding', + 'versions_title' => 'Versions', + 'no_versions' => 'Nothing uploaded yet.', + 'col_version' => 'Version', + 'col_state' => 'State', + 'col_accepted' => 'Conclusions', + 'col_files' => 'Files', + 'state_current' => 'In force', + 'state_superseded' => 'Superseded', + 'state_draft' => 'Draft', + 'publish' => 'Put in force', + 'published' => 'Version :version is in force.', + 'publish_title' => 'Put version :version in force?', + 'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.', +]; diff --git a/resources/views/livewire/admin/confirm-publish-dpa.blade.php b/resources/views/livewire/admin/confirm-publish-dpa.blade.php new file mode 100644 index 0000000..79e75e0 --- /dev/null +++ b/resources/views/livewire/admin/confirm-publish-dpa.blade.php @@ -0,0 +1,15 @@ +
+
+ + + +
+

{{ __('dpa_admin.publish_title', ['version' => $version]) }}

+

{{ __('dpa_admin.publish_body') }}

+
+
+
+ {{ __('common.cancel') }} + {{ __('dpa_admin.publish') }} +
+
diff --git a/resources/views/livewire/admin/processing-agreements.blade.php b/resources/views/livewire/admin/processing-agreements.blade.php new file mode 100644 index 0000000..e52bad8 --- /dev/null +++ b/resources/views/livewire/admin/processing-agreements.blade.php @@ -0,0 +1,151 @@ +{{-- + The processing agreement, in the console. + + Upload left, what is in force and who has it right, the versions underneath. + Uploading and publishing are two acts on purpose: publishing leaves every + customer outstanding again, and that is not something to trigger by dropping + a file on a form. +--}} +
+ +
+

{{ __('dpa_admin.eyebrow') }}

+

+ {{ __('dpa_admin.title') }} +

+

{{ __('dpa_admin.subtitle') }}

+
+ +
+ + {{-- ── Upload ─────────────────────────────────────────────────────── --}} +
+
+

{{ __('dpa_admin.upload_title') }}

+

{{ __('dpa_admin.upload_sub') }}

+
+ +
+ + +
+ +
+
+ + + @error('agreement')

{{ $message }}

@enderror +
+
+ + + @error('measures')

{{ $message }}

@enderror +
+
+ +
+ + {{ __('dpa_admin.upload_cta') }} + +
+
+ + {{-- ── What is in force ───────────────────────────────────────────── --}} +
+

{{ __('dpa_admin.current_title') }}

+ + @if ($current === null) +

{{ __('dpa_admin.none_yet') }}

+ @else +

{{ $current->version }}

+ {{-- R19: stored in UTC, read on the wall clock. --}} +

{{ __('dpa_admin.in_force_since', ['when' => $current->published_at->local()->isoFormat('LL')]) }}

+ +
+

+ {{ __('dpa_admin.accepted_count', ['accepted' => $accepted, 'total' => $customers]) }} +

+ @if ($outstanding > 0) +

{{ __('dpa_admin.outstanding', ['count' => $outstanding]) }}

+ @endif +
+ @endif +
+
+ + {{-- ── The versions ───────────────────────────────────────────────────── --}} +
+
+

{{ __('dpa_admin.versions_title') }}

+
+ + @if ($versions->isEmpty()) +

{{ __('dpa_admin.no_versions') }}

+ @else +
+ + + + + + + + + + + + @foreach ($versions as $v) + + + + + + + + @endforeach + +
{{ __('dpa_admin.col_version') }}{{ __('dpa_admin.col_state') }}{{ __('dpa_admin.col_accepted') }}{{ __('dpa_admin.col_files') }}
+ {{ $v->version }} + @if ($v->note) + {{ $v->note }} + @endif + + @if ($v->isPublished()) + + {{ $current?->id === $v->id ? __('dpa_admin.state_current') : __('dpa_admin.state_superseded') }} + + {{ $v->published_at->local()->isoFormat('LL') }} + @else + {{ __('dpa_admin.state_draft') }} + @endif + {{ $v->acceptances_count }} + + + @if (! $v->isPublished()) + {{-- R23: a modal, because publishing leaves every + customer outstanding again. --}} + + {{ __('dpa_admin.publish') }} + + @endif +
+
+ @endif +
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2b1d584..05242c8 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -338,6 +338,69 @@ @endif + {{-- ── AV-Vertrag & TOM ────────────────────────────────────── + Art. 28(3) DSGVO wants a contract wherever personal data is + processed on somebody else's behalf, which is the whole of what + this product does. "In writing" there includes electronic form, + so the document plus a recorded acceptance is enough — and the + customer needs to be able to READ it without asking us for it, + because that is what the website promises. + + Nothing renders until an operator has published a version: a + card offering an agreement that does not exist would be worse + than the silence. --}} + @if ($dpa !== null) +
+
+
+

{{ __('dpa.title') }}

+

{{ __('dpa.sub') }}

+
+ @if ($dpaAcceptance) + + {{ __('dpa.state_accepted') }} + + @else + + {{ __('dpa.state_open') }} + + @endif +
+ +
+ + {{ __('dpa.read_agreement') }} + + @if ($dpa->measures_path) + + {{ __('dpa.read_measures') }} + + @endif + {{ __('dpa.version', ['version' => $dpa->version]) }} +
+ + @if ($dpaAcceptance) + {{-- What was agreed, when, and by whom — the same three + facts the register holds. R19: stored in UTC, read on + the wall clock. --}} +

+ {{ __('dpa.accepted_on', [ + 'version' => $dpa->version, + 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), + ]) }} +

+ @else +
+

{{ __('dpa.accept_hint') }}

+ + {{ __('dpa.accept_cta') }} + +
+ @endif +
+ @endif +
diff --git a/routes/admin.php b/routes/admin.php index c236e5e..b4b218c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -59,6 +59,26 @@ Route::get('/mail/preview/{key}', function (string $key) { // into a console page: this is the document itself. return response($mailable->render()); })->name('mail.preview.show'); +// The processing agreement (Art. 28 DSGVO): the document, the version in force, +// and who has accepted it. The TEXT is uploaded, never written here. +Route::get('/processing-agreement', Admin\ProcessingAgreements::class)->name('dpa'); +Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, string $which) { + abort_unless(auth('operator')->user()?->can('dpa.manage') ?? false, 403); + abort_unless(in_array($which, ['agreement', 'measures'], true), 404); + + $version = App\Models\DpaVersion::query()->where('uuid', $uuid)->firstOrFail(); + $path = $which === 'agreement' ? $version->agreement_path : $version->measures_path; + + abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); + + // Inline: a contract is read before it is filed, and a download that opens + // nowhere is a download somebody has to go looking for. + return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline', + ]); +})->name('dpa.file'); + // The answers an operator gives over and over, written once — see // Admin\MailTemplates. Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates'); diff --git a/routes/web.php b/routes/web.php index e7e7cfe..1a2436a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -304,6 +304,25 @@ $portal = function () { ); })->name('invoices.pdf'); + // The processing agreement the customer is being asked to accept — the + // version in FORCE, never one named in the address: which document + // applies is ours to say, not a parameter. + Route::get('/processing-agreement/{which}', function (string $which) { + abort_unless(in_array($which, ['agreement', 'measures'], true), 404); + + $version = app(App\Services\Legal\ProcessingAgreement::class)->current(); + $path = $version === null + ? null + : ($which === 'agreement' ? $version->agreement_path : $version->measures_path); + + abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404); + + return Illuminate\Support\Facades\Storage::disk('local')->response($path, null, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline', + ]); + })->name('dpa.file'); + Route::get('/billing', Billing::class)->name('billing'); Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index 77f2c9b..87af37f 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -12,11 +12,11 @@ use Spatie\Permission\Models\Role; it('moves every permission and role to the operator guard, leaving none behind', function () { expect(Permission::where('guard_name', 'web')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(0) - // 17 from the original seed, plus customers.grant_plan and - // instances.restart — the latter created straight onto this guard, - // because since this migration `web` is where a permission goes to - // match nobody at all. - ->and(Permission::where('guard_name', 'operator')->count())->toBe(19) + // 17 from the original seed, plus customers.grant_plan, + // instances.restart and dpa.manage — the later ones created straight + // onto this guard, because since this migration `web` is where a + // permission goes to match nobody at all. + ->and(Permission::where('guard_name', 'operator')->count())->toBe(20) ->and(Role::where('guard_name', 'operator')->count())->toBe(6); }); @@ -157,7 +157,9 @@ it('preflights every customer conflict before mutating anything, listing all of // Nothing mutated: still exactly the pre-migration shape, not "moved and // then rolled back" — there is nothing here to roll back on a real // server, which is the whole reason this has to be checked up front. - expect(Permission::where('guard_name', 'web')->count())->toBe(19) + // Every capability this installation has, pushed back to `web` above to + // stage the pre-migration shape — the figure grows with each new one. + expect(Permission::where('guard_name', 'web')->count())->toBe(20) ->and(Permission::where('guard_name', 'operator')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(6) ->and(Role::where('guard_name', 'operator')->count())->toBe(0) diff --git a/tests/Feature/ProcessingAgreementTest.php b/tests/Feature/ProcessingAgreementTest.php new file mode 100644 index 0000000..e978be3 --- /dev/null +++ b/tests/Feature/ProcessingAgreementTest.php @@ -0,0 +1,216 @@ +create(['email_verified_at' => now()]); + $customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]); + + return [$user, $customer]; +} + +function publishedDpa(string $version = '1.0', bool $withMeasures = true): DpaVersion +{ + Storage::fake('local'); + + return DpaVersion::query()->create([ + 'version' => $version, + 'agreement_path' => UploadedFile::fake()->create('avv.pdf', 12, 'application/pdf')->store('dpa', 'local'), + 'measures_path' => $withMeasures + ? UploadedFile::fake()->create('tom.pdf', 12, 'application/pdf')->store('dpa', 'local') + : null, + 'published_at' => now()->subDay(), + ]); +} + +it('shows the customer nothing at all until a version is in force', function () { + // A card offering an agreement that does not exist is worse than silence. + [$user] = dpaCustomer(); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertDontSee(__('dpa.title')); +}); + +it('offers the document and records the acceptance with its evidence', function () { + $version = publishedDpa(); + [$user, $customer] = dpaCustomer(); + + $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']); + + $page->assertSee(__('dpa.title')) + ->assertSee(__('dpa.state_open')) + ->assertSee(__('dpa.accept_cta')) + ->call('acceptProcessingAgreement') + ->assertDispatched('notify'); + + $acceptance = DpaAcceptance::query()->where('customer_id', $customer->id)->sole(); + + // What makes it evidence rather than a flag: the version, the moment, the + // login that pressed it, and where from. + expect($acceptance->dpa_version_id)->toBe($version->id) + ->and($acceptance->user_id)->toBe($user->id) + ->and($acceptance->accepted_at)->not->toBeNull() + ->and($acceptance->ip)->not->toBeNull(); + + // And the card now states it rather than asking again. + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('dpa.state_accepted')) + ->assertDontSee(__('dpa.accept_cta')); +}); + +it('counts pressing twice as one agreement', function () { + publishedDpa(); + [$user, $customer] = dpaCustomer(); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->call('acceptProcessingAgreement') + ->call('acceptProcessingAgreement'); + + expect(DpaAcceptance::query()->where('customer_id', $customer->id)->count())->toBe(1); +}); + +it('asks again when a new version comes into force, and keeps the old acceptance', function () { + // Acceptance is per version: a new one supersedes the last, and accepting a + // superseded document is not accepting the one in force. The old row stays — + // it was true when it was made, and the history is the point. + $first = publishedDpa('1.0'); + [$user, $customer] = dpaCustomer(); + + app(ProcessingAgreement::class)->accept($customer); + + expect(app(ProcessingAgreement::class)->accepted($customer))->toBeTrue(); + + $second = DpaVersion::query()->create([ + 'version' => '2.0', + 'agreement_path' => $first->agreement_path, + 'published_at' => now(), + ]); + + expect(app(ProcessingAgreement::class)->current()->id)->toBe($second->id) + ->and(app(ProcessingAgreement::class)->accepted($customer))->toBeFalse() + // Not deleted, not overwritten. + ->and(DpaAcceptance::query()->where('dpa_version_id', $first->id)->exists())->toBeTrue(); +}); + +it('serves the document to the customer, and only the one in force', function () { + publishedDpa(); + [$user] = dpaCustomer(); + + $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertOk(); + $this->actingAs($user)->get(route('dpa.file', 'measures'))->assertOk(); + + // Which document applies is ours to say, not a parameter somebody can put + // in the address. + $this->actingAs($user)->get(route('dpa.file', 'sonstwas'))->assertNotFound(); +}); + +it('does not hand the agreement to a stranger', function () { + publishedDpa(); + + $this->get(route('dpa.file', 'agreement'))->assertRedirect(); +}); + +it('answers 404 rather than 500 when nothing is published', function () { + [$user] = dpaCustomer(); + + $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertNotFound(); +}); + +// ---- The console ---- + +it('takes an upload as a draft, and only a second act puts it in force', function () { + // Publishing leaves every customer who accepted the previous version + // outstanding again. That is correct, and expensive to trigger by dropping a + // file on a form. + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '3.0') + ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) + ->call('upload') + ->assertHasNoErrors(); + + $version = DpaVersion::query()->where('version', '3.0')->sole(); + + expect($version->isPublished())->toBeFalse() + ->and(app(ProcessingAgreement::class)->current())->toBeNull() + ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->call('publish', $version->uuid); + + expect(app(ProcessingAgreement::class)->current()?->id)->toBe($version->id); +}); + +it('takes the agreement as a PDF and nothing else', function () { + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '4.0') + ->set('agreement', UploadedFile::fake()->create('avv.docx', 20, 'application/msword')) + ->call('upload') + ->assertHasErrors(['agreement']); +}); + +it('refuses two versions under one name', function () { + publishedDpa('1.0'); + Storage::fake('local'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(ProcessingAgreements::class) + ->set('version', '1.0') + ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf')) + ->call('upload') + ->assertHasErrors(['version']); +}); + +it('keeps the whole thing to operators who may manage it', function () { + // Its own capability: whoever keeps the platform running does not thereby + // decide what every customer is asked to agree to. + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(ProcessingAgreements::class) + ->assertForbidden(); + + $this->actingAs(operator('Read-only'), 'operator') + ->get(route('admin.dpa')) + ->assertForbidden(); +}); + +it('confirms publication in a modal rather than on one click', function () { + // R23, and for a reason beyond the rule: this is the click that makes every + // customer outstanding again. + $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/processing-agreements.blade.php')); + + expect($page)->toContain("component: 'admin.confirm-publish-dpa'"); + + $modal = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/ConfirmPublishDpa.php')); + + expect($modal)->toContain("dispatch('dpa-publish-confirmed'") + // The modal decides nothing: the permission check stays where it was. + ->and($modal)->not->toContain('published_at'); +});