ADR 0019: Admin crawl/enrichment trigger gated by the admin's own 2FA¶
- Status: Accepted
- Date: 2026-07-20
- Deciders: Principal Architect (jobhub-architect); David R H
- Affects: job-service (Hexagonal), auth-service (Clean), api-contracts (job-service.yaml + auth-service.yaml), db/init, JobHub-ui
- Supersedes: ADR 0003 section 4 (the optional emailed request-code gate) and its
related pieces of sections 5 to 7. ADR 0003's core decision (trigger hosted on job-service,
signalling crawler through the
crawler.trigger_requestcontrol table, the enabled gate, and the admin allowlist toadminJWT group) is unchanged.
Context¶
Story #384 (sub-issues: #385 this contract freeze, #388 auth backend, #389 job backend, plus
PDA/QAE/UI tickets). The admin crawl/enrichment trigger shipped in ADR 0003 with an optional
emailed 6-digit "request code" gate (jobhub.admin.trigger.require-code): to fire a trigger
the admin first requested a code via POST /auth/account/verifications { action: admin-trigger },
received it by email, then passed { verificationId, code } to POST /jobs/admin/triggers,
which job-service consumed service-to-service via POST /auth/account/verifications/consume.
The product owner judged the emailed request-code path overkill: it is a second, purpose-built challenge mechanism bolted onto an admin action, with an email round-trip, when the platform already has strong per-account 2FA (TOTP + backup codes, ADR 0012). An admin who has enabled 2FA already holds an authenticator; an admin who has not has made that choice for their account. The security of a sensitive admin action should ride on the admin's own 2FA posture, not on a bespoke emailed code.
Constraints and existing facts this builds on (verified against the tree):
- auth-service already owns full TOTP 2FA (ADR 0012,
db/init/024-auth-2fa-totp.sql):auth.user.two_factor_enabled,auth.totp_secret,auth.totp_backup_code,auth.two_factor_challenge. The verification logic exists and is reused by the login-2fa path:application/usecase/VerifyLoginTwoFactorService,application/usecase/TwoFactorCodeMatcher, and theapplication/port/out/TotpCodeVerifier(adapter/out/security/TotpCodeVerifierAdapter). We reuse this; we do not re-spec any crypto. - auth already exposes
X-Service-Key-guarded internal endpoints (serviceKeyAuthscheme,JOBHUB_INTERNAL_SERVICE_KEY): e.g.GET /internal/users/emails,GET /internal/users/without-2fa. A new internal endpoint is an established, least-surface pattern. - job-service already has the admin's identity as the JWT subject. ADR 0003 records
requested_byas "admin user UUID from the JWT subject". The authuser.idis the JWTsub. So job-service can name the admin to auth service-to-service by userId, with no extra lookup. - job-service already calls auth service-to-service (
domain/port/out/VerificationGateway→adapter/out/client/VerificationGatewayAdapter→AuthServiceRestClient) and already had the WireMock dependency for its client tests. This change repoints that outbound client at the new internal endpoints; it does not add a new outbound-HTTP capability. - auth-service runs at root-path
/auth. Any rest-client from job-service must bake/authinto its@Pathor it 404s (a known past failure mode). Recorded here so developers do not relearn it.
Locked product rules (confirmed with the human):
- Gate: if the triggering admin has 2FA enabled, they must supply a valid TOTP code (or a backup code) to fire a trigger. If they have no 2FA enabled, the trigger fires directly with no extra step. Security is delegated to the admin's own 2FA choice.
- Remove the emailed request-code path end-to-end: the auth
admin-triggerverification action, job-service'sconsumecall for it, and the UI "Request Code" step all go away.
Decision¶
Replace the optional emailed request-code gate with a 2FA-conditional gate keyed on the triggering admin's own account 2FA, verified by job-service against auth-service service-to-service. The enabled gate, the control-table trigger mechanism, and the admin allowlist from ADR 0003 are untouched.
1. Two new internal (service-to-service) auth endpoints¶
Both are serviceKeyAuth (X-Service-Key), under the existing /internal/... convention,
and, because auth is root-pathed at /auth, resolve at /auth/internal/.... The admin is
named explicitly by userId (the JWT sub job-service extracted from the admin's token):
service-to-service calls carry no user JWT, and userId is already in job-service's hand and is
auth's natural key (mirrors /internal/users/emails, /internal/users/without-2fa). We chose
userId over email: it is directly available, needs no lookup, and avoids leaking/handling
the address on this path.
Two endpoints, not one, because job-service has two distinct needs:
GET /internal/users/{userId}/two-factor→200 TwoFactorStatusResponse { twoFactorEnabled }. A no-code status read used byGET /jobs/admin/triggers/statusto tell the UI whether to show a code input. Errors:400bad uuid,401bad service key,404unknown user,500.POST /internal/two-factor/verify→ bodyVerifyTwoFactorRequest { userId, code? }, returns200 VerifyTwoFactorResponse { outcome: verified | not_enrolled }. The authorization decision used byPOST /jobs/admin/triggers.verified= 2FA enabled and code valid;not_enrolled= no 2FA (proceed directly). Errors:400malformed body,401bad service key,404unknown user,4222FA enabled and code missing/invalid/expired/used,429attempt throttle,500. It reuses the existing TOTP + backup-code verification (valid TOTP verifies without side effect; a valid backup code is consumed single-use; failures are throttled).
A single collapsed endpoint was rejected: the status panel legitimately needs a side-effect-
free, throttle-free read, and overloading the verify POST for that would abuse a
state-changing/throttled operation for a query. 422 (not 401) carries "bad code" so it never
collides with the 401 that serviceKeyAuth failure already returns on the same operation.
2. Layering (no blending)¶
auth-service (Clean). Add two use cases in application/usecase/ behind new
application/port/in/ interfaces:
- a 2FA-status query (e.g. GetTwoFactorStatusUseCase + service), reading TotpSecretRepository
/ auth.user.two_factor_enabled;
- a service-to-service verify (e.g. VerifyTwoFactorForServiceUseCase + service) that takes a
command { userId, code } and reuses TwoFactorCodeMatcher / TotpCodeVerifier /
TotpSecretRepository (the same collaborators VerifyLoginTwoFactorService uses). No new
domain entity and no new crypto. The two endpoints live on the existing
adapter/in/rest/InternalUserResource (Layer 3, serviceKeyAuth), with request/response
DTOs in adapter/in/rest/dto/ mapped to the commands. Bad-code and throttle map to HTTP via
@Provider ExceptionMappers (reuse/extend the existing InvalidTotpCodeException /
TwoFactorNotEnabledException mappers; do not build responses in the resource).
job-service (Hexagonal). Keep the outbound port in domain/port/out/ and its adapter in
adapter/out/client/. Replace the VerificationGateway semantics with a 2FA-oriented out-port
(e.g. AdminTwoFactorGateway with boolean isEnabled(UUID userId) and an verify(UUID userId,
String code) returning an authorized/denied result), implemented by an adapter over
AuthServiceRestClient now pointing at /auth/internal/users/{userId}/two-factor and
/auth/internal/two-factor/verify (both with the X-Service-Key header from config, and
/auth baked into the @Path). domain/service/AdminTriggerService drives the gate: the POST
path resolves the admin's userId from the JWT subject and calls verify; the status path calls
isEnabled to set twoFactorRequired. DTOs stay at the REST boundary; the domain stays
annotation-free. No handler injects an adapter directly; the service depends only on the port.
3. Contract changes (frozen; new/changed items x-implementation-status: planned)¶
auth-service.yaml
- New schemas TwoFactorStatusResponse, VerifyTwoFactorRequest, VerifyTwoFactorResponse.
- New ops getUserTwoFactorStatus (GET /internal/users/{userId}/two-factor) and
verifyUserTwoFactor (POST /internal/two-factor/verify), tag Internal, serviceKeyAuth.
- Removed admin-trigger from the VerificationRequest.action and
ConsumeVerificationRequest.action enums and from the consumeVerification description.
delete-account / delete-all-applications are unchanged (application-service still uses
the consume endpoint for those).
job-service.yaml
- TriggerRequestBody: dropped verificationId; code is now the admin's optional own
2FA code, pattern ^\d{6}$|^[a-zA-Z0-9]{8}$ (TOTP or backup code).
- TriggerStatusResponse: dropped codeRequired (a deployment flag); added
twoFactorRequired (a per-caller boolean: does THIS admin have 2FA). This is a
deliberate breaking rename of a planned field; the UI ticket adapts.
- POST /jobs/admin/triggers: description now documents the 2FA gate; 422 means "admin has
2FA and the supplied code was missing/invalid/expired/used"; 429 is the auth-side 2FA
attempt throttle. No verificationId anywhere.
4. Migrations¶
| Ticket | Number | Change |
|---|---|---|
| auth backend #388 | 048-auth-drop-admin-trigger-action.sql |
Forward-only. Narrow the auth.verification_code.action CHECK back to ('verify-email', 'delete-account', 'delete-all-applications'), removing 'admin-trigger' (reverses 023). Must first DELETE FROM auth.verification_code WHERE action = 'admin-trigger' so the re-added CHECK does not fail on residual rows (safe: codes are short-lived, 15-min TTL). Same drop-then-add shape as 023. Prod runs Hibernate validate, so this file is the schema source; dev/test drop-and-create from entities and skip it. |
| job backend #389 | N/A | No schema change. The gate is logic-only: repoint the outbound client and change AdminTriggerService. No new table/column in job or crawler. |
048 is the next free number (latest is 047-pg-stat-statements.sql). No column is added for
2FA state on the trigger path: the source of truth stays in the auth schema (totp_secret /
two_factor_enabled), read live per request. DevOps mounts 048 in compose in numeric order;
on an existing volume it is applied by hand (podman exec -i jobhub-db psql -U jobhub -d jobhub
< db/init/048-auth-drop-admin-trigger-action.sql) then auth-service restarted.
5. Error model (job-service POST /jobs/admin/triggers, updated)¶
| Condition | Status | error title |
|---|---|---|
| No / invalid Bearer token | 401 | (policy) |
Authenticated, not in admin group |
403 | (policy) |
Triggering disabled (enabled=false) |
403 | Triggering Disabled |
Malformed body (unknown kind, code fails pattern) |
400 | Validation Error |
| Admin has 2FA and code missing/invalid/expired/used | 422 | Verification Required |
| Auth-side 2FA attempt throttle | 429 | Too Many Requests |
| Same-kind pass already queued/running | 409 | Trigger In Progress |
| Unexpected | 500 | Internal Server Error |
Consequences¶
- Positive: one fewer bespoke security mechanism. No emailed request code, no
verificationIdhandshake, noPOST /auth/account/verifications { action: admin-trigger }. The gate reuses proven per-account 2FA and honours the admin's own posture. - Positive: auth changes are additive (two internal endpoints reusing existing verification) plus one narrowing migration. job changes are logic-only, no schema. Both services keep their architecture: job Hexagonal, auth Clean.
- Positive:
twoFactorRequiredis resolved per admin, so the UI shows the code input only to admins who actually have 2FA; non-2FA admins get a one-click trigger. - Neutral / by design: an admin with no 2FA fires the trigger with no second factor. That is the explicit product rule (security delegated to the admin's own choice); if a hard second factor for all admins is later required, that is a follow-up (e.g. require 2FA enrollment for allowlisted admins), not this ADR.
- Cost: a breaking rename of the
plannedcodeRequiredfield totwoFactorRequiredand a changedcodeshape; the UI and any status consumers must adapt. Acceptable while the trigger feature is stillplanned. - Cost: the status panel adds one extra service-to-service round-trip (the 2FA-status read) per status fetch. Cheap, cacheable if ever needed, and off the hot job-search path.
Alternatives considered¶
- Keep the emailed request-code gate. Rejected by the product owner as overkill given first-class 2FA already exists; it is a second challenge mechanism with an email round-trip.
- Single collapsed verify endpoint (no status read). Rejected: the status panel needs a side-effect-free, throttle-free read; abusing the verify POST for a query conflates a state-changing/throttled operation with a status lookup.
- Name the admin by email instead of userId. Rejected: userId (JWT
sub) is already in job-service's hand and is auth's natural key; email needs a lookup and needlessly handles the address on the trigger path. 401for a bad code on the verify endpoint. Rejected: it would collide with the401thatserviceKeyAuthfailure returns on the same operation;422keeps "bad service key" and "bad 2FA code" cleanly distinguishable for the rest-client.- Store admin 2FA state in the
crawler/jobschema. Rejected: 2FA state is owned by theauthschema; reading it live service-to-service keeps a single source of truth and adds no cross-schema coupling.