Skip to content

Security: Exile10/HR-Assist

Security

docs/security.md

Security notes

Authentication

  • Bcrypt (cost 12) in production, cost 4 under phpunit.xml for fast tests.
  • Password rules: minimum 12 characters, mixed case, digit, symbol. The uncompromised() haveibeenpwned check is enabled in production only — we don't want test runs to depend on outbound HTTP.
  • Login throttling: 5 failed attempts per minute per (email + IP). After 5 cumulative failures the account moves to locked for 30 minutes.
  • TOTP 2FA via pragmarx/google2fa-laravel. Recovery codes are encrypted at rest. Disabling 2FA requires the current password.
  • Every login attempt — success, failure, MFA required, locked — is written to login_histories. Successful logins are also written to audit_logs via account.login.

Authorization

  • Hand-rolled RBAC: roles, permissions, permission_role, role_user.
  • Role assignments may be company-scoped via role_user.company_id.
  • Gate::before short-circuits all checks for system_admin.
  • The EnsureRole and EnsurePermission middleware are mounted on every authenticated controller route. Form Requests double-check the same permissions.

Tamper-evident audit

  • audit_logs.current_hash = sha256(prev_hash || canonical_json(payload) || secret).

  • The previous hash is read inside a SELECT … FOR UPDATE to avoid races.

  • The runtime DB user must have no UPDATE or DELETE on audit_logs, activity_logs, login_histories, or api_logs. Migrations run as a separate, more privileged user. This is the actual tamper barrier; the hash chain is the detection.

  • php artisan audit:verify-chain (cron, daily 02:00 UTC) walks the chain end-to-end and reports the first row whose hash does not match.

  • Tampering can be exhibited locally:

    docker compose exec mysql mysql -uhr_assist -psecret hr_assist \
      -e "UPDATE audit_logs SET action='TAMPERED' ORDER BY id LIMIT 1;"
    docker compose exec app php artisan audit:verify-chain
    # → "Audit chain broken — first bad row id: 1"

Anti-collusion

Standard HRs cannot record or terminate work experiences for another active HR at their own company. Only the Head HR (or a system admin) can. This prevents two Standard HRs from creating sham employment records for each other.

The Head HR's own work-experience at the company is created automatically by the system on appointment, so they never need to violate the self-block rule. On succession, the predecessor's open record is auto-terminated.

Identity claim privacy

When a user claims their National ID, they upload a photo of the document.

  • The file is stored under a private disk in a per-user directory.
  • The admin reviews it via a server-side proxy with Cache-Control: no-store.
  • On approve or reject, the file is deleted from disk. The attachments row stays for audit purposes (file_discarded = true, path = '__discarded__').

Input safety

  • All persistence goes through Eloquent (parameterised queries).
  • Every write endpoint has a Form Request validator.
  • File uploads compute and store a sha256 and use UUID-based paths; the original filename is kept only as metadata.
  • CSRF protection on every web POST. Sanctum bearer tokens for the API.

Headers

docker/nginx.conf ships with HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and a tight Permissions-Policy. CSP is intentionally deferred until the Tailwind/Alpine CDN scripts are inlined or replaced with Vite-built assets.

Privacy

  • Soft deletes on Person tables (users, employees).
  • national_id_verifications and verification_requests retain raw payloads for audit. A retention purger should be wired up before production (config/hr_assist.php ships with the policy slot).
  • Account deletion soft-deletes the user and removes the employee_profiles bridge row. The canonical employees row and all work_experiences remain — the system is keyed by National ID, and HR records cannot be evaporated by an account holder.

Pre-production checklist

  1. APP_ENV=production, APP_DEBUG=false.
  2. Force HTTPS at the reverse proxy. URL::forceScheme('https') is on by default in production.
  3. Strong, secret-manager-managed AUDIT_HASH_SECRET. Don't rotate without a documented chain re-anchoring procedure.
  4. DB grants: runtime user has no UPDATE/DELETE on the four log tables.
  5. Replace Tailwind / Alpine CDN tags with built assets and tighten CSP.
  6. Wire NRB credentials and run a smoke search before opening to traffic.
  7. Schedule audit:verify-chain failure alerts.
  8. Enable FEATURE_2FA_REQUIRED_ADMIN=true (default).

There aren't any published security advisories