Fundamentem izolacji jest FORCE ROW LEVEL SECURITY w PostgreSQL 16. Każda tabela zawierająca dane Dzierżawcy posiada kolumny tenant_id i (opcjonalnie) org_unit_id. Middleware Django ustawia zmienne sesji GUC przed każdym zapytaniem:
-- Middleware ustawia parę GUC przy każdym żądaniu:
SET LOCAL app.current_tenant_id = '<uuid>';
SET LOCAL app.current_org_unit_id = '<uuid>,<uuid>'; -- tylko użytkownik ograniczony do placówek
SET LOCAL ROLE app; -- rola bez uprawnień superuser
-- Przykładowa polityka RLS:
CREATE POLICY tenant_isolation ON audit_log
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;
Izolacja placówek w obrębie Dzierżawcy. Każda placówka ma własną jednostkę organizacyjną, nadawaną przez bazę danych. RLS egzekwuje org_unit_id na sześciu tabelach — placówki, wyjątki, przypisania zadań kontrolnych, Zadania Kontrolne NIS2, samoidentyfikacja i incydenty — a jednostkę wiersza ustawia wyzwalacz bazy danych na podstawie jego placówki, nie aplikacja. app.current_org_unit_id jest listą jednostek i jest ustawiana dla użytkownika, którego wszystkie role są ograniczone do placówek (np. dyrektor lub ITSO szkoły w Dzierżawcy JST, gdzie te role nadaje się wyłącznie per placówka); wiersze dotyczące całej organizacji, bez placówki, są widoczne w każdej jednostce. Główną kontrolą jest kod aplikacji, który ogranicza odczyty i zapisy do placówek, w których użytkownik ma uprawnienie; baza danych jest zabezpieczeniem pod nim. Ryzyka, aktywa i stany zgodności kontroli są ograniczane per placówka wyłącznie w kodzie aplikacji. Szkoła założona jako osobny Dzierżawca jest odizolowana od pozostałych przez RLS na tenant_id.
Rola app ma uprawnienia SELECT/INSERT/UPDATE/DELETE na większości tabel biznesowych — każde z nich ograniczone przez FORCE ROW LEVEL SECURITY. Nie jednolicie: od 2026-09-29 app nie może usunąć rekordu samego Dzierżawcy i może aktualizować tylko wskazane jego kolumny, a migracja odbierająca takie uprawnienie przerywa wdrożenie, jeśli uprawnienie przetrwa. Rola audit_writer ma INSERT oraz SELECT na dzienniku audytu: SELECT jest konieczny, bo wyzwalacz łańcucha musi odczytać bieżącą głowę. Brak UPDATE i DELETE utrzymuje charakter append-only. Migracje wykonuje superużytkownik bootstrap klastra (właściciel tabel) — nigdy w trakcie obsługi żądania; osobna, dedykowana rola DDL jest śledzona jako zadanie hardeningowe.
Każdy wpis dziennika audytu jest powiązany z poprzednim przez kryptograficzny łańcuch skrótów SHA-256:
row_hash_n = sha256( prev_hash_(n-1) || canonical(row_n) )
Gdzie canonical = dwanaście pól składowych, zserializowanych przez Postgres do tekstu i połączonych znakiem \x1f (ASCII unit separator), w kolejności: tenant_id, actor_user_id, actor_ip, actor_user_agent, action, target_type, target_id, before, after, request_id, at, contract_version. timestamptz::text renderowany jest przy TimeZone = 'UTC' i DateStyle = 'ISO, MDY' (ustawionych na stałe w wyzwalaczu), a połączony tekst haszowany jest jako bajty UTF-8. Aby odtworzyć łańcuch zapytaniem SQL lub z danych wyeksportowanych z Postgresa, ustaw wcześniej te same TimeZone, DateStyle oraz client_encoding = 'UTF8'. Wartości NULL serializują się do pustego łańcucha znaków. Wersją listy pól jest contract_version, przechowywana w każdym wierszu i wchodząca do postaci kanonicznej jako jej dwunaste pole.
Skrót wylicza wyzwalacz Postgresa (SECURITY DEFINER), a nie aplikacja, i przechowuje go jako surowe bajty (bytea), nie jako tekst szesnastkowy. Django nie może podać prev_hash, row_hash ani chain_pos — wyzwalacz nadpisuje je po stronie serwera. Łańcuch jest wiązany i przechodzony po chain_pos, nigdy po at: wiersz z datą wsteczną nadal dopisuje się na końcu.
# Odtworzenie skrótu wiersza z danych wyeksportowanych z Postgresa.
import hashlib
SEP = "\x1f" # ASCII unit separator
def compute_row_hash(prev_hash: bytes, fields: list[str]) -> bytes:
"""fields = dwanaście pól w kolejności z listy powyżej."""
canonical = SEP.join(fields).encode("utf-8")
return hashlib.sha256(prev_hash + canonical).digest()
# prev_hash pierwszego wiersza w łańcuchu to 32 bajty zerowe: bytes(32).
# Weryfikacja całego łańcucha:
# GET /api/audit/verify/ --> 200 {"ok": true, "total_rows": N}
# lub 422 {"ok": false, "total_rows": N,
# "broken_at": "<uuid>", "reason": "..."}
# To jest całe ciało odpowiedzi. Pole chain_length należy do innego
# endpointu -- GET /api/audit/public/ -- i dotyczy łańcucha płaszczyzny
# sterującej, nie łańcucha Dzierżawcy.
Rola audit_writer ma INSERT oraz SELECT (wyzwalacz łańcucha odczytuje bieżącą głowę) — brak UPDATE ani DELETE. FORCE ROW LEVEL SECURITY obowiązuje również na dzienniku. Przerwanie łańcucha (np. przywracanie po awarii) tworzy „kotwicę nieciągłości” z dokumentacją zdarzenia — jest to celowa funkcja, nie błąd.
Hasła są haszowane algorytmem Argon2id. Używamy własnej podklasy hashera Django (apps.iam.hashers.ExceptaoArgon2PasswordHasher, pierwsza pozycja listy PASSWORD_HASHERS) — parametry, z którymi liczony jest każdy nowy skrót:
Nigdy nie przechowujemy hasła w postaci jawnej ani zaszyfrowanej odwracalnie — w bazie jest wyłącznie jednokierunkowy skrót. Chronią go trzy mechanizmy, wszystkie sprawdzalne w kodzie:
password. Serializator własnego konta ma zamkniętą listę pól, na której tego pola nie ma; jedyną informacją o haśle, jaką API zwraca, jest logiczna flaga has_usable_password.app nie omija RLS. Rola aplikacji jest tworzona jako NOSUPERUSER NOBYPASSRLS, a tabele mają FORCE ROW LEVEL SECURITY. Przejęta sesja aplikacji sięga więc wyłącznie po wiersze własnego Dzierżawcy, a nie po całą tabelę użytkowników.platform_writer) ma na tabeli użytkowników wyłącznie SELECT — brak UPDATE oznacza, że nie da się z niej ustawić cudzego hasła.Do 2026-09-19 w tym miejscu stało zdanie, że skrót jest przechowywany „w kolumnie z ograniczeniami RLS — niedostępny dla roli app przez SELECT”. To nie było prawdą i nie mogło nią być: app jest rolą, która uwierzytelnia użytkownika, więc musi odczytać skrót; w bazie nie ma żadnych uprawnień ani polityk na poziomie kolumny. Zdanie zostało wycofane i zastąpione listą powyżej.
MFA jest obowiązkowe dla każdego konta z hasłem lokalnym — platforma odrzuca żądania z takiego konta do czasu rejestracji drugiego składnika. Konta logujące się przez dostawcę tożsamości Dzierżawcy nie mają u nas hasła i podlegają temu dostawcy; publiczne demo jest wyłączone (opublikowane poświadczenia, brak danych klientów, nocny reset). Dostępne metody:
Vault obsługuje envelope encryption per Dzierżawca od 2026-10-10 (P0-1, wydanie R2) dla poświadczeń Dzierżawców; od 2026-10-11 (wydanie R3b) jest to ich jedyna ochrona — zob. sekcję 14:
platform-iam), wspólnego dla wszystkich Dzierżawców.platform-iam — zob. sekcję 14.platform-iam) można rotować online (rotacja, potem ponowne opakowanie), bez zmiany zapisanych sekretów. Rotacja DEK per Dzierżawca na żądanie nie istnieje — DEK per Dzierżawca są używane od 2026-10-10 (P0-1), ale rotacja na żądanie nie została zbudowana (zob. sekcję 14).Cała komunikacja sieciowa z klientem odbywa się przez TLS terminowany przez Cloudflare. Minimalna wersja TLS jest ustawieniem strefy w Cloudflare, przechowywanym w konfiguracji panelu, a nie w repozytorium; aktualne ustawienie udostępniamy klientom na żądanie. VPS nie ma otwartych portów na zewnątrz — cały ruch HTTP/HTTPS trafia przez Cloudflare Tunnel (mTLS wewnętrznie). Nagłówki HSTS na odpowiedziach API: Strict-Transport-Security: max-age=31536000; includeSubDomains. Domena nie jest zgłoszona na listę preload przeglądarek, a strony statyczne na Cloudflare Pages nie niosą tego nagłówka.
| Kopia | Lokalizacja | Szyfrowanie | Częstotliwość |
|---|---|---|---|
| Podstawowa | Cloudflare R2 (EU) | Kopertowo: AES-256-GCM na archiwum, klucz opakowany RSA-OAEP (RSA-4096); klucz prywatny nieeksportowalny w Microsoft Azure Key Vault | Codzienna |
| Drugorzędna | Backblaze B2 (Amsterdam, EU) | Kopertowo: AES-256-GCM na archiwum, klucz opakowany RSA-OAEP (RSA-4096); klucz prywatny nieeksportowalny w Microsoft Azure Key Vault | Codzienna |
Testy przywracania — są dwa i nie są równoważne:
ops.pitr.drill.success albo ops.pitr.drill.failed. Nie przygotowuje VM, nie przywraca Vault, nie uruchamia stosu aplikacji i nie wykonuje logowania. Pierwsze zaplanowane uruchomienie 2026-10-01: powodzenie dla Exceptao, niepowodzenie dla paraKSCol na znanym przerwaniu łańcucha zarchiwizowanego dzierżawcy demonstracyjnego; ponowione ręcznie 2026-10-03, po zadeklarowaniu i przypięciu tego przerwania, zakończyło się powodzeniem dla obu (czas przywracania 79 s i 110 s).ops.backup.drill.success albo ops.backup.drill.failed. Nie może zostać zautomatyzowany zgodnie z projektem: tylko podmiot usługowy testu może odpakować klucz koperty, a jego poświadczenie celowo nie znajduje się na maszynie.Do 2026-09-19 w tym miejscu stało zdanie, że „testy przywracania są wykonywane automatycznie co 90 dni” i rejestrowane pod kodem backup.restore_drill.complete. Kwartalny harmonogram istnieje (pierwsze zaplanowane uruchomienie 2026-10-01, wynik powyżej); test zrzutu logicznego nie ma harmonogramu w ogóle; a kod backup.restore_drill.complete nie występuje nigdzie w systemie.
Dostęp do infrastruktury produkcyjnej jest ograniczony do minimum niezbędnych osób. Każde połączenie z VPS odbywa się przez Cloudflare Access (SSO + MFA), a SSH przez Cloudflare Tunnel — nie przez otwarty port 22.
| Rola PostgreSQL | Uprawnienia | Używana przez |
|---|---|---|
app | SELECT, INSERT, UPDATE, DELETE na większości tabel biznesowych (z FORCE RLS); bez DELETE na rekordzie Dzierżawcy | Django runtime (każde żądanie HTTP) |
audit_writer | INSERT i SELECT na audit_log; brak UPDATE i DELETE | Runtime Django, synchroniczna ścieżka żądania (apps/audit/services.py) |
superadmin | BYPASSRLS NOSUPERUSER NOLOGIN; SELECT na wyselekcjonowanej liście tabel, bez INSERT, UPDATE, DELETE i TRUNCATE — wyłącznie odczyty | Odczyty międzydzierżawcze konsoli operatora i endpoint weryfikacji łańcucha |
platform_writer | BYPASSRLS NOSUPERUSER NOLOGIN; INSERT/UPDATE na wyselekcjonowanej liście tabel; żadnych uprawnień do audit_log | Zapisy konsoli operatora |
iam_definer | BYPASSRLS NOSUPERUSER NOINHERIT NOLOGIN; właściciel dwóch funkcji wyszukiwania między Dzierżawcami (SECURITY DEFINER); SELECT na tenant_membership oraz SELECT/UPDATE na poziomie kolumn (trzy/jedna kolumna) w iam_password_reset_token | Nigdy nie jest przyjmowana przez sesję: osiągalna wyłącznie jako właściciel tych dwóch funkcji |
app_priv | Rola logowania, bez uprawnień superużytkownika, NOBYPASSRLS; dziedziczy app i może przechodzić (wyłącznie SET ROLE) do superadmin, platform_writer i audit_writer; bez własnych uprawnień poza tymi | Worker zadań w tle i konsola operatora — nigdy backend obsługujący żądania |
app_ro | NOLOGIN NOSUPERUSER NOBYPASSRLS; wyłącznie SELECT i tylko na tabelach, które czyta sama rola app; nie należy do żadnej roli; podlega tym samym politykom wierszowym, w tym FORCE | Odczyty tylko do odczytu w układzie federacji (nadzorca JST); osiągana wyłącznie przez SET ROLE z app |
| superużytkownik bootstrap (właściciel) | Superużytkownik klastra; DDL | Migracje Django — tylko w oknie deploy; nigdy w ścieżce żądania |
Platforma obsługuje federację tożsamości per Dzierżawca:
Konfiguracja IdP jest per Dzierżawca — jeden Dzierżawca nie może wpłynąć na konfigurację innego.
VPS jest hostowany w centrum danych OVHcloud (Warszawa, Polska) posiadającym certyfikat ISO/IEC 27001. Dane są przechowywane w UE; produkcja działa w Polsce, a kwartalny test odtworzenia odbywa się w Austrii (UE) — zob. listę podwykonawców. Operator nie posiada własnej infrastruktury fizycznej. Fizyczny dostęp do serwerów jest wyłączną kompetencją OVHcloud.
Architektura bezpieczeństwa platformy jest zaprojektowana tak, aby same środki techniczne spełniały wymagania art. 21 §2 NIS2 (szyfrowanie, MFA, kontrola dostępu, ciągłość działania, zarządzanie incydentami, bezpieczeństwo łańcucha dostaw). Szczegółowe mapowanie: Nota zgodności z NIS2/KSC.
Uczciwa lista ograniczeń (stan na 2026-05-15):
| Obszar | Status | Plan |
|---|---|---|
| Certyfikacja ISO 27001 platformy | Brak — architektura jest zgodna z wymaganiami | Ocena po 12 miesiącach produkcji |
| Niezależny audyt penetracyjny | Brak (planowany Q4 2026) | Wyniki zostaną opublikowane (streszczenie) |
| SOC 2 Type II | Brak — roadmap po skalowaniu | 2027+ |
| SIEM / SOC dla infrastruktury Dzierżawcy | Poza zakresem platformy | Integracja przez API (planowana) |
| Redundancja multi-region | Single-VPS; backup w 2 lokalizacjach | Drugi region UE przy większej skali |
Stan na 2026-10-11. Klucze szyfrowania danych (DEK) przypisane do poszczególnych Dzierżawców są używane w obu wdrożeniach produkcyjnych od 2026-10-10 (wydanie R2 w ramach P0-1): każdy aktywny Dzierżawca ma własny klucz, opakowany kluczem przypisanym mu w silniku tranzytowym Vault, a poświadczenie Dzierżawcy zapisane od tego dnia jest szyfrowane tym kluczem. W chwili włączenia kluczy żaden Dzierżawca nie miał zapisanego poświadczenia integracji ani logowania, więc nie było czego migrować. Od 2026-10-11 (wydanie R3b, oba wdrożenia produkcyjne) klucz Dzierżawcy jest jedyną ochroną jego poświadczeń: kopie pod kluczem ogólnoplatformowym, zachowywane do tego dnia, zostały usunięte, a klucz ogólnoplatformowy, który je szyfrował, nie jest już ładowany przez aplikację ani przechowywany w bieżących sekretach Vault. Kryptograficzne usuwanie („crypto-shredding”) poświadczeń Dzierżawców obowiązuje zatem od 2026-10-11, w granicach opisanych poniżej. Sekrety TOTP nie są objęte kluczami Dzierżawców (ograniczenie 5).
Dlaczego nie czekaliśmy 30 dni. Projekt przewidywał, że harmonogram zacznie obowiązywać 30 dni (okres retencji kopii zapasowych) po wydaniu R3, ponieważ kopia zapasowa wykonana przed R3 mogłaby zawierać kopię poświadczenia pod kluczem ogólnoplatformowym. Tu żadna jej nie zawiera, bo takiej kopii nigdy nie utworzono: w chwili włączenia kluczy każda kolumna poświadczeń była pusta, a testy odtworzenia z 2026-10-11, wykonane na ostatnich kopiach zapasowych sprzed R3b, również wykazały puste wszystkie kolumny poświadczeń. Operator uznał więc 2026-10-11 to oczekiwanie za zbędne. Gdyby takie kopie istniały, oczekiwanie obowiązywałoby.
Harmonogram usunięcia — obowiązuje od 2026-10-11. Kryptograficzne usunięcie może zostać zlecone wyłącznie dla zarchiwizowanego Dzierżawcy. Zlecenie jest wstrzymywane na 30-dniowy okres karencji, w którym można je anulować; po jego upływie klucz Dzierżawcy jest niszczony. Zaszyfrowane migawki Vault wykonane przed tą chwilą są przechowywane do 30 dni (VAULT_SNAPSHOT_RETENTION_DAYS, domyślnie 30 — to samo okno co dla kopii zapasowych bazy danych), a odtworzenie migawki w tym oknie przywróciłoby klucz. Usunięcie staje się więc po naszej stronie nieodwracalne około 60 dni od potwierdzonego zlecenia: 30 dni karencji, a następnie do 30 dni przechowywania zaszyfrowanych kopii zapasowych i migawek Vault. Obejmuje zapisane poświadczenia Dzierżawcy (sekrety logowania, poczty i integracji); nie obejmuje sekretów TOTP ani łańcucha audytowego (ograniczenia 2 i 5).
Dziesięć ograniczeń, które publikujemy, aby nikt nie przypisywał kryptograficznemu usuwaniu więcej, niż ono zapewnia:
/api/audit/verify nadal go weryfikuje. Zapis o usunięciu przetrwa same dane. Usunięcie danych pseudonimizuje rekord użytkownika, do którego wiersze audytu odwołują się wewnętrznym identyfikatorem; same wiersze audytu nigdy nie są przepisywane. Wiersze dziennika audytu zapisane przed wdrożeniem produkcyjnym z 2026-10-12 mogą ponadto zawierać w samym wierszu adresy e-mail: przy czynnościach (logowanie, blokada konta i reset hasła; zakładanie kont przez SSO; nadawanie uprawnień administratora platformy; zakładanie, zawieszanie i przywracanie Dzierżawców; dostęp wsparcia Operatora i wyszukiwanie w dzienniku przez Operatora; deklaracje nieciągłości łańcucha i raporty z ćwiczeń odtwarzania; delegowanie zadań przez JST; powiadomienia i przypomnienia; ocena dostawców; profil organizacji; podłączenie poczty Microsoft 365; kontrola ścieżki e-mail) — użytkownika wykonującego lub objętego czynnością, odbiorcy powiadomienia lub osoby kontaktowej Dzierżawcy albo identyfikator wpisany w formularzu logowania lub resetu hasła — a przy dowolnej czynności w tekście swobodnym, np. w komunikacie błędu lub w tekście wpisanym przez użytkownika. Pozostają one przez cały okres istnienia konta Dzierżawcy (w łańcuchu warstwy sterującej platformy — przez cały okres istnienia platformy), na tej samej podstawie prawnej co adres IP aktora: uzasadniony interes w zakresie integralności audytu (art. 6 ust. 1 lit. f RODO). Od wdrożenia produkcyjnego z 2026-10-12 w treści ani w polu obiektu docelowego wiersza audytu nie jest zapisywany żaden tekst w postaci adresu e-mail: użytkownicy są identyfikowani wewnętrznym identyfikatorem, a każdy inny adres — także w tekście swobodnym — skrótem kluczowanym (HMAC), z którego adresu nie da się odczytać. Do poprawki z 2026-10-13 obowiązywał jeden wyjątek: gdy adresy lub domeny były połączone znakiem @ bez niczego pomiędzy (x@a.com@b.org), w wierszu zostawał skrót, po nim @ i nazwa domeny — nigdy cały adres. Wiersze zapisane od wdrożenia produkcyjnego z 2026-10-12 do wdrożenia tej poprawki mogą zawierać taki fragment; są przechowywane na tej samej podstawie i ujawniane, a nie usuwane. Drugą lukę usunęła poprawka z 2026-10-14: adres zawierający łączące znaki diakrytyczne — literę z akcentem zapisaną jako litera podstawowa i osobny znak (Unicode NFD, powstający w niektórych systemach i przy kopiowaniu z niektórych dokumentów) albo pismo, w którym samogłoski zapisuje się takimi znakami, np. dewanagari lub tajskie, albo niewidoczny łącznik (ZWNJ/ZWJ) używany w nazwach perskich i indyjskich — nie był rozpoznawany i był zapisywany tak, jak go wpisano, w całości lub w części. Wiersze zapisane przed wdrożeniem tej poprawki mogą zawierać taki adres jawnym tekstem; są przechowywane na tej samej podstawie i ujawniane, a nie usuwane. Taki skrót jest pseudonimem i pozostaje daną osobową: każdy, kto może spowodować zapisanie przez platformę wybranego adresu, albo kto posiada klucz, może potwierdzić odgadnięty adres. Inne dane osobowe wpisane w pola tekstowe, np. imię i nazwisko lub numer telefonu, są zapisywane tak, jak je wpisano. Nie obejmuje to ciągu user-agent sesji wykonującej czynność: jest on zapisywany tak, jak go przesłał klient, a niektórzy klienci, zwłaszcza roboty indeksujące, umieszczają w nim adres kontaktowy.platform-iam (sekcja 5), więc crypto-shred Dzierżawcy ich nie obejmuje; usunięcie użytkownika usuwa jego sekret.IAM_OIDC_KEY), oraz dawny klucz sekretów TOTP (IAM_TOTP_KEY) zniknęły z aplikacji i z bieżących sekretów Vault w obu wdrożeniach (2026-10-11). Tego samego dnia wszystkie starsze wersje magazynu klucz-wartość Vault, które nadal przechowywały którykolwiek z nich, zostały trwale zniszczone, a Operator usunął swoje kopie depozytowe offline obu kluczy. Jedyne kopie, które można przywrócić, znajdują się w zaszyfrowanych nocnych migawkach Vault wykonanych wcześniej i przechowywanych przez najwyżej 30 dni, więc ostatnia z nich wygaśnie około 2026-11-10. Żaden szyfrogram pod kluczem IAM_OIDC_KEY nie istnieje, więc jego kopie niczego nie odszyfrowują. Kopie zapasowe bazy danych wykonane przed migracją klucza TOTP (P0-1, faza 6, 2026-10-11) zawierają sekrety TOTP zaszyfrowane kluczem IAM_TOTP_KEY; odtworzenie migawki Vault wykonanej przed usunięciem klucza pozwoliłoby je ponownie odczytać, dopóki same te kopie zapasowe nie wygasną po 30 dniach.