Skip to content
Open
104 changes: 104 additions & 0 deletions docs/_static/vcr/vcr_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,72 @@ paths:
$ref: '#/components/schemas/SearchVCResults'
default:
$ref: '../common/error_response.yaml'
/internal/vcr/v2/holder/expiring:
get:
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.

`expirationDate` are never returned, since they do not expire.

Operators can use this endpoint to monitor all wallets on the node and refresh credentials before
they expire.

error returns:
* 400 - Invalid value for the `within` parameter
* 500 - An error occurred while processing the request
operationId: getExpiringCredentialsInWallet
tags:
- credential
parameters:
- name: within
in: query
description: |
Time window relative to now in which a credential's `expirationDate` falls for it to be considered
expiring. Accepts a Go duration string: a number followed by a unit, e.g. `720h` (30 days),
`24h` (1 day) or `30m` (30 minutes). The largest supported unit is the hour (`h`); there is no
day or week unit. Must be non-negative. Defaults to 720h (30 days). Use `0s` to return only
already-expired credentials.
required: false
schema:
type: string
default: "720h"
example: "24h"
- name: excludeTypes
in: query
description: |
Credential type(s) to exclude from the result. A credential is excluded if any of its
types matches any of the supplied values. Useful for suppressing credentials that are
expected to expire and are kept for audit purposes (e.g. `NutsAuthorizationCredential`).
Repeat the parameter to exclude multiple types.
required: false
schema:
type: array
items:
type: string
example: ["NutsAuthorizationCredential"]
responses:
"200":
description: |
Map of subject ID to the list of expired or about-to-expire credentials held by that subject.
Only subjects with at least one such credential are included.
content:
application/json:
schema:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/ExpiringCredential"
example:
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"
default:
$ref: '../common/error_response.yaml'
components:
schemas:
VerifiableCredential:
Expand All @@ -616,6 +682,44 @@ components:
Revocation:
$ref: '../common/ssi_types.yaml#/components/schemas/Revocation'

ExpiringCredential:
type: object
description: |
Summary of a Verifiable Credential in a wallet on this node that is expired or about to expire.
Contains only the fields needed for monitoring; the full credential can be retrieved via the
wallet search endpoints using the `id`.
required:
- id
- holder
- issuer
- type
- expirationDate
properties:
id:
description: ID of the credential (the `id` property of the Verifiable Credential).
type: string
example: "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5"
holder:
description: DID of the wallet holding the credential.
type: string
example: "did:web:example.com:iam:123"
issuer:
description: DID of the credential's issuer.
type: string
example: "did:web:issuer.example.com"
type:
description: |
Credential type(s), excluding the generic `VerifiableCredential` type.
type: array
items:
type: string
example: ["NutsOrganizationCredential"]
expirationDate:
description: RFC3339 time at which the credential expires.
type: string
format: date-time
example: "2026-05-15T12:00:00Z"

IssueVCRequest:
type: object
description: A request for issuing a new Verifiable Credential.
Expand Down
36 changes: 36 additions & 0 deletions docs/pages/deployment/monitoring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,42 @@ Explanation of ambiguous/complex entries in the diagnostics:

Note: the ``network`` and ``vdr`` entries only apply to ``did:nuts``.

Expiring wallet credentials
***************************

Credentials held in the node's wallets generally need to be renewed before they expire, otherwise they can no longer be used.
The following endpoint 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:

.. code-block:: text

GET /internal/vcr/v2/holder/expiring

It returns a JSON object keyed by subject ID, where each value is the list of that subject's expiring credentials:

.. code-block:: json

{
"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"
}
]
}

Subjects without any expiring credentials are omitted. Credentials without an ``expirationDate`` never expire and are never returned.

Query parameters:

* ``within`` - time window (relative to now) in which a credential's ``expirationDate`` must fall to be considered expiring. A Go duration string such as ``720h`` (30 days), ``24h`` (1 day) or ``30m`` (30 minutes); the largest unit is the hour. Defaults to ``720h`` (30 days). Use ``0s`` to return only credentials that have already expired.
* ``excludeTypes`` - credential type(s) to exclude from the result, repeat the parameter to exclude multiple. Useful for suppressing credentials that are expected to expire and are kept for audit purposes (e.g. ``NutsAuthorizationCredential``).

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

Metrics
*******

Expand Down
10 changes: 10 additions & 0 deletions storage/sql_migrations/011_credential_expiration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- +goose Up
-- expiration_date is the credential's expirationDate as seconds since Unix epoch. Credentials without
-- an expirationDate get a far-future sentinel (9999-12-31) rather than null; null marks an existing
-- row not yet backfilled. The application backfills existing rows after migration.
alter table credential add expiration_date integer null;
create index idx_credential_expiration_date on credential (expiration_date);

-- +goose Down
drop index idx_credential_expiration_date;
alter table credential drop column expiration_date;
90 changes: 89 additions & 1 deletion vcr/api/vcr/v2/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ var clockFn = func() time.Time {
return time.Now()
}

// defaultExpiringWithin is the default time window used by GetExpiringCredentialsInWallet
// when the `within` query parameter is not supplied.
const defaultExpiringWithin = 30 * 24 * time.Hour

var _ StrictServerInterface = (*Wrapper)(nil)

// Wrapper implements the generated interface from oapi-codegen
Expand Down Expand Up @@ -475,7 +479,7 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC

var allCreds []vc.VerifiableCredential
for _, did := range dids {
creds, err := w.VCR.Wallet().SearchCredential(ctx, did)
creds, err := w.VCR.Wallet().Search(ctx, holder.HolderDID(did))
if err != nil {
return nil, err
}
Expand All @@ -489,6 +493,90 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC
return SearchCredentialsInWallet200JSONResponse(SearchVCResults{VerifiableCredentials: searchResults}), nil
}

// GetExpiringCredentialsInWallet returns credentials across all wallets on this node that have an
// expirationDate at or before now + within, grouped by subject ID. Already-expired credentials are
// included. Credentials without an expirationDate are never returned because they don't expire.
// Credentials whose type matches any of the excludeTypes values are omitted. Subjects without any
// expiring credentials are omitted from the result.
func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request GetExpiringCredentialsInWalletRequestObject) (GetExpiringCredentialsInWalletResponseObject, error) {
within := defaultExpiringWithin
if request.Params.Within != nil {
parsed, err := time.ParseDuration(*request.Params.Within)
if err != nil {
return nil, core.InvalidInputError("invalid value for within: %w", err)
}
if parsed < 0 {
return nil, core.InvalidInputError("within must not be negative")
}
within = parsed
}
var excludeTypes []string
if request.Params.ExcludeTypes != nil {
excludeTypes = *request.Params.ExcludeTypes
}

subjects, err := w.SubjectManager.List(ctx)
if err != nil {
return nil, err
}
// Invert the subject → DIDs map for grouping results back into subjects.
holderToSubject := make(map[string]string)
for subjectID, dids := range subjects {
for _, d := range dids {
holderToSubject[d.String()] = subjectID
}
}

// Single cross-wallet query: SQL filters on expiration_date and type, no per-subject loop.
creds, err := w.VCR.Wallet().Search(ctx,
holder.ExpiresAt(clockFn().Add(within)),
holder.ExcludeCredentialTypes(excludeTypes...),
)
if err != nil {
return nil, err
}

result := make(map[string][]ExpiringCredential)
for _, cred := range creds {
holderDID, err := cred.SubjectDID()
if err != nil {
// Wallet storage requires a subject DID; this shouldn't happen for stored credentials.
continue
}
subjectID, ok := holderToSubject[holderDID.String()]
if !ok {
// Holder belongs to a deactivated/removed subject — normal post-deactivation state
// since SubjectManager.Deactivate doesn't cascade to the wallet.
continue
}
result[subjectID] = append(result[subjectID], toExpiringCredential(cred, *holderDID))
}

return GetExpiringCredentialsInWallet200JSONResponse(result), nil
}

// toExpiringCredential builds a monitoring-friendly summary of a credential. The Wallet stores
// credentials per holder DID, so the holder is supplied by the caller rather than re-derived.
func toExpiringCredential(cred vc.VerifiableCredential, holder did.DID) ExpiringCredential {
types := make([]string, 0, len(cred.Type))
for _, t := range cred.Type {
if s := t.String(); s != "VerifiableCredential" {
types = append(types, s)
}
}
var id string
if cred.ID != nil {
id = cred.ID.String()
}
return ExpiringCredential{
Id: id,
Holder: holder.String(),
Issuer: cred.Issuer.String(),
Type: types,
ExpirationDate: *cred.ExpirationDate,
}
}

func (w *Wrapper) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) {
// get DIDs for holder
dids, err := w.SubjectManager.ListDIDs(ctx, request.SubjectID)
Expand Down
Loading
Loading