096 —Workflow
Freelancer-overdracht: twaalf minuten voor ze verdwijnt
Twaalf minuten is genoeg om de SSH-key, de wees-cronjob, de knoop in file ownership en de mail-alias te vinden die de freelancer vergat te noemen.
Een ervaren contractor bij een Rotterdams bureau rondt haar laatste sprint af op vrijdag om 16:30. De maandag erna gaat haar laptop terug naar het bureau, haar SSO wordt ingetrokken, haar telefoonnummer verdwijnt uit Slack. De Drupal 7-site die ze vier jaar lang heeft onderhouden, een regionale supermarktketen met zeventien filialen, blijft draaien omdat niemand iets heeft aangeraakt. Dan, woensdagmiddag, valt iemand op dat de nachtelijke productfeed sinds zondag niet meer is gedraaid. Het cron-entry wees naar een script onder /home/marije/bin/feed-export.sh. Haar home-directory is weg.
Dit is het gat in de freelancer-overdracht dat niemand documenteert. Tussen haar laatste commit en het eerste incident zit een venster waarin haar oppervlak nog steeds verweven is met productie: SSH-keys, cron-entries, file ownership, mail-aliases, een .htaccess-blok gekoppeld aan haar oude staging-IP. Niets daarvan komt naar boven in een code review. Niets daarvan staat in het overdrachtsdocument, tenzij iemand er expliciet naar vraagt. Dus draaien we de freelancer-overdrachtssweep op de vrijdagmiddag voor ze vertrekt, en houden we het bewust kort. Twaalf minuten, vier checkpoints, één tekstbestand aan het eind.
Twaalf minuten, vier checkpoints
De freelancer-overdrachtssweep is kort omdat langere audits gewoon niet gebeuren. Als je het kunt doen vanuit één SSH-sessie en een database client, dan doe je het. Als het drie agenda-uitnodigingen en een stand-up vereist, dan niet.
De vier checkpoints vangen vier verschillende faalmodi:
- Login-oppervlakken. Wie kan er nog bij de server, de database, het paneel.
- Geplande taken. Wat draait er zonder dat er een persoon aan vastzit, en wat breekt zodra die persoon weg is.
- File ownership. Welke UID is eigenaar van de bestanden die de webserver morgen moet overschrijven.
- Mail-routes. Waar klantmail daadwerkelijk landt, in plaats van waar de klant dénkt dat het landt.
Elk checkpoint is twee of drie commando's. Je kunt de hele sweep in een runbook plakken en hergebruiken voor elke volgende freelancer-overdracht.
SSH-keys: elk account op de server
Begin met authorized_keys voor elke user op de server. De key van de freelancer zelf is de voor de hand liggende, maar let ook op de keys die met haar zijn meegekomen. Een junior die ze vorig jaar heeft ingewerkt. Een deploy key uit haar oude Bitbucket-account. Een GitHub Actions runner die ze in 2022 heeft opgezet en waar niemand meer aan denkt.
for u in $(cut -d: -f1 /etc/passwd); do
f="$(getent passwd "$u" | cut -d: -f6)/.ssh/authorized_keys"
[ -f "$f" ] && echo "== $u ==" && cat "$f"
doneLees de comment aan het eind van elke key-regel. Dat is het enige label dat SSH je geeft, en freelancers zetten er meestal hun e-mailadres of hostname neer. Heeft een key geen comment, dan is vragen voor verwijderen de veilige route. Onveilig is hem laten staan en jezelf wijsmaken dat je er later op terugkomt.
Twee specifieke gevallen verdienen een tweede ronde. Eén: keys op het root-account die eruitzien als deploy-pipeline keys. Heeft de freelancer die pipeline opgezet, dan is de key ervan nu een wees, en draaien is niet optioneel. Twee: keys in /etc/ssh/ssh_known_hosts die ze heeft toegevoegd zodat haar CI uitgaand kon verbinden. Die laten niemand binnen, maar ze vertellen je wel waar ze mee praatte.
Nu je toch in /etc/ssh zit: scan sshd_config op Match User-blokken. Freelancers met verhoogde rechten krijgen soms een uitzondering (SFTP-only chroot, alternatieve poort, password auth weer aan), en dat blok overleeft de behoefte ruim. De OpenSSH sshd_config manual documenteert de precedentieregels, wat ertoe doet als je een ruim blok wilt laten staan voor een opvolger.
Nog één pass voor je doorgaat: SFTP-only users. Veel verouderde WordPress- en Joomla-sites leunen op een aparte SFTP-account met een naam als wp-deploy of staging. Controleer /etc/passwd op shells die op /usr/sbin/nologin of /bin/false staan, en kijk dan of de authorized_keys van dat account nog de key van de freelancer bevat.
Cron en timers: de wees-jobs
Cron is de meest voorkomende landmijn in een freelancer-overdracht. De user wordt verwijderd, de crontab blijft draaien, het falen is stil omdat ze de output jaren geleden naar /dev/null heeft doorgesluisd.
# system-wide cron
ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/
cat /etc/crontab
# per-user crontabs
for u in $(cut -d: -f1 /etc/passwd); do
out=$(crontab -u "$u" -l 2>/dev/null)
[ -n "$out" ] && echo "-- $u --" && echo "$out"
done
# systemd timers, die steeds meer installs verkiezen
systemctl list-timers --allLees elke regel. Het signaal waar je naar zoekt: paden onder de home-directory van de freelancer, scripts die onder haar user draaien, of commando's die naar een persoonlijk e-mailadres pipen. De fix is meestal drie stappen: verplaats het script naar /usr/local/bin, zet eigendom op root:root, en voeg het cron-entry opnieuw toe onder een systeem-user met hetzelfde schema.
Raakt een job een database, controleer dan of de credentials in het script zelf staan of in een .my.cnf onder haar home-directory. Het tweede geval breekt stil zodra haar account weg is, omdat het bestand er dan niet meer is voor mysql om te lezen.
Dezelfde logica geldt voor PHP-FPM pool-definities. Als /etc/php/8.1/fpm/pool.d/ een pool bevat die als marije draait, dan stopt de site die door die pool wordt bediend met reageren zodra haar user wordt verwijderd. Pools worden makkelijk over het hoofd gezien omdat ze ooit tijdens een oude migratie zijn ingesteld en daarna nooit meer aangeraakt.
File ownership en de www-data-val
Draai find tegen de document root en zoek naar bestanden die eigendom zijn van de freelancer. Op een opgeruimde server levert dat niets op. Op een vier jaar oude WordPress-installatie levert het duizenden treffers op, omdat elke plugin die ze via SFTP heeft geïnstalleerd haar UID draagt, elke plugin-update die via de WordPress-admin is geschreven de UID van de webserver draagt, en die twee nooit met elkaar zijn verzoend.
find /var/www/example.com -not -user www-data -printf '%u %p\n' \
| sort | uniq -c | sort -rn | headDe output vertelt je wie er onder welke naam naar disk heeft geschreven. Zie je 1.800 bestanden van marije in wp-content/plugins, dan is dat het oppervlak dat je moet hernormaliseren voor de volgende deploy. Los het op in twee stappen. Paden waar de applicatie zelf naar schrijft krijgen de web-user. De rest krijgt een deploy-user die de webserver wel kan lezen maar niet kan schrijven.
# paden waar de applicatie zelf naar schrijft
chown -R www-data:www-data /var/www/example.com/wp-content/uploads
chown -R www-data:www-data /var/www/example.com/wp-content/cache
# de rest gaat naar een deploy-user
chown -R deploy:www-data /var/www/example.com
find /var/www/example.com -type d -exec chmod 750 {} \;
find /var/www/example.com -type f -exec chmod 640 {} \;
chmod 600 /var/www/example.com/wp-config.phpDe exacte verdeling hangt af van het CMS. De WordPress hardening guide heeft de canonieke getallen en legt uit waarom wp-config.php op 600 hoort te staan. Drupal, Joomla en Magento hebben elk hun eigen varianten in hun eigen documentatie. Het punt is dat je ownership niet kunt normaliseren zonder eerst de directories te inventariseren waar de applicatie zelf naartoe schrijft. Krijg je dat verkeerd, dan faalt de volgende core-update met een permission error op het slechtst denkbare moment.
Database-users volgen hetzelfde patroon. De freelancer heeft waarschijnlijk een persoonlijke MySQL-user aangemaakt voor haar eigen debugging, met ruimere grants dan de application-user waarmee de site daadwerkelijk verbindt.
SELECT User, Host FROM mysql.user ORDER BY User;
SHOW GRANTS FOR 'marije_admin'@'%';Houd de application-user. Drop de persoonlijke. Weet je niet zeker welke welke is, check dan de DB-credentials in wp-config.php, settings.php of app/etc/env.php. Waar de site mee verbindt, is degene die je houdt.
Mail-aliases en de stille forwarder
Dit is degene die niemand controleert, en het is degene die de klant een klant kost. Drie plekken om te kijken.
# Postfix- of Exim-aliases
cat /etc/aliases
postmap -q webmaster@example.com hash:/etc/aliases 2>/dev/null
# .forward-bestanden in elke home-directory
find /home /root -name '.forward' -exec echo {} \; -exec cat {} \;De forwarder waar je op jaagt is degene die stilletjes info@example.com of orders@example.com doorzet naar de privé-Gmail van de freelancer. Soms was het bewust, opgezet tijdens een launchsprint om klantmail te triëren. Soms was het een tijdelijke debug-stap die nooit is teruggedraaid. Hoe dan ook: zes maanden later landt de klantmail in een inbox die de klant niet beheert, en heeft het Gmail-filter van de freelancer hem auto-gearchiveerd.
Aliases op applicatieniveau zijn de derde plek. Elk CMS heeft z'n eigen versie:
-- WordPress
SELECT option_name, option_value FROM wp_options
WHERE option_name IN ('admin_email', 'new_admin_email');
-- Drupal 7
SELECT name, value FROM variable WHERE name = 'site_mail';
-- Magento 2
SELECT path, value FROM core_config_data
WHERE path LIKE 'trans_email/ident_%/email';Wijst een van deze naar een persoonlijk adres, pas het dan aan. Nu je daar toch zit: controleer de SMTP-plugin of -module. WP Mail SMTP, de SendGrid-extensie voor Magento en de SMTP Authentication Support-module voor Drupal houden allemaal een from-adres bij dat stilletjes via de eigen API key van de freelancer kan routeren. Trekt zij die key in bij vertrek, dan stopt de site volledig met mail versturen, en dat is op zichzelf weer een incident.
RFC 5321 beschrijft het formele model van hoe aliases en forwarders zich tot elkaar verhouden. Het praktische model is dat elk van deze lagen klantmail kan opslokken zonder dat iemand het maandenlang doorheeft.
De overdrachtsnotitie die overleeft
De freelancer-overdrachtssweep levert één artefact op: een tekstbestand in de repository onder docs/handover-2026-06-10.md. Drie secties, niet meer.
- Ingetrokken oppervlakken. Verwijderde SSH-keys, gedropte MySQL-users, herschreven aliases, gewiste
.htaccess-blokken. Eén regel per wijziging, met de vorige waarde in een comment. - Oppervlakken die blijven staan. Jobs die we bewust hebben laten draaien, met de reden. Het deploy-script dat we nog niet de tijd hadden te vervangen. Het cron-entry dat een derde-partij-API raakt waarbij de freelancer nog steeds het contractuele aanspreekpunt is.
- Openstaande vragen. Het
.htaccess-blok dat een IP whitelist'te dat we niet konden thuisbrengen. De MySQL-user die we hebben laten staan omdat de applicatie hem ook lijkt te gebruiken. De CNAME die wijst naar een Heroku-app waar we niet op zijn ingelogd.
De derde sectie is degene die toekomstige-jij als eerste leest de volgende keer dat er iets breekt, en het telefoonnummer van de freelancer is dan al een jaar verlopen. Schrijf het zo dat volgende-kwartaal-jij ernaar kan handelen zonder context.
Wat we uiteindelijk hebben gebouwd
Toen we Pier bouwden, bleven we deze sweep handmatig draaien tegen klantservers, en bleef het langer duren dan twaalf minuten omdat de helft van het werk scrollback lezen is en wachten tot find klaar is. De manier waarop we dat uiteindelijk hebben opgelost: de scan op file ownership, de cron- en timer-dump en de alias-check zijn in de MySQL editor en SFTP-browser ingebakken, zodat de sweep één leesbare diff oplevert en een versiegeschiedenis van elke wijziging die je tegen de verouderde site maakt.
Het kleinste wat je vandaag kunt doen: open ~/.ssh/authorized_keys op je oudste productieserver en lees de comments aan het eind van elke regel. De namen die je niet herkent, zijn het begin van je audit-lijst.
— Vragen —
Wat als de freelancer al onbereikbaar is en we dit niet samen met haar kunnen doen?
Draai het toch, maar behandel elke onbekende key en elke persoonlijk ogende forwarder als vijandig totdat het tegendeel is bewezen. De sweep werkt ook retroactief, hij levert alleen meer openstaande vragen op.
Kan de freelancer-overdrachtssweep zonder root-toegang?
Het meeste wel. authorized_keys, per-user crontabs en find tegen de docroot werken als elke user met leesrechten. /etc/aliases, sshd_config en chown vereisen root of sudo.
Geldt dit ook op shared hosting waar ik alleen cPanel of Plesk heb?
Ja. Vervang de SSH-stappen door de File Manager- en Cron Jobs-panelen, en check de sectie Email Forwarders voor de alias-stap. De vier checkpoints corresponderen één-op-één met paneel-schermen.