ADR 0003: Admin-only trigger for crawl & enrichment passes¶
- Status: Accepted
- Date: 2026-06-06
- Deciders: Principal Architect (jobhub-architect); David R H
- Affects: job-service (Hexagonal), crawler-service (Hexagonal), auth-service (Clean), api-contracts (job-service.yaml + auth-service.yaml), db/init, podman-compose / deploy (DevOps)
Context¶
Story #7 (sub-issues #31 auth, #32 crawler, #33 frontend) adds an admin-only page that can manually trigger a crawl and trigger an enrichment pass and show basic status / last-run. Triggering must be enable/disable-able via deployment config (reloadable) and optionally require a 6-digit verification code before firing. This ADR is the contract-freeze gate the three tracks build on in parallel.
Locked product decisions (not relitigated here):
- Admin identity = a configured email allowlist (deployment env var). On login, an email in
the allowlist gets an
admingroup in its JWT; the admin page + endpoints require that group. Noauth.userschema change — a DB admin flag was explicitly rejected as hacker-prone. Admin-ness is derived purely from config at token-issue time. - Email/verification-code infra from Story #6 (ADR 0002) is in
main:auth.verification_code(hashed 6-digit code,actionCHECK, expiry, attempts, 429 throttle), theVerificationActionenum,EmailVerificationService/AccountVerificationService, and a profile-selectedVerificationNotifier. The optional code gate reuses this — no new code mechanism. - Reloadable toggle to enable/disable triggering + an optional code gate, both from config.
- Out of scope: full RBAC/permissions; per-target scheduling UI. Just "trigger a pass" + status.
The forces that actually constrain the design come from the deploy topology, verified against the code and the production wiring:
- crawler-service has no published HTTP port and no JWT.
podman-compose.ymlanddeploy/shared/compose/docker-compose.prod.ymlboth label it "Scheduled batch crawler … No JWT, no published port." The oci-free edge (deploy/shared/nginx/default.conf, fronted by Caddy) proxies only/auth/,/jobs/,/applications/to the three published services; crawler sits in the internal-only group with Postgres and Ollama and is not internet-reachable. Its pom hasquarkus-rest+quarkus-rest-client-jacksonbut noquarkus-smallrye-jwt. - job-service is already JWT-verifying and internet-exposed. Its pom has
quarkus-smallrye-jwt quarkus-test-security-jwt+ the JWT public-key plugin;JobResourcealready gates endpoints with@RolesAllowed("user"). It is proxied at/jobs/and publishes8081.- job-service already writes the
crawlerschema cross-schema.SavedJobEntity/SavedFilterEntityare@Table(schema = "crawler"), and010-crawler.sqlgrantsjob_userSELECT, INSERT, UPDATE, DELETEon those tables. A control table incrawlerwritten by job-service is therefore an existing, proven pattern, not a new one. - The crawl/enrich use cases are synchronous and directly callable.
CrawlUseCase.crawlBatch(int)→CrawlBatchResultandEnrichJobsUseCase.enrichPending(int)→int. A trigger needs only to invoke them; it adds no new domain logic to the crawler. - A reusable service-to-service code-consume endpoint already exists.
POST /auth/account/verifications/consume(consumeVerification) is documented as the internal endpoint other services call to validate+consume a code for an action they own (application-service already uses it via itsAuthServiceRestClient). It returns 204 on valid+consumed, 400 on invalid/expired/used/action-mismatch, keyed to the Bearer-token user.
job-service and crawler-service are Hexagonal; auth-service is Clean. The architecture per service is unchanged by this story — this is an additive feature within each.
Decision¶
We will host the admin trigger + status API on job-service (already JWT-secured and
internet-exposed) and have it signal crawler-service through a control-table row in the
crawler schema, which a new crawler poller claims and executes. This is alternative (C)
for the API host composed with alternative (B) for the crawler signal. crawler-service stays
non-public, JWT-free, and port-free — exactly as the deploy docs promise.
1. Trigger mechanism — control table polled by the crawler¶
- New table
crawler.trigger_request(one row per admin-requested pass):id,kind(crawl|enrichment),status(queued|running|succeeded|failed),requested_by(admin user UUID from the JWT subject),requested_at,started_at,finished_at,result_summary,error_reason, plus alocked_by/lease_expires_atlease pair mirroringcrawler.pull_targetso multiple crawler instances don't double-run a request. It lives in the crawler schema because crawler owns the control-plane lifecycle and reads it with no cross-schema grant;016-crawler-trigger-request.sqlgrantsjob_userSELECT, INSERT(andSELECTfor status reads) — the same least-privilege shape as saved jobs. - job-service (Hexagonal):
POST /jobs/admin/triggersruns the gates (below), then through a new out-port (TriggerRequestRepository→ a Panache adapter writingcrawler.trigger_request) inserts aqueuedrow and returns 202 with the row.GET /jobs/admin/triggers/statusreads back per-kind last-run + the two config flags via the same port. - crawler-service (Hexagonal): a new
adapter/in/scheduler/TriggerRequestScheduler(poll ~10 s) claims the oldestqueuedrow per kind via a new out-port (TriggerRequestQueuewithclaimNext/markRunning/markDone), setsrunning, calls the existingcrawlBatch/enrichPendinguse case, then writessucceeded/failed+result_summary. No inbound HTTP, no JWT, no new domain logic on the crawler.
2. Admin identity — config allowlist → JWT group (no DB change)¶
- New config
auth.admin.emails(CSV, envAUTH_ADMIN_EMAILS, default empty). At token issue,SmallryeJwtTokenGenerator.generate(user)adds"admin"to the existing.groups(Set.of("user"))iff the user's (normalised, case-insensitive) email is in the allowlist. Nothing is persisted;auth.useris untouched. AccountResponsegainsisAdmin(the convenience flag the UI gates on) andgroups(the full group list), both derived from the same rule viaAccountResponseMapper. The admin page shows itself offaccount.isAdmin; the endpoints enforce theadmingroup server-side regardless of the flag.
3. "Reloadable" — pinned, testable semantics¶
Quarkus @ConfigProperty is resolved at startup; the gates are re-read on every request, but
the value is fixed for the life of a running container. So "reload the deployment variables"
means: change the env var in the deploy environment and recreate the container
(compose up -d <svc>), which re-reads env with no image rebuild and no code change — the same
operational pattern the repo already uses for CRAWLER_ENRICHMENT_ENABLED. This is what
acceptance tests assert: with JOBHUB_ADMIN_TRIGGER_ENABLED=false the running job-service returns
403 from POST /jobs/admin/triggers and triggerEnabled:false from the status endpoint;
recreating it with =true flips both. We deliberately do not implement true hot-reload (a
polling ConfigSource): it adds a moving part for a control that flips rarely, and a few-second
container recreate is acceptable for an admin toggle.
Config keys (job-service application.properties, with env mappings for prod):
| Property | Env | Default | Meaning |
|---|---|---|---|
jobhub.admin.trigger.enabled |
JOBHUB_ADMIN_TRIGGER_ENABLED |
true |
Master enable for triggering. |
jobhub.admin.trigger.require-code |
JOBHUB_ADMIN_TRIGGER_REQUIRE_CODE |
false |
Turns on the optional code gate. |
auth.admin.emails (auth-service) |
AUTH_ADMIN_EMAILS |
(empty) | CSV allowlist → admin group. |
crawler.trigger.poll-cron (crawler) |
CRAWLER_TRIGGER_POLL_CRON |
0/10 * * * * ? |
Control-table poll cadence. |
4. Optional code gate — reuse #6, two-phase, service-to-service¶
When jobhub.admin.trigger.require-code=true:
- UI → auth
POST /auth/account/verifications { action: "admin-trigger" }→{ verificationId, expiresAt }; a 6-digit code is emailed (15-min TTL). - UI → job-service
POST /jobs/admin/triggers { kind, verificationId, code }. - job-service → auth
POST /auth/account/verifications/consume { verificationId, code, action: "admin-trigger" }, forwarding the admin's Bearer token. 204 ⇒ record the request and return 202; 400 ⇒ return 422 (Verification Required).
This adds no new auth endpoint and no new code mechanism — only the value admin-trigger
in the VerificationAction enum / verification_code.action CHECK and the two OpenAPI action
enums. job-service gains an outbound quarkus-rest-client + an AuthServiceRestClient
(the pattern application-service already proves). When require-code=false, the body omits
verificationId/code and job-service skips step 3.
5. Contract (frozen; all new/changed ops & properties x-implementation-status: planned)¶
auth-service.yaml (additive, backward-compatible):
- AccountResponse gains isAdmin: boolean (default false) and groups: string[].
- VerificationRequest.action and ConsumeVerificationRequest.action enums gain admin-trigger.
job-service.yaml (new Admin tag; operations land in the single generated JobsApi):
- POST /jobs/admin/triggers — @RolesAllowed("admin"); body TriggerRequestBody
{ kind: crawl|enrichment, verificationId?, code? }; 202 TriggerResponse.
- GET /jobs/admin/triggers/status — @RolesAllowed("admin"); 200
TriggerStatusResponse { triggerEnabled, codeRequired, crawl?, enrichment? : TriggerRunInfo }.
6. Error model (pinned)¶
| Condition | Status | error title |
Notes |
|---|---|---|---|
| No / invalid Bearer token | 401 | (policy) | Standard JWT denial. |
Authenticated, not in admin group |
403 | (policy) | Standard @RolesAllowed denial. |
Triggering disabled (enabled=false) |
403 | Triggering Disabled |
Distinct body from the policy 403 so the UI can message it; status endpoint also reports triggerEnabled:false. |
Malformed body (unknown kind, bad code shape) |
400 | Validation Error |
Bean-validation / enum parse. |
| Code gate on, code missing/invalid/expired/used/wrong-action | 422 | Verification Required |
Maps auth-consume 400 → 422 at the job-service boundary. |
| Auth-side verification throttle | 429 | Too Many Requests |
Propagated from the code flow. |
| Same-kind pass already queued/running | 409 | Trigger In Progress |
Optional dedupe; surface the in-flight run. |
| Unexpected | 500 | Internal Server Error |
GenericExceptionMapper. |
7. Migrations¶
- crawler-dev (#32):
db/init/016-crawler-trigger-request.sql—CREATE TABLE crawler.trigger_request (…)+ achk_trigger_request_kind/…_statusCHECK + an index for the poll ((kind, status, requested_at)partial onstatus='queued') +GRANT SELECT, INSERT ON crawler.trigger_request TO job_user. Crawler range is 010–019; 016 is free. - auth-dev (#31):
db/init/023-auth-admin-trigger-action.sql— widen theverification_code.actionCHECK to includeadmin-trigger(the same shape as022). Auth latest is 022; 023 is next. Otherwise auth-dev is config + JWT-group logic only. - applications range (030) is untouched.
8. Security¶
crawler-service stays internal: no inbound port, no JWT, not in the nginx/Caddy proxy map —
the attack surface of the background crawler is unchanged. The only exposed write path is
job-service's already-authenticated /jobs/ surface, now admin-gated. The allowlist and any
SMTP/mailer secrets are env-only and never committed (consistent with ADR 0002 and the
oci-free credential model). The control row records requested_by for a basic audit trail.
Consequences¶
- Positive: crawler-service keeps its "portless background worker" invariant intact — no JWT
extension, no published port, no proxy entry, no new domain logic (it reuses
crawlBatch/enrichPending). The deploy topology and threat model are unchanged. - Positive: the API host (job-service) already has the JWT stack, the proxy entry, and a cross-schema-write precedent, so the surface is additive and idiomatic — one new table, two new endpoints on an existing resource, two reused config flags.
- Positive: the code gate reuses #6 end-to-end (issue → consume) including the existing service-to-service consume endpoint and the 429 throttle; no second challenge mechanism.
- Negative / cost: "trigger" is asynchronous (poll latency ≈ the poll cron, ~10 s) rather than
a synchronous "run now". The status endpoint +
TriggerRunInfoexist precisely to make this UX honest; this matches the explicit "reliable, not fast" directive. - Negative / cost: job-service gains an outbound REST client to auth-service
(
quarkus-rest-client+AuthServiceRestClient+ token propagation) — new for job-service, though proven in application-service — and therefore a WireMock dependency for its client tests (job-service previously had none). Only needed when the code gate is exercised. - Negative / cost: a new owned table (
crawler.trigger_request) and a new crawler poller to maintain; a lease column set to keep multi-instance safe. - Negative / cost:
isAdmin/groupscorrectness now depends on the allowlist being set in the deploy env; a misconfigured/empty allowlist silently yields no admins (intended fail-closed). - Follow-ups: auth-dev #31 (allowlist config + JWT-group rule +
AccountResponseMapper+ enum + migration 023), crawler-dev #32 (table migration 016 +TriggerRequestQueueport + poller + done/failed bookkeeping), job-dev/frontend #33 (the two endpoints with@RolesAllowed("admin"), theTriggerRequestRepositoryport + cross-schema adapter, the optionalAuthServiceRestClient, and the admin page driven byaccount.isAdmin+ the status endpoint). DevOps wires the new env keys and mounts016(crawler) and023(auth) in compose in numeric order.
Alternatives considered¶
- (A) Inbound admin REST endpoint on crawler-service. Rejected. It forces crawler to add
quarkus-smallrye-jwt+ the JWT public-key plugin, publish a port, and be added to the nginx/Caddy proxy map and the Vite proxy — directly violating the deploy invariant "No JWT, no published port" and widening the attack surface of a service that is currently unreachable from the internet. That is the network-layer version of the "hacker-prone" exposure the product owner rejected for a DB admin flag. Its only upside (synchronous "run now") is not worth the exposure. - (B) hosted on crawler (admin writes the control row directly). Rejected as a host: crawler has no exposed write path at all, so the admin's insert still has to happen on an exposed, JWT-verifying service — which is job-service. (B) is the right crawler signal; it does not by itself answer where the admin API lives. Our decision keeps (B)'s table and adopts (C)'s host.
- (C) on auth-service instead of job-service. Viable — auth is exposed and JWT-aware — but
auth is the Clean domain service for identity; a crawl/enrichment trigger is not an identity
concern and would couple auth to the
crawlerschema. job-service already reads/writescrawlerand already serves job-domain reads, so it is the better-fitting host. - Synchronous trigger via a direct job-service → crawler internal HTTP call. Rejected: still requires crawler to expose a port + verify a token (or trust an unauthenticated internal call), reintroducing (A)'s exposure for the sake of removing a ~10 s poll delay.
- True hot-reload of the toggle (polling
ConfigSource, no restart). Rejected for now: extra machinery for a rarely-flipped admin control; env-change + container-recreate is simpler and testable. Recorded as the fallback if operators later need zero-restart toggling. - New admin-flag column on
auth.user. Rejected by product (hacker-prone) and unnecessary — the allowlist-at-token-issue rule needs no schema and no migration onauth.user. - A brand-new api-contracts spec / module for the admin surface. Rejected: the admin API lives
on job-service, so it belongs in
job-service.yaml; no new generator execution or module pom is warranted. (auth changes likewise stay inauth-service.yaml.)