Skip to content

Add endpoint to list (about-to-)expired wallet credentials#4224

Open
reinkrul wants to merge 9 commits into
masterfrom
4217-expiring-credentials-endpoint
Open

Add endpoint to list (about-to-)expired wallet credentials#4224
reinkrul wants to merge 9 commits into
masterfrom
4217-expiring-credentials-endpoint

Conversation

@reinkrul
Copy link
Copy Markdown
Member

@reinkrul reinkrul commented Apr 30, 2026

Closes #4217.

Summary

Adds GET /internal/vcr/v2/holder/expiring — lists credentials across all wallets on the node that are expired or about to expire, so a monitoring system can alert operators to renew them in time. Returns a JSON object keyed by subject ID with the list of expiring credentials per subject; subjects with none are omitted.

Query parameters

  • within — time window (relative to now) for a credential's expirationDate to count as expiring. Go duration string (largest unit is the hour, e.g. 720h, 24h, 30m). Defaults to 720h (30 days). Non-negative; 0s returns only already-expired credentials. Invalid/negative values give 400.
  • excludeTypes — credential type(s) to exclude (repeatable). Suppresses credentials that are expected to expire but are kept for audit purposes (e.g. NutsAuthorizationCredential).

Already-expired credentials are included by default so they stay visible until cleaned up.

Response uses a focused monitoring DTO (id, holder, issuer, type, expirationDate) rather than the raw VC, so the shape is uniform regardless of JSON-LD vs JWT encoding. The full VC can be fetched by id via the existing wallet endpoints.

{
  "90BC1AE9-752B-432F-ADC3-DD9F9C61843C": [
    {
      "id":             "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5",
      "holder":         "did:web:example.com:iam:123",
      "issuer":         "did:web:issuer.example.com",
      "type":           ["NutsOrganizationCredential"],
      "expirationDate": "2026-05-15T12:00:00Z"
    }
  ]
}

Filtering is pushed down to SQL

Both within and excludeTypes are applied in the SQL store rather than in Go, so a call no longer loads and JSON-parses every credential of every subject on the node.

  • New expiration_date column on credential (seconds since epoch, NULL = never expires) + index. Migration 011_credential_expiration.sql. Store() populates it for new credentials.
  • BackfillExpirationDates runs on node start to populate the column for credentials stored before it existed. It parses every row whose expiration_date is still NULL (a substring pre-filter can't work: JWT-encoded credentials keep their expiry in a base64 exp claim, so it'd silently skip them). Paginates by id as a keyset cursor, batched, logged-and-continued on error. Tagged for removal in v7. Trade-off: never-expiring credentials are re-scanned on every boot — see the open discussion below.
  • Wallet interface: SearchCredential(holderDID)Search(opts ...SearchOption) with functional options HolderDID, ExpiresAt, ExcludeCredentialTypes (combined with AND); the expiring endpoint queries cross-wallet with no HolderDID.

Docs

  • OpenAPI spec for the new endpoint and ExpiringCredential schema.
  • Monitoring documentation section (docs/pages/deployment/monitoring.rst).

Test plan

  • Handler: grouping by subject, omitting empty subjects, within/excludeTypes pass-through, orphaned holder skipped, empty map, invalid/negative within, subject manager error
  • SQL store: Store() populates/leaves NULL expiration_date; BackfillExpirationDates backfills, leaves no-expiry rows untouched, idempotent
  • Wallet Search: no options, HolderDID, ExpiresAt, ExcludeCredentialTypes, combined options
  • go build ./...
  • go test ./vcr/...

Assisted by AI

Adds GET /internal/vcr/v2/holder/expiring which aggregates credentials
across all wallets on the node and returns a JSON object grouping
expiring credentials by subject ID. Operators can poll a single URL to
monitor and refresh credentials before they expire (closes #4217).

The response is a focused monitoring DTO (id, holder, issuer, type,
expirationDate) rather than the raw VC, so the shape stays uniform
regardless of whether the underlying credential is JSON-LD or JWT-encoded.

Assisted by AI
@qltysh
Copy link
Copy Markdown
Contributor

qltysh Bot commented Apr 30, 2026

5 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 8): NewGetExpiringCredentialsInWalletRequest 3
ripgrep Lint // TODO: remove together with BackfillExpirationDates in v7. v7+ stores can map "no expirationDate" 2

@reinkrul
Copy link
Copy Markdown
Member Author

reinkrul commented Apr 30, 2026

TODO / open question — filtering by credential type:

Some credential types are expected to expire and shouldn't trigger refresh alerts, but operators still want to keep them in the wallet for audit / paper-trail purposes (e.g. NutsAuthorizationCredential). For those, deleting is not an option.

We may want to add type-based filtering to this endpoint, e.g. ?excludeType=NutsAuthorizationCredential (and/or ?includeType=...), so monitoring tools can suppress credentials that are expected to expire.

Assisted by AI

@qltysh
Copy link
Copy Markdown
Contributor

qltysh Bot commented Apr 30, 2026

Qlty


Coverage Impact

⬇️ Merging this pull request will decrease total coverage on master by 0.05%.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: B Coverage rating: B
vcr/holder/sql_wallet.go93.9%64-65
Coverage rating: A Coverage rating: A
vcr/api/vcr/v2/api.go93.5%536-537, 543-544
Coverage rating: A Coverage rating: B
vcr/credential/store/sql.go73.5%178-179, 187-193...
Coverage rating: F Coverage rating: F
vcr/holder/memory_wallet.go0.0%83-114
New Coverage rating: B
vcr/holder/wallet_search.go87.5%48-49
Total76.4%
🤖 Increase coverage with AI coding...
In the `4217-expiring-credentials-endpoint` branch, add test coverage for this new code:

- `vcr/api/vcr/v2/api.go` -- Lines 536-537 and 543-544
- `vcr/credential/store/sql.go` -- Lines 178-179, 187-193, 202-203, and 208-209
- `vcr/holder/memory_wallet.go` -- Line 83-114
- `vcr/holder/sql_wallet.go` -- Line 64-65
- `vcr/holder/wallet_search.go` -- Line 48-49

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Copy link
Copy Markdown
Member

@stevenvegt stevenvegt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't look into to the implementation yet, first lets discuss the API and how people might want to use this feature.

Comment thread docs/_static/vcr/vcr_v2.yaml Outdated
summary: List credentials across all wallets on this node that are expired or about to expire.
description: |
Returns all credentials held by any subject on this node whose `expirationDate` is at or before
`now + within`. This includes credentials that are already expired. Credentials without an
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since eOverdracht (and others) uses NutsAuthVCs, this list will quickly become longer and longer. I think you might want to add some additional filtering here to include or exclude certain types. Also a param to ignore already expired VCs might be a good idea since you probably want to signal for upcoming expiration?

Copy link
Copy Markdown
Member Author

@reinkrul reinkrul May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since eOverdracht (and others) uses NutsAuthVCs, this list will quickly become longer and longer. I think you might want to add some additional filtering here to include or exclude certain types.

That's right, we need something like that. I'm leaning towards an exclude model, because an include model quickly becomes out of date if a new credential is introduced. So you'd have something like:

/expiring?within=30d&exclude=NutsAuthorizationCredential

The downside is that you have to explicitly (in many cases, always) exclude certain types, but at least it'll be visible for operators if the configuration is off.

Also a param to ignore already expired VCs might be a good idea since you probably want to signal for upcoming expiration?

Maybe... You don't want to keep being bothered if you don't clean up expired VCs, on the other hand you could've missed/ignored (and forgot) about renewing it. We could make it more flexible (at the cost of a more complex API), by adding a parameter which specifies for how long we'll keep returning it, after it expired. E.g., return VCs that expired less than a week ago.

Now I think of it, you also don't want to send e-mails (if your monitoring system does that) every day for the same VC, 30 days straight (if you're checking for VCs that expire within 30 days, every day). Not sure if we should solve that here, but it complicates things.

Proposal: keep this feature simple at first;

  • Add excludeTypes parameter
  • Let the monitoring system deal with not sending too many notifications for the same VC every hour/day (we're not building a monitoring system here, just feeding it with data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it, lets include expired credentials, since those should be visible so vendors can clean them up.

@reinkrul
Copy link
Copy Markdown
Member Author

We also need to add this to the official monitoring documentation.

@reinkrul
Copy link
Copy Markdown
Member Author

We'll be adding exclude_types as parameter

reinkrul added 2 commits May 22, 2026 12:18
Allows operators to suppress credentials that are expected to expire
and are kept for audit purposes (e.g. NutsAuthorizationCredential).
A credential is excluded if any of its types matches any supplied value.

Assisted by AI
@reinkrul
Copy link
Copy Markdown
Member Author

reinkrul commented May 22, 2026

TODO: push filtering down to the SQL store

Neither within nor excludeTypes is currently pushed to the SQL layer. GetExpiringCredentialsInWallet calls Wallet().SearchCredential(ctx, holderDID), which runs SELECT ... WHERE holder_did = ? and parses every raw VC blob for that holder; within and excludeTypes are then applied in the Go loop (api.go).

For larger wallets this means a full load + JSON parse of every credential of every subject on the node on every call. Both filters should be propagated all the way down to the SQL store before this merges.

What that needs:

  • excludeTypes: straightforward — credential.type is a queryable column. It stores only the first non-VerifiableCredential type, but since the node doesn't support credentials with multiple types, that column fully represents the credential's type. A WHERE type NOT IN (...) works directly.
  • within: no queryable column exists; expirationDate is a top-level VC property and isn't indexed (credential_prop only covers credentialSubject.* paths). Needs a new expiration_date column + migration.
  • A new walletStore query method taking the filter args, instead of the current list(holderDID).

Assisted by AI

@stevenvegt stevenvegt marked this pull request as draft May 22, 2026 14:43
@stevenvegt
Copy link
Copy Markdown
Member

Given the previous comment, I think this PR needs some more work? I've converted it to state "draft".

reinkrul added 4 commits May 27, 2026 08:41
Replaces Wallet.SearchCredential with Wallet.Search(opts...) that filters
on holder_did, type, and a new expiration_date column directly in SQL.
GetExpiringCredentialsInWallet now issues one cross-wallet query instead
of looping per holder DID and filtering in Go.

- New credential.expiration_date column (epoch seconds) + index.
- CredentialRecord.ExpirationDate populated by Store(); idempotent Go-side
  backfill on startup for rows stored before the column existed (TODO:
  remove in v7).
- Wallet.Search options: HolderDID, ExcludeCredentialTypes, ExpiresAt.
- Handler groups results back into subjects via an inverse holderDID →
  subjectID map; credentials whose holder no longer maps to a subject
  (post-deactivation) are silently skipped.

Assisted by AI
- Spell out accepted duration units on `within` and use a non-default example.
- Add monitoring documentation for the expiring wallet credentials endpoint.

Assisted by AI
The substring pre-filter (raw LIKE '%expirationDate%') skipped JWT VCs,
whose expiry lives in a base64 `exp` claim that never appears as literal
text. Parse every NULL-expiration row instead, paginating by id as a
keyset cursor so never-expiring rows (which stay NULL) don't stall or
short-circuit the walk.

Assisted by AI
@reinkrul
Copy link
Copy Markdown
Member Author

reinkrul commented Jun 1, 2026

@stevenvegt — design question on the expiration_date backfill, would like your take.

BackfillExpirationDates currently runs synchronously on every node start and scans the entire credential table (it's shared across stores, not just wallet rows). Credentials that genuinely never expire keep expiration_date = NULL, so they get re-SELECTed and re-parsed on every boot, forever (until we remove the backfill in v7). For a node with a large credential table and many non-expiring credentials, that's recurring startup latency + CPU.

Background: we landed on this after dropping an earlier raw LIKE '%expirationDate%' pre-filter, which silently skipped JWT-encoded credentials (their expiry lives in a base64 exp claim, so the substring never appears). Parsing every NULL row is correct for both JSON-LD and JWT, but it's the trade for that recurring scan.

Given the general boot-time issues we've had lately, do you think we should reconsider? Two options, each with a real cost:

  • One-shot completion marker — run the full scan exactly once per node, then skip it. Correct and cheap at runtime, but it adds complexity: a new table (or migration) to persist "backfill done" plus the surrounding code.
  • Run the backfill asynchronously so it doesn't block startup. Smaller change, but the column is briefly incomplete after boot (the expiring endpoint under-reports until the scan finishes), and a restart mid-scan just re-runs it.

Or we keep it as-is and accept the recurring scan, betting credential tables stay small enough. Happy to implement whichever you prefer.

Assisted by AI

`ALTER TABLE ... ADD COLUMN` is rejected by MSSQL/Azure SQL ("Incorrect
syntax near the keyword 'column'"), failing node startup on those
backends. Drop the COLUMN keyword to match migrations 009/010, which is
valid across SQLite, Postgres, MySQL and SQL Server.

Assisted by AI
@reinkrul reinkrul requested a review from stevenvegt June 1, 2026 08:09
@reinkrul reinkrul marked this pull request as ready for review June 1, 2026 08:09
@stevenvegt
Copy link
Copy Markdown
Member

Or use a "9999-12-31" sentinel to indicate the migration has been run, but the credential is valid until the end of times. Delaying the problem to future generations.

…rationDate

Store() and the backfill now write a far-future sentinel (9999-12-31) for
credentials without an expirationDate, so NULL means only "not yet
backfilled". The backfill therefore converges to a one-shot operation
instead of re-scanning every never-expiring credential on each startup,
which avoids the recurring boot-time cost. The expiring query drops its
IS NOT NULL guard since <= excludes both the sentinel and legacy NULLs.

Assisted by AI
@reinkrul
Copy link
Copy Markdown
Member Author

reinkrul commented Jun 1, 2026

@stevenvegt good call — went with your idea. Implemented in 1121a93.

What it does:

  • Store() and the backfill write the far-future marker value 9999-12-31 (253402300799) for credentials with no expirationDate, instead of NULL. So NULL now means only "row predates the column, not yet backfilled."
  • The backfill therefore converges to one-shot: every row it touches becomes non-NULL (real date or the marker) and drops out of the WHERE expiration_date IS NULL set, so subsequent startups don't re-scan never-expiring credentials. That kills the recurring boot-time cost.
  • Picked a far-future value on purpose: the expiring query is expiration_date <= now+within, so 9999-12-31 never falls in-window and a never-expiring credential is correctly excluded with no query special-casing. (A negative/zero marker would read as already expired, so it has to be far future.) Dropped the IS NOT NULL guard since <= already excludes both the marker and any legacy NULL.

On your two flavours: I went with the readable 9999-12-31 over math.MaxInt64. Tiny theoretical downside — a credential genuinely expiring in the year 9999 would be indistinguishable from "never" — which I'll take over a magic out-of-range int. Left a TODO on the constant: in v7, drop the backfill and (optionally) a migration to NULL those out if we prefer clean NULL semantics there.

"Delaying it to future generations" — yep, that's the trade, and the v7 cleanup is flagged. Let me know if you'd rather use MaxInt64.

Assisted by AI

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.

Add endpoint for detecting (about to) expired credentials

2 participants