Offboarding po pięciu dniach: jak nie zostawić osieroconych kont i dostępów
Krótki staż nie zwalnia z pełnego cyklu odbierania dostępów — pokazujemy checklistę i automatyzację, która zamyka konta także po kilku dniach pracy.

Pięć dni w roli i odejście — taki nagłówek pojawił się w ostatnich dniach w serwisach informacyjnych (BBC). Komentarz polityczny do tej historii zostawiamy innym. Dla nas to przede wszystkim gotowy przypadek testowy: człowiek, który dostał dostępy do systemów na kilka dni, a nie na kilka lat.
Offboarding prawie nigdy nie zawodzi na etatowym pracowniku z ośmioletnim stażem — jego odejście jest zaplanowane, ktoś przygotowuje tickety, zespół wie, że trzeba odebrać laptopa. Zawodzi na stażyście, wykonawcy z trzymiesięczną umową, konsultancie na dwa tygodnie i na osobie, która w piątek zaczęła, a w środę już jej nie ma. Wszyscy oni przechodzą przez ten sam formularz provisioningowy, ale ich data końca bywa zapisana tylko w czyjejś głowie.
Ten tekst jest o tym, jak zrobić z offboardingu właściwość systemu, a nie zadanie do zapamiętania. O wykrywaniu kont, które zostały po ludziach, i o tym, jak nie zgubić tokena, którego nikt nigdy nie widział w żadnym tickecie.
„Przecież był tylko chwilę” — najgorsze zdanie w audycie
Argument brzmi rozsądnie, dopóki nie spojrzysz na listę dostępów. Konto w IdP zostało wyłączone, ale sesje i refresh tokeny żyją dalej. Token API ma datę wygaśnięcia za 90 dni, bo domyślnie nadawaliśmy takie. Klucz SSH wciąż leży w authorized_keys na dwóch hostach, bo nikt nie skojarzył go z człowiekiem. Do tego konto lokalne w panelu administracyjnym, deploy key w repozytorium i hasło do bazy wpisane do .env w projekcie, który „zaraz przejmiemy”.
Problem nie leży w złej woli zespołu. Leży w asymetrii kosztów: nadanie dostępu to jedno kliknięcie w self-service, odebranie to siedem decyzji rozrzuconych po różnych systemach. Kiedy brakuje właściciela procesu, wygrywa domyślne zachowanie — nic się nie dzieje. Konto zostaje, bo usunięcie wymaga odwagi, a dezaktywacja nie. Zostaje też dlatego, że nikt nie wie, kto miałby je usunąć.
W audycie „był tylko chwilę” nie jest okolicznością łagodzącą. Jest pytaniem: jak to możliwe, że osoba z pięciodniowym stażem zostawiła po sobie ślad, którego nie umiemy wskazać w raporcie?
Inwentaryzacja śladów: czego szukać po człowieku
Zanim cokolwiek zautomatyzujesz, musisz wiedzieć, co ma zostać odebrane. Praktyczna lista miejsc, w których zostają dostępy:
- IdP i tożsamość — konto, aktywne sesje, refresh tokeny, enrolment MFA, członkostwa w grupach, zgody OAuth udzielone aplikacjom zewnętrznym.
- Konta lokalne — bazy danych, panele administracyjne, legacy appki bez SSO, VPN z certyfikatem.
- Konta techniczne — service accounty „przypisane do człowieka”, boty, konta deployowe, które przeżywają właściciela.
- Sekrety — personal access tokeny, deploy keys, klucze SSH (GitHub, serwery, CI), certyfikaty, klucze dostępowe do chmury.
- Chmura i IAM — użytkownicy IAM, założenia ról, service principals, zaufania federacyjne.
- Repozytoria — collaboratorzy, członkostwa w zespołach, ownership pakietów, tokeny do rejestru obrazów.
- SaaS — seaty, integracje, webhooki skonfigurowane na prywatne endpointy.
- Infrastruktura — delegacje DNS, dostępy do panelu płatności, do rejestratora, do monitoringu.
- Dane — skrzynka mailowa, aliasy, forwarding, kalendarz, dokumenty w chmurze. To najczęstsze źródło osieroconych zasobów, bo nikt nie myśli o tym jak o „dostępie”.
Ta lista nie musi być od razu kompletna. Ważne, żeby była wersjonowana w repozytorium razem z kodem, który ją egzekwuje — inaczej rozjedzie się z rzeczywistością w ciągu kwartału.
Maszyna stanów: dostęp z datą ważności od pierwszego dnia
Kluczowa zmiana to przeniesienie momentu decyzji. Zamiast pytać „kiedy odejdzie i kto wtedy wyłączy konto”, pytamy przy nadawaniu: do kiedy ten dostęp ma żyć. Data końca umowy jest znana w dniu jej podpisania — także dla pięciodniowych epizodów.
Minimalny model danych wygląda tak:
CREATE TABLE access_grants (
id bigserial PRIMARY KEY,
subject_id text NOT NULL, -- identyfikator osoby z HR
system text NOT NULL, -- 'github', 'aws', 'vpn'
principal text NOT NULL, -- login, ARN, fingerprint klucza
owner text NOT NULL, -- kto odpowiada za dostęp
granted_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
state text NOT NULL DEFAULT 'active'
CHECK (state IN ('active', 'revoking', 'revoked')),
revoked_at timestamptz
);
CREATE UNIQUE INDEX access_grants_active
ON access_grants (system, principal)
WHERE state = 'active';
expires_at nie jest wpisywane ręcznie — jest wyliczane z kontraktu, z twardym limitem, żeby nikt nie dostał dostępu „na zawsze” przez literówkę. Dopiero potem uruchamiasz nocny reconciler, który domyka wygasłe granty:
def revoke(conn, grant_id):
with conn.transaction():
row = conn.execute("""
UPDATE access_grants
SET state = 'revoking'
WHERE id = %s AND state = 'active'
RETURNING system, principal
""", (grant_id,)).fetchone()
if row is None:
return # ktoś już to robi — operacja jest idempotentna
driver_for(row["system"]).revoke(row["principal"])
conn.execute("""
UPDATE access_grants
SET state = 'revoked', revoked_at = now()
WHERE id = %s
""", (grant_id,))
Trzy detale, które decydują o tym, czy to działa w praktyce. Po pierwsze, revoke() w każdym driverze musi być idempotentne — wywołane dwa razy nie może się wysypać. Po drugie, błąd wywołania nie może zniknąć: trafia do kolejki retry z alertem, bo cichy wyjątek w nocy to dokładnie ten scenariusz, którego się boisz. Po trzecie, konta w IdP nie usuwamy w dniu odejścia — przechodzą w stan disabled z unieważnieniem sesji. Twarde usunięcie po okresie retencji chroni ślad audytowy i pozwala wrócić kontraktorowi bez kolizji loginów.
Checklista: co wyłączyć od razu, a co wygasić po karencji
- 0–1 h: unieważnienie sesji i refresh tokenów,
disabledw IdP, unieważnienie PAT-ów i kluczy SSH, odebranie tokenów MFA i sprzętu, rotacja haseł współdzielonych, odebranie dostępu do VPN. - do 24 h: dostępy w repozytoriach, klucze dostępowe w chmurze i zaufania ról, seaty w SaaS, listy mailingowe, aliasy, delegacje skrzynki, przekazanie on-call, dostępy do DNS i paneli.
- karencja 30 dni: skrzynka z auto-odpowiedzią i delegacją do przełożonego (z wyłączonym forwardingiem na zewnątrz), katalog domowy, dane w bucketach, przekazanie własności dokumentów.
- purge po retencji: twarde usunięcie konta i wpis w logu audytowym.
Przekazanie własności to element offboardingu, nie zadanie „do sprawdzenia później”. Repozytorium bez ownera, domena bez opiekuna i skrzynka bez delegata to ten sam problem co aktywne konto.
Raport krzyżowy: jak znaleźć osierocone konta
Najprostsze narzędzie, jakie znam, to różnica dwóch zbiorów. Ludzie aktywni w HR kontra principaly aktywne w systemie. Różnica w obie strony jest informacją.
# aktywne osoby wg HR
curl -s "$HR_API/employees?status=active" | jq -r '.[].login' | sort > /tmp/humans.txt
# aktywne konta w IdP
curl -s "$IDP_API/users?status=active" -H "Authorization: Bearer $TOKEN" \
| jq -r '.users[].userName' | sort > /tmp/idp.txt
# ma konto, nie ma umowy
comm -13 /tmp/humans.txt /tmp/idp.txt
# ma umowę, nie ma konta (blokada onboardingu)
comm -23 /tmp/humans.txt /tmp/idp.txt
comm wymaga posortowanych plików, dlatego sort nie jest tu ozdobnikiem. Drugi wynik też ma wartość — pokazuje, że ktoś pracuje bez konta, czyli że provisioning poszedł bokiem.
Do tego dwie kolumny, bez których raport jest bezużyteczny: owner i last_activity. Principal bez właściciela to kandydat na sierotę niezależnie od tego, czy wygląda jak człowiek. Dlatego konta techniczne powinny mieć deklarowanego ownera w pliku w repozytorium, a CI powinien failować, gdy pojawia się nowe konto bez wpisu. Wtedy lista własności nie starzeje się dyskretnie.
Warto mierzyć trzy liczby i pokazywać je co miesiąc: odsetek aktywnych grantów z ustawionym expires_at, liczbę kandydatów na sieroty oraz czas od daty końca umowy do stanu revoked (p50 i p95). Pierwsza liczba mówi o projekcie systemu, trzecia o tym, czy komukolwiek zależy.
Test na sucho, zanim zabraknie czasu
Procedury nie sprawdza się w dniu odejścia — sprawdza się ją wtedy, gdy nikt się nie spieszy. Ćwiczenie jest tanie: wybierz ochotnika, załóż mu konto „fake leaver” w środowisku staging (albo w osobnym tenancie), nadaj kilka dostępów i odpal proces z datą końca ustawioną na wczoraj. Zmierz, ile czasu zajęło dojście do revoked i co wymagało ręcznej decyzji. Każda taka decyzja to miejsce, które w piątek o 22:00 zamieni się w telefon do jednej osoby.
Osobno przetestuj awarie. Co się dzieje, gdy HR API nie odpowiada? Sensowna odpowiedź to fail-closed dla nowych nadań i kolejka retry z alertem dla odbiorów — nigdy „przepuść, sprawdzimy później”. Sprawdź też, czy raport krzyżowy faktycznie widzi konto bez ownera, i czy potrafisz wskazać, kto ma dostęp do produkcyjnej bazy sprzed dwóch lat.
Runbook trzymaj w repozytorium obok kodu, który go wykonuje. Dokument w narzędziu bez wersjonowania rozjedzie się z rzeczywistością po pierwszej zmianie w pipeline’ie, a w krytycznym momencie nikt nie będzie miał czasu go szukać. Powtarzaj próbę co kwartał — nie po to, żeby mieć certyfikat, ale dlatego, że ludzie w zespole się zmieniają, a wraz z nimi wiedza o tym, gdzie co leży.
Wnioski
Pięciodniowy epizod nie jest przypadkiem brzegowym. Krótkie staże, kontrakty, praktykanci i zewnętrzni wykonawcy to stała część rzeczywistości, a nie wyjątek od reguły. System, który zakłada, że wszyscy zostają na lata, będzie działał tylko dla tych, którzy zostają na lata.
Reguła projektowa jest prosta: każdy dostęp ma ownera i datę ważności, a jeśli ich nie ma, nie powinien istnieć. Data ważności wyliczana z umowy, nie wpisywana ręcznie. Odbiór wykonywany przez maszynę, nie przez pamięć zespołu. Raport krzyżowy uruchamiany cyklicznie, bo rzeczywistość i tak będzie się rozjeżdżać — chodzi tylko o to, żeby rozjazd został wykryty w tygodniach, a nie w trakcie audytu.
Jeżeli z całej tej historii da się wyciągnąć jedną techniczną lekcję, to tę: pytanie „kto jeszcze ma dostępy po tym człowieku” powinno mieć odpowiedź w SQL-u, a nie w czyjejś głowie.