Write the processing agreement and its annex, and let it be downloaded

The mechanism shipped without a document. Both are here now, generated
from this repository rather than uploaded — so the company is named from
CompanyProfile exactly as the invoices name it, the sub-processor list is
the three places customer data really leaves this application, and the
deletion deadlines are read off the commands that enforce them. A wording
change is a diff somebody can review, not a file that appears.

`clupilot:publish-dpa 1.0` renders both to PDF (TCPDF, the engine the
invoices already use), stores them on the private disk and puts them in
force; `--draft` stops short of that. The console's upload form is
untouched: a lawyer's revision arrives as a PDF and becomes a version like
any other.

**The annex says what this installation does, not what it would like to.**
Every measure in it was checked against the code first — the isolation is
one VM per customer because that is what provisioning builds, the backup
is a daily snapshot job at 02:00 because that is what RegisterBackup
creates, the deletion deadlines are the two prune commands. Claims the
system does not currently keep are NOT in the document, and they are named
in the handover instead. A TOM that promises more than the machine does is
the document an auditor reads before looking.

**Downloading** now carries the version in the filename —
CluPilot-AV-Vertrag-1.0.pdf — so "which fassung did I agree to" is
answerable from a downloads folder months later without opening anything.
Both sides get the button; inline stays the default, because a contract is
read before it is filed.

Placeholder register data is left off the document rather than printed: a
"FN 000000a" on a contract looks like a real number and is not one.

Also: the wordmark scan tripped on a paragraph whose first word is the
company name, for the second time. It now looks for the name as the whole
CONTENT of an element, which is what a lockup is, rather than for the name
after a tag, which is also how a sentence starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 17:12:30 +02:00
parent 56daaef7de
commit d04ea712c7
14 changed files with 650 additions and 18 deletions

View File

@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use App\Models\DpaVersion;
use App\Services\Legal\DpaRenderer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Render the processing agreement and its annex, store them, put them in force.
*
* The documents are generated from the repository (resources/views/legal/dpa),
* so a change to the wording is a change somebody can review in a diff and the
* company name, the sub-processors and the deletion deadlines come from the same
* places the rest of the application reads them, which is what stops the
* agreement drifting away from what the software does.
*
* The console's upload form still exists and is unchanged: a lawyer's revision
* arrives as a PDF and becomes a version like any other. This command is for the
* version this application maintains itself.
*/
class PublishProcessingAgreement extends Command
{
protected $signature = 'clupilot:publish-dpa
{version : What the document calls itself, e.g. 1.0}
{--draft : Store it without putting it in force}';
protected $description = 'Render the processing agreement and its annex, and put them in force';
public function handle(DpaRenderer $renderer): int
{
$version = (string) $this->argument('version');
if (DpaVersion::query()->where('version', $version)->exists()) {
$this->error("Version {$version} already exists. Pick another name — two documents under one name makes every acceptance ambiguous.");
return self::FAILURE;
}
$agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf';
$measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf';
Storage::disk('local')->put($agreementPath, $renderer->agreement($version));
Storage::disk('local')->put($measuresPath, $renderer->measures($version));
$row = DpaVersion::query()->create([
'version' => $version,
'agreement_path' => $agreementPath,
'measures_path' => $measuresPath,
'note' => 'Aus dem Repository erzeugt (clupilot:publish-dpa).',
// Publishing is the point of the command; --draft is for looking at
// it first. Every customer who accepted an earlier version is
// outstanding again from this moment.
'published_at' => $this->option('draft') ? null : now(),
]);
$this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.');
$this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)');
$this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)');
return self::SUCCESS;
}
private function slug(string $version): string
{
return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version';
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Services\Legal;
use App\Console\Commands\PruneDormantAccounts;
use App\Console\Commands\PruneUnverifiedAccounts;
use App\Support\CompanyProfile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\View;
use TCPDF;
/**
* The processing agreement and its annex, as PDFs.
*
* TCPDF, because the invoices already render through it a second PDF engine
* for two documents a year would be a dependency nobody maintains. Its HTML
* support is not a browser's, so the two Blade views are deliberately plain:
* headings, paragraphs, lists, one table.
*
* ## The documents are generated, not uploaded
*
* They name the company from CompanyProfile and take their deadlines from the
* commands that enforce them, so the agreement cannot drift from the invoices or
* from what the software actually does. An operator can still upload a lawyer's
* revision as a new version the console does not care where a file came from.
*/
final class DpaRenderer
{
/**
* The sub-processors this installation actually talks to.
*
* Here rather than in the template because it is a fact about the system,
* not wording: hosting, payment and mail are the three places customer-
* related data leaves this application, and a list that quietly omits one is
* the single most common defect in a processing agreement.
*
* @return array<int, array<string, string>>
*/
public static function subprocessors(): array
{
return [
[
'name' => 'Hetzner Online GmbH',
'service' => 'Rechenzentrum, Server und Netzanbindung der Kundeninstanzen',
'location' => 'Falkenstein und Nürnberg, Deutschland; Helsinki, Finnland',
],
[
'name' => 'Stripe Payments Europe, Ltd.',
'service' => 'Zahlungsabwicklung (Vertrags- und Zahlungsdaten des Kunden, keine Instanzinhalte)',
'location' => 'Irland (EU)',
],
[
'name' => 'thinkidoo e.U.',
'service' => 'Mailserver für den Versand und Empfang der Betriebs- und Support-E-Mails',
'location' => 'Österreich',
],
];
}
/** The agreement itself. */
public function agreement(string $version, ?Carbon $issuedOn = null): string
{
return $this->render('legal.dpa.agreement', $version, $issuedOn, [
'subprocessors' => self::subprocessors(),
]);
}
/** Annex 1: the technical and organisational measures. */
public function measures(string $version, ?Carbon $issuedOn = null): string
{
return $this->render('legal.dpa.measures', $version, $issuedOn, [
// From the commands that enforce them, so the document cannot
// promise one deadline while the sweep runs another.
'unverifiedDays' => PruneUnverifiedAccounts::AFTER_DAYS,
'warnDays' => PruneDormantAccounts::WARN_DAYS_BEFORE,
]);
}
/** @param array<string, mixed> $data */
private function render(string $view, string $version, ?Carbon $issuedOn, array $data): string
{
$issuedOn ??= now();
$html = View::make($view, array_merge($data, [
'version' => $version,
// R19: written on the wall clock, because a date on a contract is
// read by a person and not by a server.
'issuedOn' => $issuedOn->local()->isoFormat('LL'),
]))->render();
$pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8');
$pdf->SetCreator('CluPilot');
$pdf->SetAuthor((string) (CompanyProfile::get('name') ?? 'CluPilot'));
$pdf->SetTitle(__('dpa.title').' '.$version);
// No default header or footer bar: this is a contract, not a report, and
// TCPDF's default header prints a black line and a title nobody set.
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(true);
$pdf->SetMargins(20, 18, 20);
$pdf->SetAutoPageBreak(true, 20);
// DejaVu, because the text is German: TCPDF's core fonts have no ß in
// the encoding this uses and would print it as a blank.
$pdf->SetFont('dejavusans', '', 9.5);
$pdf->AddPage();
$pdf->writeHTML($html, true, false, true, false, '');
return (string) $pdf->Output('', 'S');
}
}

View File

@ -14,4 +14,5 @@ return [
'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.',
'download' => 'Herunterladen',
];

View File

@ -32,4 +32,5 @@ return [
'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.',
'download' => 'Herunterladen',
];

View File

@ -14,4 +14,5 @@ return [
'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.',
'download' => 'Download',
];

View File

@ -32,4 +32,5 @@ return [
'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.',
'download' => 'Download',
];

View File

@ -0,0 +1,204 @@
{{--
Auftragsverarbeitungsvertrag (Art. 28 DSGVO).
Rendered to PDF by App\Services\Legal\DpaRenderer through TCPDF, so the
markup is deliberately plain: headings, paragraphs, lists and simple tables.
TCPDF's HTML support is not a browser's no flexbox, no grid, no custom
properties and anything fancier silently renders as something else.
The company's own details come from CompanyProfile, so this document and the
invoices cannot name the same company differently.
## What this text is and is not
It is a working agreement built from what this installation actually does:
the sub-processors are the ones the software really talks to, the measures
referenced are the ones in the annex, and the deletion deadlines are the ones
the code enforces. It is NOT a lawyer's opinion, and it should be read by one
before it is relied on in a dispute particularly the liability clause and
anything a customer negotiates.
--}}
@php
$c = App\Support\CompanyProfile::all();
$seat = trim(($c['postcode'] ?? '').' '.($c['city'] ?? ''));
// A register number of "FN 000000a" is a placeholder somebody has not
// replaced yet, and printing it on a contract is worse than leaving the line
// out: it looks like a real number and is not one. Anything whose digits are
// all zeros is treated as unfilled.
$real = fn (?string $value) => $value !== null && $value !== '' && preg_match('/[1-9]/', $value) === 1;
@endphp
<h1>Vertrag über die Auftragsverarbeitung</h1>
<p><i>gemäß Art. 28 der Verordnung (EU) 2016/679 (DSGVO)</i></p>
<p><b>Fassung {{ $version }}</b> · Stand {{ $issuedOn }}</p>
<h2>Die Vertragsparteien</h2>
<p><b>Auftragsverarbeiter</b> (im Folgenden „CluPilot"):<br />
{{ $c['name'] ?? 'CluPilot Cloud e.U.' }}<br />
{{ $c['address'] ?? '' }}<br />
{{ $seat }}, {{ $c['country'] ?? 'Österreich' }}<br />
@if ($real($c['register_number'] ?? null)){{ $c['register_number'] }}, {{ $c['register_court'] ?? '' }}<br />@endif
@if ($real($c['vat_id'] ?? null)){{ $c['vat_id'] }}<br />@endif
{{ $c['email'] ?? '' }}</p>
<p><b>Verantwortlicher</b> (im Folgenden „Kunde"):<br />
die im Kundenkonto hinterlegten Stammdaten. Der Kunde hält diese Angaben aktuell; sie
sind Bestandteil dieses Vertrags.</p>
<p>Dieser Vertrag wird elektronisch geschlossen. Art. 28 Abs. 9 DSGVO lässt das
ausdrücklich zu; eine Unterschrift auf Papier ist nicht erforderlich. CluPilot hält
Fassung, Zeitpunkt und IP-Adresse des Abschlusses fest und stellt dem Kunden den
Vertrag im Kundenbereich dauerhaft zum Abruf bereit.</p>
<h2>1. Gegenstand und Dauer</h2>
<p>1.1 CluPilot betreibt für den Kunden eine eigene Nextcloud-Instanz und verarbeitet
dabei personenbezogene Daten ausschließlich im Auftrag und nach Weisung des Kunden.
Der Kunde bleibt Verantwortlicher im Sinne von Art. 4 Z 7 DSGVO.</p>
<p>1.2 Dieser Vertrag gilt für die Dauer des Hauptvertrags (Nutzung eines CluPilot-Pakets)
und endet mit ihm. Regelungen, die ihrer Natur nach fortwirken insbesondere
Verschwiegenheit und Löschung , gelten darüber hinaus.</p>
<h2>2. Art, Zweck und Umfang der Verarbeitung</h2>
<p>2.1 <b>Zweck:</b> Bereitstellung, Betrieb, Wartung und Sicherung einer
Nextcloud-Installation samt zugehöriger Dienste (Speicherung und Freigabe von Dateien,
Kalender, Kontakte, sowie die vom Kunden gebuchten Zusatzmodule).</p>
<p>2.2 <b>Art der Verarbeitung:</b> Speichern, Aufbewahren, Sichern, Übermitteln im
Rahmen des Dienstes, Löschen. Eine inhaltliche Auswertung der Kundendaten findet nicht
statt.</p>
<p>2.3 <b>Kategorien betroffener Personen:</b> Beschäftigte, Kundinnen und Kunden,
Mandantinnen und Mandanten, Patientinnen und Patienten, Lieferanten und sonstige
Kontakte des Kunden je nachdem, wessen Daten der Kunde in seiner Instanz speichert.</p>
<p>2.4 <b>Kategorien personenbezogener Daten:</b> Stamm- und Kontaktdaten,
Kommunikationsdaten, Inhalts- und Dokumentendaten, Nutzungs- und Protokolldaten der
Instanz. Der Kunde entscheidet allein, welche Daten er speichert; besondere Kategorien
nach Art. 9 DSGVO sind möglich, wenn der Kunde solche Daten einbringt.</p>
<h2>3. Weisungen</h2>
<p>3.1 CluPilot verarbeitet die Daten nur auf dokumentierte Weisung des Kunden. Der
Hauptvertrag samt Leistungsbeschreibung ist die vollständige Weisung für den
Regelbetrieb; weitere Weisungen erteilt der Kunde in Textform an
{{ $c['email'] ?? '' }} oder über den Kundenbereich.</p>
<p>3.2 Hält CluPilot eine Weisung für rechtswidrig, teilt sie dies dem Kunden unverzüglich
mit und darf die Ausführung bis zur Bestätigung aussetzen (Art. 28 Abs. 3 letzter Satz
DSGVO).</p>
<p>3.3 Eine Verarbeitung zu eigenen Zwecken findet nicht statt. Davon unberührt bleiben
Daten, die CluPilot als eigener Verantwortlicher verarbeitet insbesondere die
Vertrags- und Rechnungsdaten des Kunden selbst.</p>
<h2>4. Vertraulichkeit</h2>
<p>CluPilot setzt zur Verarbeitung nur Personen ein, die zur Vertraulichkeit verpflichtet
und über die einschlägigen Bestimmungen des Datenschutzes unterwiesen sind (Art. 28
Abs. 3 lit. b DSGVO). Die Verpflichtung wirkt über das Ende der Tätigkeit hinaus.</p>
<h2>5. Technische und organisatorische Maßnahmen</h2>
<p>5.1 CluPilot trifft die Maßnahmen nach Art. 32 DSGVO. Sie sind in der <b>Anlage 1
(TOM)</b> beschrieben, die Bestandteil dieses Vertrags ist.</p>
<p>5.2 Die Maßnahmen unterliegen dem technischen Fortschritt. CluPilot darf sie
weiterentwickeln, solange das Schutzniveau nicht unterschritten wird; wesentliche
Änderungen werden dem Kunden mit einer neuen Fassung dieses Vertrags mitgeteilt.</p>
<h2>6. Unterauftragsverarbeiter</h2>
<p>6.1 Der Kunde erteilt die allgemeine Genehmigung zur Beauftragung der nachstehenden
Unterauftragsverarbeiter (Art. 28 Abs. 4 DSGVO):</p>
<table border="1" cellpadding="4">
<tr>
<td width="30%"><b>Unternehmen</b></td>
<td width="30%"><b>Leistung</b></td>
<td width="40%"><b>Ort der Verarbeitung</b></td>
</tr>
@foreach ($subprocessors as $sub)
<tr>
<td>{{ $sub['name'] }}</td>
<td>{{ $sub['service'] }}</td>
<td>{{ $sub['location'] }}</td>
</tr>
@endforeach
</table>
<p>6.2 CluPilot informiert den Kunden über die Hinzuziehung oder den Austausch eines
Unterauftragsverarbeiters mindestens vier Wochen vorher in Textform. Der Kunde kann
binnen zwei Wochen ab Zugang aus wichtigem, datenschutzbezogenem Grund widersprechen;
in diesem Fall kann jede Seite den Vertrag zum Wirksamwerden der Änderung kündigen.</p>
<p>6.3 CluPilot verpflichtet jeden Unterauftragsverarbeiter auf Pflichten, die diesem
Vertrag entsprechen, und haftet für dessen Verhalten wie für eigenes.</p>
<h2>7. Ort der Verarbeitung, Drittländer</h2>
<p>Die Verarbeitung findet ausschließlich in Mitgliedstaaten der Europäischen Union
statt. Eine Übermittlung in ein Drittland erfolgt nicht; sollte sie künftig
erforderlich werden, geschieht dies nur unter den Voraussetzungen der Art. 44 ff.
DSGVO und nach vorheriger Information des Kunden.</p>
<h2>8. Unterstützung des Kunden</h2>
<p>8.1 CluPilot unterstützt den Kunden mit geeigneten Maßnahmen bei der Erfüllung von
Betroffenenrechten (Art. 12 bis 23 DSGVO). Wendet sich eine betroffene Person direkt an
CluPilot, wird sie an den Kunden verwiesen und der Kunde unverzüglich verständigt.</p>
<p>8.2 CluPilot unterstützt den Kunden bei den Pflichten nach Art. 32 bis 36 DSGVO,
insbesondere bei Datenschutz-Folgenabschätzungen und bei Meldungen an die Aufsichtsbehörde.</p>
<p>8.3 <b>Verletzungen des Schutzes personenbezogener Daten</b> meldet CluPilot dem Kunden
unverzüglich, spätestens innerhalb von 24 Stunden ab Kenntnis, mit den nach Art. 33
Abs. 3 DSGVO erforderlichen Angaben, soweit sie vorliegen. Die Meldung an die
Aufsichtsbehörde obliegt dem Kunden.</p>
<h2>9. Nachweise und Kontrollen</h2>
<p>9.1 CluPilot stellt dem Kunden auf Anfrage die zum Nachweis der Einhaltung dieses
Vertrags erforderlichen Informationen zur Verfügung, insbesondere die jeweils geltende
Fassung der Anlage 1.</p>
<p>9.2 Der Kunde ist berechtigt, sich von der Einhaltung zu überzeugen. Kontrollen finden
nach Anmeldung mit angemessener Frist, während der üblichen Geschäftszeiten und ohne
Störung des Betriebsablaufs statt. CluPilot kann eine Kontrolle durch Vorlage geeigneter
Nachweise ersetzen, soweit dies zur Überprüfung ausreicht. Der Zutritt zu Räumen Dritter
richtet sich nach den Regeln des jeweiligen Unterauftragsverarbeiters.</p>
<h2>10. Löschung und Rückgabe</h2>
<p>10.1 Nach Beendigung des Hauptvertrags stellt CluPilot dem Kunden dessen Daten in
einem gängigen Format zum Abruf bereit. Die Bereitstellung erfolgt zum Ende der bezahlten
Periode; der Kunde hat ab Bereitstellung <b>30 Tage</b> Zeit, sie abzuholen.</p>
<p>10.2 Nach Ablauf dieser Frist löscht CluPilot die Instanz samt aller darin enthaltenen
personenbezogenen Daten sowie die zugehörigen Sicherungen. Die Löschung wird dem Kunden
auf Anfrage bestätigt.</p>
<p>10.3 Von der Löschung ausgenommen sind Daten, für die eine gesetzliche
Aufbewahrungspflicht besteht insbesondere Rechnungen und Buchhaltungsunterlagen
(sieben Jahre, § 132 BAO). Diese Daten verarbeitet CluPilot als eigener Verantwortlicher.</p>
<h2>11. Haftung</h2>
<p>Es gilt Art. 82 DSGVO. Im Übrigen richtet sich die Haftung nach dem Hauptvertrag.</p>
<h2>12. Schlussbestimmungen</h2>
<p>12.1 Änderungen bedürfen der Textform. Widerspricht dieser Vertrag dem Hauptvertrag in
Fragen des Datenschutzes, geht dieser Vertrag vor.</p>
<p>12.2 Ist eine Bestimmung unwirksam, bleibt der übrige Vertrag gültig.</p>
<p>12.3 Es gilt österreichisches Recht. Gerichtsstand ist, soweit gesetzlich zulässig,
{{ $c['city'] ?? 'Wien' }}.</p>
<p><b>Anlage 1:</b> Technische und organisatorische Maßnahmen (TOM), Fassung {{ $version }}.</p>

View File

@ -0,0 +1,148 @@
{{--
Anlage 1: Technische und organisatorische Maßnahmen (Art. 32 DSGVO).
Written from what this installation ACTUALLY does the backup job as
RegisterBackup creates it, the isolation Proxmox gives, the sign-in as
Fortify enforces it, the deletion deadlines the two prune commands run. Every
sentence here is meant to survive somebody checking it against the machine.
Where a measure is planned but not in place, it is not written down. A TOM
that claims more than the system does is worse than a short one: it is the
document an auditor reads before looking.
--}}
@php
$c = App\Support\CompanyProfile::all();
@endphp
<h1>Anlage 1 Technische und organisatorische Maßnahmen</h1>
<p><i>gemäß Art. 32 DSGVO, Anlage zum Auftragsverarbeitungsvertrag</i></p>
<p><b>Fassung {{ $version }}</b> · Stand {{ $issuedOn }} · {{ $c['name'] ?? 'CluPilot Cloud e.U.' }}</p>
<p>Diese Anlage beschreibt die Maßnahmen, die CluPilot beim Betrieb der
Kundeninstanzen tatsächlich umsetzt. Sie wird mit jeder Fassung des
Auftragsverarbeitungsvertrags überprüft und, wo nötig, fortgeschrieben.</p>
<h2>1. Zutrittskontrolle (physisch)</h2>
<p>Die Instanzen laufen ausschließlich in Rechenzentren der in Ziffer 6 des
Hauptvertrags genannten Unterauftragsverarbeiter innerhalb der Europäischen Union.
Physischer Zutritt, Videoüberwachung, Zutrittsprotokollierung, Brand- und
Einbruchschutz sowie unterbrechungsfreie Stromversorgung liegen in deren
Verantwortung und richten sich nach deren jeweils veröffentlichten Nachweisen
(u. a. ISO/IEC 27001). CluPilot selbst betreibt keine eigenen Serverräume.</p>
<h2>2. Zugangskontrolle (Systemzugang)</h2>
<ul>
<li>Jeder Zugang zum Kundenportal und zur Betreiberkonsole erfordert Benutzername
und Passwort. Passwörter werden ausschließlich als Hash gespeichert (bcrypt);
die Klartexte sind CluPilot zu keinem Zeitpunkt bekannt.</li>
<li>Zwei-Faktor-Authentisierung (TOTP) steht sowohl Kundinnen und Kunden als auch
Betreibern zur Verfügung; für Betreiberkonten wird sie eingesetzt.</li>
<li>Sicherheitsrelevante Änderungen (Zwei-Faktor abschalten, Schlüssel speichern)
verlangen zusätzlich eine erneute Passwortbestätigung innerhalb der laufenden
Sitzung.</li>
<li>Angemeldete Geräte werden je Konto geführt und können einzeln abgemeldet
werden; eine Anmeldung von einem neuen Gerät löst eine Benachrichtigung per
E-Mail aus.</li>
<li>Die Betreiberkonsole ist über eine eigene Adresse erreichbar und für das
öffentliche Internet gesperrt; der Zugang erfolgt über ein privates Netz
(WireGuard).</li>
<li>Der administrative Zugriff auf die Virtualisierungsplattform erfolgt über
API-Token je Host, nicht über gemeinsam genutzte Kennwörter.</li>
</ul>
<h2>3. Zugriffskontrolle (Berechtigungen)</h2>
<ul>
<li>Die Betreiberkonsole kennt abgestufte Rollen (Owner, Admin, Support, Billing,
Read-only) mit einzeln vergebenen Berechtigungen; jede Aktion prüft die
Berechtigung serverseitig, nicht durch Ausblenden von Bedienelementen.</li>
<li>Betreiber- und Kundenkonten sind getrennte Identitäten in getrennten Tabellen
mit getrennten Anmeldewegen. Ein Kundenkonto kann keine Betreiberrechte
erhalten.</li>
<li>CluPilot greift nicht auf Inhalte in Kundeninstanzen zu. Ein Zugriff erfolgt
nur, wenn der Kunde im Rahmen einer Supportanfrage darum ersucht, oder wenn
eine gesetzliche Verpflichtung besteht.</li>
<li>Zugangsdaten und Schlüssel (Zahlungsdienst, DNS, Hosts, Mailkonten) werden
verschlüsselt gespeichert; der dafür verwendete Schlüssel ist vom
Anwendungsschlüssel getrennt.</li>
</ul>
<h2>4. Trennungskontrolle (Mandantentrennung)</h2>
<ul>
<li>Jede Kundin und jeder Kunde erhält eine <b>eigene virtuelle Maschine</b> mit
eigenem Betriebssystem, eigener Datenbank und eigenem Dateisystem. Es gibt
keine geteilte Nextcloud-Installation und keine gemeinsame Datenbank für
Kundeninhalte.</li>
<li>Jede Instanz hat eine eigene Adresse und ein eigenes Zertifikat.</li>
<li>Die Verwaltungsdaten von CluPilot (Verträge, Rechnungen, Betriebsdaten) sind
von den Kundeninhalten getrennt und liegen nicht in den Kundeninstanzen.</li>
</ul>
<h2>5. Weitergabekontrolle (Transport und Ablage)</h2>
<ul>
<li>Sämtlicher Verkehr zu Kundeninstanzen, Portal und Konsole läuft über HTTPS mit
Zertifikaten von Let's Encrypt; unverschlüsselte Verbindungen werden nicht
angeboten.</li>
<li>E-Mail wird über TLS an den Mailserver übergeben.</li>
<li>Administrativer Zugriff auf Hosts erfolgt über SSH beziehungsweise über die
API der Virtualisierungsplattform, jeweils verschlüsselt.</li>
<li>Zahlungsdaten werden nicht von CluPilot verarbeitet: Karteneingabe und
Zahlungsabwicklung finden beim Zahlungsdienstleister statt. CluPilot erfährt
nur, dass und in welcher Höhe gezahlt wurde.</li>
</ul>
<h2>6. Eingabekontrolle (Nachvollziehbarkeit)</h2>
<ul>
<li>Jede automatisierte Einrichtung, Änderung und Beendigung einer Instanz wird
Schritt für Schritt protokolliert und bleibt in der Konsole einsehbar.</li>
<li>Ausgehende E-Mails an Kundinnen und Kunden werden mit Zeitpunkt, Empfänger und
Betreff in einem eigenen Register geführt.</li>
<li>Wesentliche Vertragsvorgänge (Bestellung, Kündigung samt Grund, Widerruf,
Abschluss dieses Vertrags) werden mit Zeitpunkt festgehalten; der Abschluss
dieser Vereinbarung zusätzlich mit Fassung und IP-Adresse.</li>
<li>Anwendungs- und Systemprotokolle werden auf den Servern geführt.</li>
</ul>
<h2>7. Verfügbarkeit und Belastbarkeit</h2>
<ul>
<li><b>Sicherung:</b> Für jede Instanz wird bei der Einrichtung ein täglicher
Sicherungslauf um 02:00 Uhr auf der Virtualisierungsplattform eingerichtet
(Snapshot der gesamten Maschine).</li>
<li><b>Überwachung:</b> Erreichbarkeit und Zustand der Instanzen und Hosts werden
laufend überwacht; Störungen erzeugen Meldungen an den Betrieb.</li>
<li><b>Kapazität:</b> Vor jeder Einrichtung wird geprüft, ob auf dem Zielhost
ausreichend Speicher und Rechenleistung frei sind; reicht sie nicht, wird die
Bestellung vorgemerkt statt einen Host zu überbuchen.</li>
<li><b>Aktualisierung:</b> Sicherheits- und Versionsaktualisierungen werden
eingespielt; geplante Wartungen werden angekündigt.</li>
</ul>
<h2>8. Löschung und Aufbewahrung</h2>
<ul>
<li>Nach Vertragsende werden die Daten zum Abruf bereitgestellt und nach Ablauf der
im Hauptvertrag genannten Frist samt Sicherungen gelöscht.</li>
<li>Registrierungen, deren E-Mail-Adresse nie bestätigt wurde, werden nach
{{ $unverifiedDays }} Tagen automatisch gelöscht.</li>
<li>Bestätigte Konten ohne jemals gebuchtes Paket werden nach einem Jahr gelöscht;
die Löschung wird {{ $warnDays }} Tage vorher per E-Mail angekündigt.</li>
<li>Rechnungen und Buchhaltungsunterlagen werden sieben Jahre aufbewahrt
(§ 132 BAO). Sie enthalten Vertrags-, nicht Inhaltsdaten.</li>
</ul>
<h2>9. Auftragskontrolle und Überprüfung</h2>
<ul>
<li>Unterauftragsverarbeiter werden vertraglich auf ein entsprechendes
Schutzniveau verpflichtet; die Liste ist Teil des Hauptvertrags.</li>
<li>Diese Maßnahmen werden bei jeder neuen Fassung des
Auftragsverarbeitungsvertrags überprüft, mindestens jedoch jährlich.</li>
<li>Ansprechstelle für Datenschutzfragen: {{ $c['email'] ?? '' }}</li>
</ul>

View File

@ -129,6 +129,10 @@
{{ __('dpa_admin.file_measures') }}
</a>
@endif
<a href="{{ route('admin.dpa.file', ['uuid' => $v->uuid, 'which' => 'agreement', 'download' => 1]) }}"
class="text-xs font-semibold text-muted underline-offset-4 hover:underline">
{{ __('dpa_admin.download') }}
</a>
</div>
</td>
<td class="px-6 py-3 text-right">

View File

@ -376,6 +376,12 @@
<x-ui.icon name="shield" class="size-4" />{{ __('dpa.read_measures') }}
</x-ui.button>
@endif
{{-- Keep, not just read: the file carries the version in
its name, so "which fassung did I agree to" is
answerable from a downloads folder months later. --}}
<x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm">
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
</x-ui.button>
<span class="font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span>
</div>

View File

@ -71,12 +71,16 @@ Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, strin
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',
]);
// Inline: a contract is read before it is filed. With ?download=1 it is
// kept instead, under a name carrying the version.
$name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf';
return request()->boolean('download')
? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name)
: Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
})->name('dpa.file');
// The answers an operator gives over and over, written once — see

View File

@ -317,10 +317,17 @@ $portal = function () {
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',
]);
// Inline to read, attachment to keep — and the file that lands in
// somebody's downloads folder carries the VERSION in its name, so
// "which one did I agree to" is answerable from the filename alone.
$name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf';
return request()->boolean('download')
? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name)
: Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
})->name('dpa.file');
Route::get('/billing', Billing::class)->name('billing');

View File

@ -21,7 +21,9 @@ use Livewire\Livewire;
* paper. The website already promises "AV-Vertrag inklusive", which means it has
* to be obtainable without asking for it.
*
* The TEXT is never this application's. It is uploaded.
* The TEXT comes from one of two places, and the console does not care which:
* generated from this repository (resources/views/legal/dpa, via
* clupilot:publish-dpa) or uploaded as a lawyer's revision.
*/
function dpaCustomer(): array
{
@ -214,3 +216,74 @@ it('confirms publication in a modal rather than on one click', function () {
// The modal decides nothing: the permission check stays where it was.
->and($modal)->not->toContain('published_at');
});
// ---- The documents this repository generates ----
it('renders both documents as real PDFs, from the company on record', function () {
// Generated rather than uploaded, so the agreement cannot name the company
// differently from the invoices — and a wording change is a diff somebody
// can review.
App\Support\CompanyProfile::put(['name' => 'Prüf GmbH', 'city' => 'Wien', 'country' => 'Österreich']);
$renderer = app(App\Services\Legal\DpaRenderer::class);
$agreement = $renderer->agreement('9.9');
$measures = $renderer->measures('9.9');
expect(substr($agreement, 0, 4))->toBe('%PDF')
->and(substr($measures, 0, 4))->toBe('%PDF')
->and(strlen($agreement))->toBeGreaterThan(20000)
->and(strlen($measures))->toBeGreaterThan(20000);
});
it('names every place customer data actually leaves this application', function () {
// A sub-processor list that quietly omits one is the single most common
// defect in a processing agreement — hosting, payment and mail are the three
// here, and each is named with where it processes.
$names = collect(App\Services\Legal\DpaRenderer::subprocessors())->pluck('name')->implode(' ');
expect($names)->toContain('Hetzner')
->and($names)->toContain('Stripe')
->and(App\Services\Legal\DpaRenderer::subprocessors())->each->toHaveKeys(['name', 'service', 'location']);
});
it('publishes a generated version in one command, and refuses to reuse a name', function () {
Storage::fake('local');
$this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertSuccessful();
$version = DpaVersion::query()->where('version', '7.0')->sole();
expect($version->isPublished())->toBeTrue()
->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue()
->and(Storage::disk('local')->exists($version->measures_path))->toBeTrue();
// Two documents under one name makes every acceptance ambiguous.
$this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertFailed();
});
it('keeps a drafted version out of force', function () {
Storage::fake('local');
$this->artisan('clupilot:publish-dpa', ['version' => '8.0', '--draft' => true])->assertSuccessful();
expect(DpaVersion::query()->where('version', '8.0')->sole()->isPublished())->toBeFalse()
->and(app(ProcessingAgreement::class)->current())->toBeNull();
});
it('hands the file over under a name carrying the version', function () {
// "Which fassung did I agree to" has to be answerable from a downloads
// folder months later, without opening anything.
publishedDpa('2.5');
[$user] = dpaCustomer();
$this->actingAs($user)
->get(route('dpa.file', ['which' => 'agreement', 'download' => 1]))
->assertOk()
->assertDownload('CluPilot-AV-Vertrag-2.5.pdf');
$this->actingAs($user)
->get(route('dpa.file', ['which' => 'measures', 'download' => 1]))
->assertOk()
->assertDownload('CluPilot-TOM-2.5.pdf');
});

View File

@ -148,13 +148,17 @@ it('draws the wordmark from one definition, not five', function () {
$source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname()));
// The wordmark is markup around the name; a plain sentence mentioning
// the company is not. `>Clu` with NO whitespace after the tag catches
// exactly the lockup forms — the name is the whole content of its
// element there. Whitespace used to be allowed, and then the terms page
// arrived with a paragraph whose first word is the company name, which
// is a sentence and not a brand lockup.
if (preg_match('/>Clu(Pilot|<)/', $source)) {
// The wordmark is markup AROUND the name: the name is the whole content
// of its element, so what follows it is the next tag and not more
// sentence. `>Clu<` is the split form, `>CluPilot` up to a tag is the
// whole one.
//
// Twice now a document has tripped a looser version of this — the terms
// page and then the processing agreement, both with a paragraph whose
// first word happens to be the company name. That is a sentence, not a
// brand lockup, and a scan that cannot tell them apart is a scan that
// gets edited every time somebody writes prose.
if (preg_match('/>Clu(<|Pilot\s*<)/', $source)) {
$offenders[] = str_replace(resource_path().'/', '', $file->getPathname());
}
}