Workflow

Auditor Access

Grant external auditors time-limited, scoped access to your compliance records. A single magic-link flow issues an HMAC-signed session cookie for every access level — no Supabase signup required. Auditors and org members share a bidirectional comment thread per entity with optional evidence attachments. Every action logged append-only for DORA Art. 5 and ISO 27001 A.5.28.

Owner: Security TeamLast reviewed: 2026-04-17

Access levels

3

Read-only · Comment · Full

Scopes

6

Evidence · Controls · Policies · Incidents · Risks · SOA

Default expiry

90 days

Auto-revokes; configurable per grant

Session TTL

8 hours

Re-reads DB every request — revokes are immediate

Who can grant access

Only users with the OWNER or ADMIN role can grant, edit, or revoke auditor access. The endpoint is POST /api/auditors and is RBAC-gated. Lower roles see the Auditors page in read-only mode via the normal PermissionGate.

How to grant access — step by step

  1. Go to Dashboard → Auditor Collaboration and click Grant Access.
  2. Enter the auditor's email. The invite is personal to this address; a different email signing up later is rejected.
  3. Pick the audit firm (used only as a label in the UI and email).
  4. Choose the access level:
    • Read-only — the auditor receives a magic-link invite, no signup required. Best for short engagements.
    • Comment — auditor can also leave review comments on evidence. Requires signup so comments attribute to a real identity.
    • Full — auditor can view, comment, and download. Also requires signup.
  5. Check the scopes the auditor will see. Defaults to Evidence only. Adding more scopes broadens what the auditor can review — keep least-privilege in mind (DORA Art. 28).
  6. Optionally narrow vendor scope if evidence or risks are in scope. Empty = all vendors.
  7. Set an expiry date or leave blank for 90 days. Access is auto-revoked at midnight on the expiry date by the daily auditor-access-expiry cron job.
  8. Click Send Invite. An invite email is sent via Resend; a warning toast appears if the email could not be delivered, with a one-click Resend.

Invite tokens are never stored

The raw token is generated server-side, emailed once, and never persisted. Only its SHA-256 hash is written to AuditorAccess.tokenHash. A leaked database dump cannot be used to impersonate an auditor — the attacker would need the raw token from the original email.
Rendering diagram…
Grant-and-invite flow — including the explicit email-failure branch.

Access levels explained

All three access levels use the same cookie-only flow — the auditor clicks the invite link and lands directly in the portal. An HMAC-signed session cookie (8 hour TTL) is issued and no Supabase user row is created. Attribution lives on the AuditorAccess row itself plus snapshotted email + firm fields on every record the auditor writes, so the audit trail survives revocation.

  • Read-only — view only. No comment composer, no download buttons. Best for certification-body spot checks.
  • Comment — everything read-only sees, plus the ability to post review notes on any in-scope entity and attach existing evidence items as context for an org reply.
  • Full — comment-level permissions plus signed-URL download of evidence files. Use this for deep engagements (external ISO 27001 certification, DORA TLPT audit prep).

An auditor's level can be changed after the fact via PATCH /api/auditors/[id]. Capability changes take effect on the next request — resolveAuditorSession re-reads the row from the DB every time; the session cookie only proves identity, never capabilities.

Accepting the invitation

The auditor clicks the link in the email. The landing page at /auditor/accept exchanges the token for an HMAC-signed session cookie and redirects to /auditor/portal. No signup, no Supabase round-trip, no password — the cookie itself proves identity for the 8-hour window, and every portal request re-reads AuditorAccess for authorisation.

The accept endpoint:

  • Rate-limits every POST to 10/min per (IP × token prefix) so a brute-force attempt on an unknown token cannot scan the space.
  • Consolidates every failure (bad token, revoked, expired) into a single 404 so we don't leak the existence of a token.
  • Logs ACCEPT_INVITE (first visit) or REACCESS_INVITE to AuditLog with the auditor's email and client IP.
Rendering diagram…
Accept flow — both magic-link and signup branches.

What auditors see in the portal

The portal at /auditor/portalis a minimal, dashboard-free surface. The top-bar shows only the auditor's firm, email, and access level. The tab nav is rendered from the grant's scopes — an auditor never sees a tab they were not granted.

  • Evidence — list of evidence items in scope (filtered by vendorIds). Rows expand to show metadata, versions, and the review-notes thread. FULL auditors get a signed-URL Download button on rows with a real fileUrl; metadata-only rows display No evidence attached.
  • Vendors — scoped vendor list with risk tier and active-contract flags.
  • Controls & SOA — register with status and owner; clickable rows expand to the same shared detail dialog used in the dashboard, including review notes.
  • Policies — published-version list with effective dates. Expansion shows the document body and the review-notes thread.
  • Incidents — DORA Art. 19 register with severity, timeline, and deadline tracking. Expansion shows the details / timeline / actions / review-notes tabs.
  • Risks — ISO 27001 Clause 6.1 register with treatment and owner. Expansion shows the thread.

Every API call in the portal goes through /api/auditor/* routes which share a common auth helper, resolveAuditorSession(). That helper:

  • Verifies the HMAC signature on the session cookie.
  • Reads the live AuditorAccess row from the database (never trusts the cookie's payload for permissions).
  • Rejects revoked, expired, or deactivated grants immediately.
  • Bumps lastAccessedAt so granters see when the auditor was last in.
  • Returns the current scopes and vendorIds for the calling route to enforce.
Rendering diagram…
Every portal request re-validates against the DB — no stale sessions.

Terms of Engagement (first portal visit)

The first time an auditor lands on /auditor/portalafter clicking their magic link, they're redirected to /auditor/toe— a blocking Terms of Engagement page. The page covers what they're accessing, confidentiality expectations carried over from their engagement letter with the granting organisation, audit-log semantics, and what they must not do (share cookies, exceed scope, copy evidence out-of-engagement). They check a single acknowledgement box and click Accept.

Acceptance sets AuditorAccess.auditorTermsAcceptedAt to NOW() and writes an ACCEPT_INVITEentry to the granting org's AuditLog with the IP address and user-agent captured from the request. Returning visits skip the TOE — the portal layout only redirects when the column is still null. If the granter ever clears the column, the auditor re-accepts on next visit.

Regulated customers (CSSF / DORA) specifically ask for documented acknowledgement of confidentiality and audit-log expectationsbefore external access is granted. The TOE + AuditLog row is that documentation.

Bidirectional collaboration

The portal and the dashboard share a single polymorphic AuditorComment table. Auditors and org members both post into it; both sides see the same thread woven into chronological order; and every reply produces a real-time notification on the other side. Threads are append-only — no edits, no deletes — so the audit trail is safe for ISO 27001 A.5.28 retention.

A comment is uniquely identified by (entityType, entityId)Evidence, Control, Policy, Incident, or Risk. The authorType discriminator (AUDITOR vs ORG_MEMBER) drives the visual styling (amber vs blue accent) and determines which snapshot fields are populated on the row.

Auditor → org notification

  • On POST /api/auditor/comments, notifyUser dispatches an in-app bell badge + an email to the original granter and every COMPLIANCE_OFFICER on the org.
  • The notification link includes ?focusId=<entityId>. Each dashboard register page (controls, policies, incidents, evidence, risks) reads this param, expands the matching row, and scrolls it into view so the reviewer lands exactly on the threaded entity.
  • The bell polls every 15 s and refetches instantly on visibilitychange + window.focus so new notes appear within ~1 s of tab wake.

Org → auditor reply

  • Any role that can read the entity can reply via POST /api/entity-comments. Permission matches whatever the central RBAC matrix defines for read:<entity>.
  • Every reply fans out an email via sendAuditorReplyEmail to every distinct auditor who has commented on the thread, filtered to grants that are still active, unrevoked, and unexpired.
  • Auditors receive no in-app bell (no User row), so email is the canonical channel.

Evidence attachments

  • Both sides can attach existing Evidence rows to any reply via a shared EvidencePicker (search + multi-select, max 10 per comment). Attachments are linked through the AuditorCommentEvidence join table and are append-only — you can't un-attach after sending.
  • Rendered attachment chips link to a signed URL. Org side hits GET /api/evidence/download?id=; auditor side (FULL only) hits GET /api/auditor/evidence/[id]/download.
  • Vendor-scope bypass. A vendor-scoped auditor can download evidence that falls outside their normal vendorIds restriction when an org member has intentionally attached it to a thread the auditor has scope for. Still gated on orgId, soft-delete, and FULL access level.
  • Metadata-only evidence (no fileUrl) renders as a greyed-out chip that can't be clicked — no signed-URL dialog is ever generated for an empty record.
  • Notifications & audit-log previews suffix [N evidence items attached] so reviewers know what to expect before clicking through.
Rendering diagram…
Bidirectional comment flow — auditor note triggers org notification; org reply fans out email to every auditor on the thread. Both sides can attach existing evidence rows via AuditorCommentEvidence.

Security features

  • Hash-at-rest tokens. Raw invite tokens are generated with crypto.randomBytes(32) (256 bits of entropy), delivered in the email once, and never persisted. The database holds only SHA-256(token) in tokenHash.
  • Stateless session verification. The session cookie is an HMAC-signed compact token covering {accessId, orgId, iat, exp}. Signed with AUDITOR_JWT_SECRET (separate from any other platform secret). No DB round-trip to verify identity, but every request still re-reads the access row for authorisation.
  • HttpOnly + SameSite=Lax cookie. Not accessible to JavaScript. Marked Secure in production.
  • Email-identity hard match. For Comment/Full auditors, the Supabase signup email must equal the invited email (case-insensitive). A mismatch signs the user out and rejects the link-up — no silent fallback.
  • Rate-limited. Accept endpoint is 10/min per (IP × token); resend is 3/hour per grant. Both return 429 with Retry-After.
  • Append-only audit trail. CREATE, UPDATE,REVOKE, ACCEPT_INVITE, VIEW, DOWNLOAD, COMMENT, EXPIRE, and AUTH_FAILURE all write to AuditLog via logAudit(). Auditor entries have source = "auditor" so you can filter the log per external party.
  • Immediate revocation. Clicking Revoke sets isActive = false, revokedAt, and revokedBy. The next request the auditor makes fails at resolveAuditorSession(), not at cookie expiry. Defence-in-depth beyond the 8 hour session TTL.
  • Auto-expiry cron. A daily job auditor-access-expiry revokes any grant whose expiresAt has passed, writes an EXPIRE audit entry, and emails the granter with a one-click re-grant link. Runs via GET /api/cron?job=auditor-access-expiry with a CRON_SECRET bearer.

Managing granted access

  • Lifecycle states shown in the table:
    • Pending — invite sent, auditor hasn't accepted yet.
    • Active — accepted, not expired, not revoked.
    • Expired — past expiresAt; auto-revoked by cron.
    • Revoked — manually revoked by an admin/owner.
  • Resend. For Pending grants, click Resendto rotate the token and re-send the invite email. The previous email's link is immediately invalidated. Rate limit: 3/hour.
  • Revoke. Click Revoke on Active or Pending grants. The access row is deactivated and all active sessions are terminated on the next request.
  • Edit scope or level. PATCH /api/auditors/[id] allows editing accessLevel, scopes, expiresAt, or vendorIds without a revoke-then-re-grant cycle. Currently exposed via the API; UI button to be added in a follow-up.
  • Last accessed.The table column shows when the auditor last hit any portal route. A blank "Never" on a Pending grant + several days elapsed is a good prompt to Resend or follow up.

Regulatory mapping

  • DORA Art. 5 (Governance) — external-party access is logged append-only, attributable to the granter and to the auditor's session.
  • DORA Art. 28 (Third-party risk) — per-grant scoping + vendor filtering lets you enforce least-privilege when granting access to auditors of ICT third-party service providers.
  • ISO 27001 A.5.18 (Access rights) — access is time-limited with auto-expiry, and explicit revocation is recorded.
  • ISO 27001 A.5.28 (Supplier & external-party access) — every VIEW / DOWNLOAD / COMMENT is captured in AuditLog with source = "auditor".
  • ISO 27001 A.8.5 (Secure authentication) — hash-at-rest tokens, HMAC session signatures, Secure/HttpOnly cookies, email-identity binding.

Deferred to a follow-up release

  • NDA / Terms-of-Engagement gate on first portal visit — some regulated customers want a signed-and-timestamped NDA capture before any data is shown.
  • Watermarked PDF downloads — dynamic overlays with auditor email + access timestamp on every downloaded evidence PDF.
  • Threaded @mentions of specific org members so a reply can route to one person instead of broadcasting to all Compliance Officers.
  • Per-comment resolution state ("mark as addressed") so long engagements don't leave threads ambiguous.
  • Digest emails on hot threads — today every reply is its own email; a simple 15-min digest would blunt notify-spam without losing signal.
  • UI for access-level / scope edits — the PATCH /api/auditors/[id] endpoint supports them but the Auditors page currently only exposes grant + revoke.

Troubleshooting

  • "Invite email did not arrive." Check the toast after Grant Access — if the API returned emailSent: false, the Resend call failed. Common causes: RESEND_API_KEY unset (logs [email] skipped), sender domain not verified in Resend, or the recipient domain bouncing. Use the Resend action on the toast (or the button on the Pending row) to retry.
  • "Invalid or expired link."The 404 response is intentional — we don't tell the client which of token unknown / already revoked / past expiry / already accepted elsewhere triggered it. Look in the server logs or re-issue via Resend.
  • Portal tabs empty. Check the grant's scopes. An empty scopes array defaults to Evidence only; non-Evidence tabs are hidden by the portal layout.