Skip to content

feat: offline delegation for the Scheduler - #1815

Draft
DmytroZaichenkoDev wants to merge 21 commits into
developmentfrom
feat/offline-delegation
Draft

feat: offline delegation for the Scheduler#1815
DmytroZaichenkoDev wants to merge 21 commits into
developmentfrom
feat/offline-delegation

Conversation

@DmytroZaichenkoDev

@DmytroZaichenkoDev DmytroZaichenkoDev commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Offline delegation for the Scheduler

Closes #1814

Lets a headless application act as a user who is not present — a scheduled run calling DIAL as
the person who set it up, days later — without that user logging in per application.

Two decisions, made by two people. The user enables offline credentials once, platform-wide:
an ordinary authorization-code flow with offline_access, stored in their own bucket. A DIAL
admin
approves a specific (application, service) pair. Neither works alone — credentials with no
consent reach no application, consent with no credentials has no identity to act with.

The admin's approval reaches every user who has enabled offline access, not only users who
opted into that application. That is a deliberate trade — it is what keeps the feature deployable
without prompting every user about every app — and it is the thing most worth arguing with in
review.

What is here

GET/POST /v1/user/offline-credentials, /signin, /signout the user's own credentials; status doubles as "how to connect"
POST/DELETE /v1/applications/{appId}/external-services/{id}/consent admin-only approval
DIAL_NATIVE authentication type registered in the validator and refresh-strategy dispatchers
redemption branch on /obo-credentials checks consent, refreshes only if expired, returns the access token
audit events sign-in, sign-out, grant, withdraw and every redemption — failures included
status contract app level means approved; user level means the caller's platform-wide credentials

Notable decisions a reviewer should push on:

  • The credential returned is the IdP's own access token, not a per-request key. A PRK would give
    attribution and a core-controlled expiry, at the cost of grant machinery and client changes.
    Deferred, not dismissed.
  • PKCE is deferred. In its place, sign-in verifies the exchange's ID token names the caller and
    fails closed when there is no ID token — otherwise nothing refuses code injection.
  • The offline storage id is reserved and sits outside the applications/…/external_services/…
    shape the app-facing credential endpoints can address, so no application can reach it. Pinned by
    CredentialsPathReservationTest.
  • Consent is checked before anything user-specific, so an unapproved application cannot learn
    whether a given user has credentials.

🤖 Generated with Claude Code

Dmytro Zaichenko and others added 7 commits August 10, 2026 10:48
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
User level now reflects the caller's platform-wide offline credentials
instead of per-service records this type never writes. App level already
meant approved. Also annotates the new endpoints for the OpenAPI spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ai-dial-actions

This comment has been minimized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ai-dial-actions

This comment has been minimized.

…d paths

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ai-dial-actions

This comment has been minimized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ai-dial-actions

This comment has been minimized.

Dmytro Zaichenko and others added 2 commits August 10, 2026 22:37
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ai-dial-actions

This comment has been minimized.

@astsiapanay

astsiapanay commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Let’s split the task into smaller pieces.

@DmytroZaichenkoDev

Dmytro Zaichenko and others added 8 commits August 11, 2026 12:05
Collapse the stray double blank lines, move the newAppKey helper below the
tests that use it, and restore the trailing newlines and the one-line
userRoles array the config fixtures lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- an opaque access token no longer surfaces as a 500: with several providers
  configured there is no issuer to match on, so refuse with 503
- a request carrying no bearer token is a bad request, not an NPE
- a single configured provider no longer ignores the recorded issuer. When it
  has an issuerPattern that disclaims that issuer, the record was minted by a
  provider that is gone, and refreshing it against this one would use the
  wrong client
- provider resolution failures during redemption map to 503 rather than 400,
  which blamed the caller for server-side state
- the status enricher returns early for a userless caller instead of guarding
  downstream of code that already fails on a null user id, which also removes
  an intermittent 500 on the external-services listing for an admin API key

Shares asRuntime through ExternalServiceErrors rather than duplicating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The enricher's early return for a null user id skipped the base status
computation entirely, dropping app-level statuses the raw resource GET
previously reported for API-key callers. Only the offline-credentials
lookup needs a user: a userless caller holds none by definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The consent record lives at APPLICATION level under the same scope the
generic sign-out addresses, and sign-out admits app owners with write
access — so an owner could withdraw an administrator's consent, with no
consent audit event. There is nothing legitimate to sign out of at any
level: withdrawal belongs to the audited, admin-only consent endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
putCredentialsRecord now sets createdAt/updatedAt from the service's
TimeProvider, replacing the controller's direct System.currentTimeMillis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"connected": false alone could not tell "not connected yet" from
"cannot connect": a provider with no offline client left chat offering
a flow that could only end in an error, and an unresolvable provider
turned the status GET into a 503. The status now carries "available",
and every way the feature can be unusable collapses into false — the
loud refusal stays on sign-in, where it matters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Includes the Entra nuance: issuerPattern must also match the ID token's
issuer, which differs from the v1 access-token issuer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ai-dial-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Offline delegation: let approved applications act on behalf of an absent user

3 participants