From 33ca58f757fdfda0f1a69e28a46e526ee8d922b5 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 30 Apr 2026 13:21:56 +0200 Subject: [PATCH 1/7] Add endpoint to list (about-to-)expired credentials 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 --- docs/_static/vcr/vcr_v2.yaml | 89 ++++++++++++ vcr/api/vcr/v2/api.go | 75 ++++++++++ vcr/api/vcr/v2/api_test.go | 160 +++++++++++++++++++++ vcr/api/vcr/v2/generated.go | 269 +++++++++++++++++++++++++++++++++++ 4 files changed, 593 insertions(+) diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index ff65540466..ee0dcd1ca3 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -605,6 +605,57 @@ 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 + `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 (e.g. `24h`, `720h`, `30m`). 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: "720h" + 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: @@ -616,6 +667,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. diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 8d7c21b128..4cae66e072 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -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 @@ -489,6 +493,77 @@ 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. +// 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 + } + + subjects, err := w.SubjectManager.List(ctx) + if err != nil { + return nil, err + } + + threshold := clockFn().Add(within) + result := make(map[string][]ExpiringCredential) + for subjectID, dids := range subjects { + var expiring []ExpiringCredential + for _, holderDID := range dids { + creds, err := w.VCR.Wallet().SearchCredential(ctx, holderDID) + if err != nil { + return nil, err + } + for _, cred := range creds { + if cred.ExpirationDate == nil || cred.ExpirationDate.IsZero() { + continue + } + if cred.ExpirationDate.After(threshold) { + continue + } + expiring = append(expiring, toExpiringCredential(cred, holderDID)) + } + } + if len(expiring) > 0 { + result[subjectID] = expiring + } + } + + 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) diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index e0d8d22776..ef1d88ee8e 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -891,6 +891,166 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { }) } +func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { + now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) + originalClock := clockFn + clockFn = func() time.Time { return now } + t.Cleanup(func() { clockFn = originalClock }) + + issuerURI := ssi.MustParseURI("did:web:issuer.example.com") + otherHolderDID := did.MustParseDID("did:web:example.com:iam:other") + makeVC := func(idSuffix string, holder did.DID, exp *time.Time) vc.VerifiableCredential { + id := ssi.MustParseURI("did:web:issuer.example.com#" + idSuffix) + return vc.VerifiableCredential{ + Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsOrganizationCredential")}, + ID: &id, + Issuer: issuerURI, + CredentialSubject: []map[string]any{{"id": holder.String()}}, + ExpirationDate: exp, + } + } + expired := now.Add(-24 * time.Hour) + soon := now.Add(10 * 24 * time.Hour) + farFuture := now.Add(365 * 24 * time.Hour) + expiredVC := makeVC("expired", holderDID, &expired) + soonVC := makeVC("soon", holderDID, &soon) + farVC := makeVC("far", holderDID, &farFuture) + noExpVC := makeVC("noexp", holderDID, nil) + otherSubjectExpiredVC := makeVC("other-expired", otherHolderDID, &expired) + + expectedExpiredEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#expired", + Holder: holderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: expired, + } + expectedSoonEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#soon", + Holder: holderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: soon, + } + expectedFarEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#far", + Holder: holderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: farFuture, + } + expectedOtherEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#other-expired", + Holder: otherHolderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: expired, + } + + t.Run("ok - groups expiring credentials by subject; subjects with none are omitted", func(t *testing.T) { + testContext := newMockContext(t) + emptySubjectDID := did.MustParseDID("did:web:example.com:iam:empty") + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + "holder-b": {otherHolderDID}, + "holder-c": {emptySubjectDID}, + }, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). + Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, otherHolderDID). + Return([]vc.VerifiableCredential{otherSubjectExpiredVC}, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, emptySubjectDID). + Return([]vc.VerifiableCredential{farVC, noExpVC}, nil) + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry, expectedSoonEntry}, + "holder-b": {expectedOtherEntry}, + }, response) + }) + + t.Run("ok - custom within=8760h (1y) also returns far", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + }, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). + Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil) + within := "8760h" + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry, expectedSoonEntry, expectedFarEntry}, + }, response) + }) + + t.Run("ok - within=0 returns only already-expired", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + }, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). + Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil) + within := "0s" + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry}, + }, response) + }) + + t.Run("ok - no subjects returns empty map", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{}, nil) + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{}, response) + }) + + t.Run("error - invalid within", func(t *testing.T) { + testContext := newMockContext(t) + within := "not-a-duration" + + _, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.ErrorContains(t, err, "invalid value for within") + }) + + t.Run("error - negative within", func(t *testing.T) { + testContext := newMockContext(t) + within := "-1h" + + _, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.ErrorContains(t, err, "within must not be negative") + }) + + t.Run("error - subject manager fails", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(nil, assert.AnError) + + _, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.ErrorIs(t, err, assert.AnError) + }) +} + func TestWrapper_RemoveCredentialFromSubjectWallet(t *testing.T) { didNuts := did.MustParseDID("did:nuts:123") didWeb := did.MustParseDID("did:web:example.com") diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 23e163a6a2..1b18585dab 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/labstack/echo/v4" "github.com/oapi-codegen/runtime" @@ -99,6 +100,26 @@ type CredentialIssuer struct { Issuer string `json:"issuer"` } +// ExpiringCredential 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`. +type ExpiringCredential struct { + // ExpirationDate RFC3339 time at which the credential expires. + ExpirationDate time.Time `json:"expirationDate"` + + // Holder DID of the wallet holding the credential. + Holder string `json:"holder"` + + // Id ID of the credential (the `id` property of the Verifiable Credential). + Id string `json:"id"` + + // Issuer DID of the credential's issuer. + Issuer string `json:"issuer"` + + // Type Credential type(s), excluding the generic `VerifiableCredential` type. + Type []string `json:"type"` +} + // IssueVCRequest A request for issuing a new Verifiable Credential. type IssueVCRequest struct { // Context The resolvable context of the credentialSubject as URI. If omitted, the "https://nuts.nl/credentials/v1" context is used. @@ -244,6 +265,14 @@ type VPVerificationResult struct { Validity bool `json:"validity"` } +// GetExpiringCredentialsInWalletParams defines parameters for GetExpiringCredentialsInWallet. +type GetExpiringCredentialsInWalletParams struct { + // Within Time window relative to now in which a credential's `expirationDate` falls for it to be considered + // expiring. Accepts a Go duration string (e.g. `24h`, `720h`, `30m`). Must be non-negative. + // Defaults to 720h (30 days). Use `0s` to return only already-expired credentials. + Within *string `form:"within,omitempty" json:"within,omitempty"` +} + // SearchIssuedVCsParams defines parameters for SearchIssuedVCs. type SearchIssuedVCsParams struct { // CredentialType The type of the credential @@ -477,6 +506,9 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // GetExpiringCredentialsInWallet request + GetExpiringCredentialsInWallet(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateVPWithBody request with any body CreateVPWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -542,6 +574,18 @@ type ClientInterface interface { ListUntrusted(ctx context.Context, credentialType string, reqEditors ...RequestEditorFn) (*http.Response, error) } +func (c *Client) GetExpiringCredentialsInWallet(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetExpiringCredentialsInWalletRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) CreateVPWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateVPRequestWithBody(c.Server, contentType, body) if err != nil { @@ -830,6 +874,55 @@ func (c *Client) ListUntrusted(ctx context.Context, credentialType string, reqEd return c.Client.Do(req) } +// NewGetExpiringCredentialsInWalletRequest generates requests for GetExpiringCredentialsInWallet +func NewGetExpiringCredentialsInWalletRequest(server string, params *GetExpiringCredentialsInWalletParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/vcr/v2/holder/expiring") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Within != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "within", runtime.ParamLocationQuery, *params.Within); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewCreateVPRequest calls the generic CreateVP builder with application/json body func NewCreateVPRequest(server string, body CreateVPJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -1503,6 +1596,9 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // GetExpiringCredentialsInWalletWithResponse request + GetExpiringCredentialsInWalletWithResponse(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*GetExpiringCredentialsInWalletResponse, error) + // CreateVPWithBodyWithResponse request with any body CreateVPWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVPResponse, error) @@ -1568,6 +1664,38 @@ type ClientWithResponsesInterface interface { ListUntrustedWithResponse(ctx context.Context, credentialType string, reqEditors ...RequestEditorFn) (*ListUntrustedResponse, error) } +type GetExpiringCredentialsInWalletResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *map[string][]ExpiringCredential + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetExpiringCredentialsInWalletResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetExpiringCredentialsInWalletResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type CreateVPResponse struct { Body []byte HTTPResponse *http.Response @@ -2076,6 +2204,15 @@ func (r ListUntrustedResponse) StatusCode() int { return 0 } +// GetExpiringCredentialsInWalletWithResponse request returning *GetExpiringCredentialsInWalletResponse +func (c *ClientWithResponses) GetExpiringCredentialsInWalletWithResponse(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*GetExpiringCredentialsInWalletResponse, error) { + rsp, err := c.GetExpiringCredentialsInWallet(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetExpiringCredentialsInWalletResponse(rsp) +} + // CreateVPWithBodyWithResponse request with arbitrary body returning *CreateVPResponse func (c *ClientWithResponses) CreateVPWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVPResponse, error) { rsp, err := c.CreateVPWithBody(ctx, contentType, body, reqEditors...) @@ -2284,6 +2421,48 @@ func (c *ClientWithResponses) ListUntrustedWithResponse(ctx context.Context, cre return ParseListUntrustedResponse(rsp) } +// ParseGetExpiringCredentialsInWalletResponse parses an HTTP response from a GetExpiringCredentialsInWalletWithResponse call +func ParseGetExpiringCredentialsInWalletResponse(rsp *http.Response) (*GetExpiringCredentialsInWalletResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetExpiringCredentialsInWalletResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest map[string][]ExpiringCredential + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + // ParseCreateVPResponse parses an HTTP response from a CreateVPWithResponse call func ParseCreateVPResponse(rsp *http.Response) (*CreateVPResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -2930,6 +3109,9 @@ func ParseListUntrustedResponse(rsp *http.Response) (*ListUntrustedResponse, err // ServerInterface represents all server handlers. type ServerInterface interface { + // List credentials across all wallets on this node that are expired or about to expire. + // (GET /internal/vcr/v2/holder/expiring) + GetExpiringCredentialsInWallet(ctx echo.Context, params GetExpiringCredentialsInWalletParams) error // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx echo.Context) error @@ -2985,6 +3167,26 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetExpiringCredentialsInWallet converts echo context to params. +func (w *ServerInterfaceWrapper) GetExpiringCredentialsInWallet(ctx echo.Context) error { + var err error + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetExpiringCredentialsInWalletParams + // ------------- Optional query parameter "within" ------------- + + err = runtime.BindQueryParameter("form", true, false, "within", ctx.QueryParams(), ¶ms.Within) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter within: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetExpiringCredentialsInWallet(ctx, params) + return err +} + // CreateVP converts echo context to params. func (w *ServerInterfaceWrapper) CreateVP(ctx echo.Context) error { var err error @@ -3261,6 +3463,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.GET(baseURL+"/internal/vcr/v2/holder/expiring", wrapper.GetExpiringCredentialsInWallet) router.POST(baseURL+"/internal/vcr/v2/holder/vp", wrapper.CreateVP) router.GET(baseURL+"/internal/vcr/v2/holder/:subjectID/vc", wrapper.GetCredentialsInWallet) router.POST(baseURL+"/internal/vcr/v2/holder/:subjectID/vc", wrapper.LoadVC) @@ -3280,6 +3483,44 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } +type GetExpiringCredentialsInWalletRequestObject struct { + Params GetExpiringCredentialsInWalletParams +} + +type GetExpiringCredentialsInWalletResponseObject interface { + VisitGetExpiringCredentialsInWalletResponse(w http.ResponseWriter) error +} + +type GetExpiringCredentialsInWallet200JSONResponse map[string][]ExpiringCredential + +func (response GetExpiringCredentialsInWallet200JSONResponse) VisitGetExpiringCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetExpiringCredentialsInWalletdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response GetExpiringCredentialsInWalletdefaultApplicationProblemPlusJSONResponse) VisitGetExpiringCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type CreateVPRequestObject struct { Body *CreateVPJSONRequestBody } @@ -3896,6 +4137,9 @@ func (response ListUntrusteddefaultApplicationProblemPlusJSONResponse) VisitList // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // List credentials across all wallets on this node that are expired or about to expire. + // (GET /internal/vcr/v2/holder/expiring) + GetExpiringCredentialsInWallet(ctx context.Context, request GetExpiringCredentialsInWalletRequestObject) (GetExpiringCredentialsInWalletResponseObject, error) // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx context.Context, request CreateVPRequestObject) (CreateVPResponseObject, error) @@ -3958,6 +4202,31 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetExpiringCredentialsInWallet operation middleware +func (sh *strictHandler) GetExpiringCredentialsInWallet(ctx echo.Context, params GetExpiringCredentialsInWalletParams) error { + var request GetExpiringCredentialsInWalletRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetExpiringCredentialsInWallet(ctx.Request().Context(), request.(GetExpiringCredentialsInWalletRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetExpiringCredentialsInWallet") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetExpiringCredentialsInWalletResponseObject); ok { + return validResponse.VisitGetExpiringCredentialsInWalletResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // CreateVP operation middleware func (sh *strictHandler) CreateVP(ctx echo.Context) error { var request CreateVPRequestObject From 43c7bee5981d2e2b27f393a95b1568cb46de1d23 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Fri, 22 May 2026 12:18:09 +0200 Subject: [PATCH 2/7] Add excludeTypes filter to expiring credentials endpoint 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 --- docs/_static/vcr/vcr_v2.yaml | 13 +++++++++++++ vcr/api/vcr/v2/api.go | 26 +++++++++++++++++++++++++- vcr/api/vcr/v2/api_test.go | 32 ++++++++++++++++++++++++++++++++ vcr/api/vcr/v2/generated.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index ee0dcd1ca3..7523f98e29 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -634,6 +634,19 @@ paths: type: string default: "720h" example: "720h" + - 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: | diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 4cae66e072..a3506d7d05 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -496,7 +496,8 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC // 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. -// Subjects without any expiring credentials are omitted from the result. +// 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 { @@ -510,6 +511,13 @@ func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request Ge within = parsed } + excludedTypes := make(map[string]struct{}) + if request.Params.ExcludeTypes != nil { + for _, t := range *request.Params.ExcludeTypes { + excludedTypes[t] = struct{}{} + } + } + subjects, err := w.SubjectManager.List(ctx) if err != nil { return nil, err @@ -531,6 +539,9 @@ func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request Ge if cred.ExpirationDate.After(threshold) { continue } + if isExcludedType(cred, excludedTypes) { + continue + } expiring = append(expiring, toExpiringCredential(cred, holderDID)) } } @@ -542,6 +553,19 @@ func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request Ge return GetExpiringCredentialsInWallet200JSONResponse(result), nil } +// isExcludedType reports whether any of the credential's types is in the excludedTypes set. +func isExcludedType(cred vc.VerifiableCredential, excludedTypes map[string]struct{}) bool { + if len(excludedTypes) == 0 { + return false + } + for _, t := range cred.Type { + if _, ok := excludedTypes[t.String()]; ok { + return true + } + } + return false +} + // 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 { diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index ef1d88ee8e..5a72cfeb94 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -1009,6 +1009,38 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { }, response) }) + t.Run("ok - excludeTypes omits matching credentials and now-empty subjects", func(t *testing.T) { + testContext := newMockContext(t) + makeAuthVC := func(idSuffix string, holder did.DID) vc.VerifiableCredential { + id := ssi.MustParseURI("did:web:issuer.example.com#" + idSuffix) + return vc.VerifiableCredential{ + Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsAuthorizationCredential")}, + ID: &id, + Issuer: issuerURI, + CredentialSubject: []map[string]any{{"id": holder.String()}}, + ExpirationDate: &expired, + } + } + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + "holder-b": {otherHolderDID}, + }, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). + Return([]vc.VerifiableCredential{expiredVC, makeAuthVC("auth-a", holderDID)}, nil) + testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, otherHolderDID). + Return([]vc.VerifiableCredential{makeAuthVC("auth-b", otherHolderDID)}, nil) + excludeTypes := []string{"NutsAuthorizationCredential"} + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{ExcludeTypes: &excludeTypes}, + }) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry}, + }, response) + }) + t.Run("ok - no subjects returns empty map", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{}, nil) diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 1b18585dab..665d32c2f8 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -271,6 +271,12 @@ type GetExpiringCredentialsInWalletParams struct { // expiring. Accepts a Go duration string (e.g. `24h`, `720h`, `30m`). Must be non-negative. // Defaults to 720h (30 days). Use `0s` to return only already-expired credentials. Within *string `form:"within,omitempty" json:"within,omitempty"` + + // ExcludeTypes 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. + ExcludeTypes *[]string `form:"excludeTypes,omitempty" json:"excludeTypes,omitempty"` } // SearchIssuedVCsParams defines parameters for SearchIssuedVCs. @@ -912,6 +918,22 @@ func NewGetExpiringCredentialsInWalletRequest(server string, params *GetExpiring } + if params.ExcludeTypes != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "excludeTypes", runtime.ParamLocationQuery, *params.ExcludeTypes); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + queryURL.RawQuery = queryValues.Encode() } @@ -3182,6 +3204,13 @@ func (w *ServerInterfaceWrapper) GetExpiringCredentialsInWallet(ctx echo.Context return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter within: %s", err)) } + // ------------- Optional query parameter "excludeTypes" ------------- + + err = runtime.BindQueryParameter("form", true, false, "excludeTypes", ctx.QueryParams(), ¶ms.ExcludeTypes) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter excludeTypes: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments err = w.Handler.GetExpiringCredentialsInWallet(ctx, params) return err From d90f116753e9d497e5385ba2516a60ea9dffa6aa Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 27 May 2026 08:41:59 +0200 Subject: [PATCH 3/7] Push expiring-credentials filtering down to the SQL store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../011_credential_expiration.sql | 9 ++ vcr/api/vcr/v2/api.go | 71 +++++------- vcr/api/vcr/v2/api_test.go | 70 ++++++------ vcr/credential/store/sql.go | 69 ++++++++++++ vcr/credential/store/sql_test.go | 106 +++++++++++++++++- vcr/holder/interface.go | 10 +- vcr/holder/memory_wallet.go | 35 +++++- vcr/holder/mock.go | 17 ++- vcr/holder/sql_wallet.go | 37 +++++- vcr/holder/sql_wallet_test.go | 93 +++++++++++++-- vcr/holder/wallet_search.go | 68 +++++++++++ 11 files changed, 474 insertions(+), 111 deletions(-) create mode 100644 storage/sql_migrations/011_credential_expiration.sql create mode 100644 vcr/holder/wallet_search.go diff --git a/storage/sql_migrations/011_credential_expiration.sql b/storage/sql_migrations/011_credential_expiration.sql new file mode 100644 index 0000000000..afd001ddef --- /dev/null +++ b/storage/sql_migrations/011_credential_expiration.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- expiration_date is the credential's expirationDate as seconds since Unix epoch, null if the +-- credential does not expire. Existing rows are backfilled by the application after migration. +alter table credential add column 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; diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index a3506d7d05..40325ec76d 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -479,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 } @@ -510,60 +510,49 @@ func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request Ge } within = parsed } - - excludedTypes := make(map[string]struct{}) + var excludeTypes []string if request.Params.ExcludeTypes != nil { - for _, t := range *request.Params.ExcludeTypes { - excludedTypes[t] = struct{}{} - } + excludeTypes = *request.Params.ExcludeTypes } subjects, err := w.SubjectManager.List(ctx) if err != nil { return nil, err } - - threshold := clockFn().Add(within) - result := make(map[string][]ExpiringCredential) + // Invert the subject → DIDs map for grouping results back into subjects. + holderToSubject := make(map[string]string) for subjectID, dids := range subjects { - var expiring []ExpiringCredential - for _, holderDID := range dids { - creds, err := w.VCR.Wallet().SearchCredential(ctx, holderDID) - if err != nil { - return nil, err - } - for _, cred := range creds { - if cred.ExpirationDate == nil || cred.ExpirationDate.IsZero() { - continue - } - if cred.ExpirationDate.After(threshold) { - continue - } - if isExcludedType(cred, excludedTypes) { - continue - } - expiring = append(expiring, toExpiringCredential(cred, holderDID)) - } - } - if len(expiring) > 0 { - result[subjectID] = expiring + for _, d := range dids { + holderToSubject[d.String()] = subjectID } } - return GetExpiringCredentialsInWallet200JSONResponse(result), nil -} - -// isExcludedType reports whether any of the credential's types is in the excludedTypes set. -func isExcludedType(cred vc.VerifiableCredential, excludedTypes map[string]struct{}) bool { - if len(excludedTypes) == 0 { - return false + // 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 } - for _, t := range cred.Type { - if _, ok := excludedTypes[t.String()]; ok { - return true + + 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 false + + return GetExpiringCredentialsInWallet200JSONResponse(result), nil } // toExpiringCredential builds a monitoring-friendly summary of a credential. The Wallet stores diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index 5a72cfeb94..7239471a17 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -840,7 +840,7 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { t.Run("ok - no results", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).Return([]vc.VerifiableCredential{}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any()).Return([]vc.VerifiableCredential{}, nil) response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ SubjectID: subjectID, @@ -853,7 +853,7 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { t.Run("ok - not revoked", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).Return([]vc.VerifiableCredential{testVC}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any()).Return([]vc.VerifiableCredential{testVC}, nil) testContext.mockVerifier.EXPECT().GetRevocation(testVC).Return(nil, nil) response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ @@ -868,7 +868,7 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { t.Run("ok - revoked", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).Return([]vc.VerifiableCredential{vcWithRevocation}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any()).Return([]vc.VerifiableCredential{vcWithRevocation}, nil) // GetRevocation is then called to get the details testContext.mockVerifier.EXPECT().GetRevocation(vcWithRevocation).Return(revocationInfo, nil) @@ -915,7 +915,6 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { expiredVC := makeVC("expired", holderDID, &expired) soonVC := makeVC("soon", holderDID, &soon) farVC := makeVC("far", holderDID, &farFuture) - noExpVC := makeVC("noexp", holderDID, nil) otherSubjectExpiredVC := makeVC("other-expired", otherHolderDID, &expired) expectedExpiredEntry := ExpiringCredential{ @@ -947,6 +946,9 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { ExpirationDate: expired, } + // SQL filter semantics (within/excludeTypes) are covered in Test_sqlWallet_Search. Tests here + // verify that the handler passes options through and groups Search results by subject. + t.Run("ok - groups expiring credentials by subject; subjects with none are omitted", func(t *testing.T) { testContext := newMockContext(t) emptySubjectDID := did.MustParseDID("did:web:example.com:iam:empty") @@ -955,12 +957,9 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { "holder-b": {otherHolderDID}, "holder-c": {emptySubjectDID}, }, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). - Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, otherHolderDID). - Return([]vc.VerifiableCredential{otherSubjectExpiredVC}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, emptySubjectDID). - Return([]vc.VerifiableCredential{farVC, noExpVC}, nil) + // Mock returns what SQL would have returned: only credentials within the default 30d window. + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC, soonVC, otherSubjectExpiredVC}, nil) response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) @@ -971,13 +970,13 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { }, response) }) - t.Run("ok - custom within=8760h (1y) also returns far", func(t *testing.T) { + t.Run("ok - returns whatever Search returns; threshold computed from clockFn+within", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ "holder-a": {holderDID}, }, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). - Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC}, nil) within := "8760h" response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ @@ -990,17 +989,21 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { }, response) }) - t.Run("ok - within=0 returns only already-expired", func(t *testing.T) { + t.Run("ok - excludeTypes parameter is passed through to Search", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ "holder-a": {holderDID}, + "holder-b": {otherHolderDID}, }, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). - Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil) - within := "0s" + // Mock returns what SQL would return after applying NOT IN (NutsAuthorizationCredential): + // only the org-cred for holder-a; holder-b's auth cred is filtered out and that subject is + // omitted from the response. + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC}, nil) + excludeTypes := []string{"NutsAuthorizationCredential"} response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ - Params: GetExpiringCredentialsInWalletParams{Within: &within}, + Params: GetExpiringCredentialsInWalletParams{ExcludeTypes: &excludeTypes}, }) assert.NoError(t, err) @@ -1009,31 +1012,20 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { }, response) }) - t.Run("ok - excludeTypes omits matching credentials and now-empty subjects", func(t *testing.T) { + t.Run("ok - credential whose holder isn't a current subject is silently skipped", func(t *testing.T) { + // Subject deactivation doesn't cascade to the wallet, so the wallet can return rows whose + // holder DID no longer maps to any subject. The handler skips them rather than failing or + // surfacing them under an empty key. testContext := newMockContext(t) - makeAuthVC := func(idSuffix string, holder did.DID) vc.VerifiableCredential { - id := ssi.MustParseURI("did:web:issuer.example.com#" + idSuffix) - return vc.VerifiableCredential{ - Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsAuthorizationCredential")}, - ID: &id, - Issuer: issuerURI, - CredentialSubject: []map[string]any{{"id": holder.String()}}, - ExpirationDate: &expired, - } - } + orphanDID := did.MustParseDID("did:web:example.com:iam:orphan") + orphanedVC := makeVC("orphaned", orphanDID, &expired) testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ "holder-a": {holderDID}, - "holder-b": {otherHolderDID}, }, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID). - Return([]vc.VerifiableCredential{expiredVC, makeAuthVC("auth-a", holderDID)}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, otherHolderDID). - Return([]vc.VerifiableCredential{makeAuthVC("auth-b", otherHolderDID)}, nil) - excludeTypes := []string{"NutsAuthorizationCredential"} + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC, orphanedVC}, nil) - response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ - Params: GetExpiringCredentialsInWalletParams{ExcludeTypes: &excludeTypes}, - }) + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) assert.NoError(t, err) assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ @@ -1044,6 +1036,8 @@ func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { t.Run("ok - no subjects returns empty map", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{}, nil) response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) diff --git a/vcr/credential/store/sql.go b/vcr/credential/store/sql.go index a23181c3e9..2743052b8f 100644 --- a/vcr/credential/store/sql.go +++ b/vcr/credential/store/sql.go @@ -22,6 +22,8 @@ import ( "encoding/json" "fmt" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/log" "gorm.io/gorm" "strconv" "strings" @@ -37,6 +39,9 @@ type CredentialRecord struct { SubjectID string // Type contains the 'type' property of the Verifiable Credential (not being 'VerifiableCredential'). Type *string + // ExpirationDate is the credential's 'expirationDate' as seconds since Unix epoch, null if the + // credential does not expire. + ExpirationDate *int64 `gorm:"column:expiration_date"` // Raw contains the raw JSON of the Verifiable Credential. Raw string Properties []CredentialPropertyRecord `gorm:"foreignKey:CredentialID;references:ID"` @@ -87,6 +92,11 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) break } } + // Set expiration date (seconds since Unix epoch), if present. + if credential.ExpirationDate != nil && !credential.ExpirationDate.IsZero() { + exp := credential.ExpirationDate.Unix() + newCredential.ExpirationDate = &exp + } // Create key-value properties of the credential subject, which is then stored in the property table for searching. if len(credential.CredentialSubject) != 1 { return nil, fmt.Errorf("expected exactly one credential subject, got %d", len(credential.CredentialSubject)) @@ -125,6 +135,65 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) return &newCredential, nil } +// BackfillExpirationDates populates the expiration_date column for credentials stored before the +// column existed. Idempotent: rows whose expiration_date is already set, and rows whose raw VC has +// no expirationDate (so the column legitimately stays NULL), are not visited on subsequent runs +// because the LIKE filter prunes them. Updates are batched in transactions of backfillBatchSize. +// +// TODO: remove in v7 — by then all v6 nodes will have backfilled their existing rows, and v7+ +// stores populate expiration_date directly on Store(). +func BackfillExpirationDates(db *gorm.DB) error { + const backfillBatchSize = 500 + for { + var records []CredentialRecord + // LIKE filter prunes rows that can't have an expirationDate, keeping each pass cheap. + err := db.Model(&CredentialRecord{}). + Where("expiration_date IS NULL AND raw LIKE ?", "%expirationDate%"). + Limit(backfillBatchSize). + Find(&records).Error + if err != nil { + return fmt.Errorf("backfill expiration_date: query: %w", err) + } + if len(records) == 0 { + return nil + } + var updated int + err = db.Transaction(func(tx *gorm.DB) error { + for _, record := range records { + parsed, err := vc.ParseVerifiableCredential(record.Raw) + if err != nil { + // Unparseable raw blob shouldn't happen — Store() parsed it before persisting. + // Skip rather than abort the whole backfill, but warn so it's investigatable. + log.Logger(). + WithError(err). + WithField(core.LogFieldCredentialID, record.ID). + Warn("backfill expiration_date: unable to parse stored credential") + continue + } + if parsed.ExpirationDate == nil || parsed.ExpirationDate.IsZero() { + continue + } + exp := parsed.ExpirationDate.Unix() + if err := tx.Model(&CredentialRecord{}). + Where("id = ?", record.ID). + Update("expiration_date", exp).Error; err != nil { + return err + } + updated++ + } + return nil + }) + if err != nil { + return fmt.Errorf("backfill expiration_date: update: %w", err) + } + // If we didn't update anything, the remaining matched rows can't be backfilled (unparseable + // or no real expirationDate) — exit to avoid spinning on them. Also exit on partial batch. + if updated == 0 || len(records) < backfillBatchSize { + return nil + } + } +} + // stripWhitespaceAndLinebreaks removes all whitespace and linebreaks from a string. func stripWhitespaceAndLinebreaks(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, " ", ""), "\n", "") diff --git a/vcr/credential/store/sql_test.go b/vcr/credential/store/sql_test.go index 3284d8d379..37271115cf 100644 --- a/vcr/credential/store/sql_test.go +++ b/vcr/credential/store/sql_test.go @@ -20,6 +20,9 @@ package store import ( "encoding/json" + "testing" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/storage" @@ -28,7 +31,6 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" "gorm.io/gorm/schema" - "testing" ) var vcAlice vc.VerifiableCredential @@ -137,6 +139,108 @@ func TestCredentialStore_Store(t *testing.T) { }) } +func TestCredentialStore_Store_ExpirationDate(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { _ = storageEngine.Shutdown() }) + db := storageEngine.GetSQLDatabase() + + t.Run("populates expiration_date when credential has expirationDate", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + cred := createPersonCredential("exp-1", "did:example:alice", nil) + cred.ExpirationDate = &exp + + _, err := CredentialStore{}.Store(db, cred) + require.NoError(t, err) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "exp-1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) + + t.Run("leaves expiration_date NULL when credential has none", func(t *testing.T) { + setupStore(t, db) + cred := createPersonCredential("noexp-1", "did:example:bob", nil) + + _, err := CredentialStore{}.Store(db, cred) + require.NoError(t, err) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "noexp-1").Error) + assert.Nil(t, got.ExpirationDate) + }) +} + +func TestBackfillExpirationDates(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { _ = storageEngine.Shutdown() }) + db := storageEngine.GetSQLDatabase() + + storeWithExpiration := func(t *testing.T, id string, exp *time.Time) vc.VerifiableCredential { + t.Helper() + cred := createPersonCredential(id, "did:example:"+id, nil) + if exp != nil { + cred.ExpirationDate = exp + // Re-marshal so Raw includes expirationDate (simulates how Store() ingests it normally). + data, err := cred.MarshalJSON() + require.NoError(t, err) + parsed, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + cred = *parsed + } + _, err := CredentialStore{}.Store(db, cred) + require.NoError(t, err) + return cred + } + + t.Run("backfills rows whose expiration_date is NULL but raw has expirationDate", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 6, 15, 12, 0, 0, 0, time.UTC) + storeWithExpiration(t, "bf-1", &exp) + // Simulate the pre-migration state: column unset on an existing row that does have raw expirationDate. + require.NoError(t, db.Exec("UPDATE credential SET expiration_date = NULL WHERE id = ?", "bf-1").Error) + + require.NoError(t, BackfillExpirationDates(db)) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "bf-1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) + + t.Run("leaves rows without expirationDate in raw untouched", func(t *testing.T) { + setupStore(t, db) + storeWithExpiration(t, "bf-2", nil) + // Column should already be NULL from Store(); confirm before and after backfill. + var before CredentialRecord + require.NoError(t, db.First(&before, "id = ?", "bf-2").Error) + require.Nil(t, before.ExpirationDate) + + require.NoError(t, BackfillExpirationDates(db)) + + var after CredentialRecord + require.NoError(t, db.First(&after, "id = ?", "bf-2").Error) + assert.Nil(t, after.ExpirationDate) + }) + + t.Run("idempotent: re-running is a no-op when nothing needs backfilling", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 6, 15, 12, 0, 0, 0, time.UTC) + storeWithExpiration(t, "bf-3", &exp) + + require.NoError(t, BackfillExpirationDates(db)) + require.NoError(t, BackfillExpirationDates(db)) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "bf-3").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) +} + func sliceToMap(slice []CredentialPropertyRecord) map[string]string { var result = make(map[string]string) for _, curr := range slice { diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index 5776f30f6f..86c24d8c3f 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -59,11 +59,11 @@ type Wallet interface { // If the wallet does not contain any credentials for the given holder, it returns an empty list. List(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) - // SearchCredential returns all credentials in the wallet for the given holder. - // Unlike List, which filters out expired and revoked credentials, SearchCredential returns all credentials - // regardless of their validity status (signature, expired/revoked). - // This can be used to find credentials that can be removed, e.g. because they are expired or revoked. - SearchCredential(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) + // Search returns wallet credentials matching the given filter options. Without any options it + // returns every credential in the wallet. Unlike List, Search does not validate the credentials + // it returns — it filters purely on stored metadata (holder, type, expiration_date), so callers + // can use it to find credentials that should be cleaned up (expired, wrong type, etc.). + Search(ctx context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) // Remove removes the given credential from the wallet. // If the credential is not in the wallet, it returns ErrNotFound. diff --git a/vcr/holder/memory_wallet.go b/vcr/holder/memory_wallet.go index 9adbb3af0f..79aba3f0a7 100644 --- a/vcr/holder/memory_wallet.go +++ b/vcr/holder/memory_wallet.go @@ -21,6 +21,7 @@ package holder import ( "context" "errors" + "slices" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -79,8 +80,38 @@ func (m memoryWallet) List(_ context.Context, holderDID did.DID) ([]vc.Verifiabl return m.credentials[holderDID], nil } -func (m memoryWallet) SearchCredential(_ context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { - return m.credentials[holderDID], nil +func (m memoryWallet) Search(_ context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) { + q := buildSearchQuery(opts) + var results []vc.VerifiableCredential + for holderDID, creds := range m.credentials { + if q.holderDID != nil && holderDID != *q.holderDID { + continue + } + for _, cred := range creds { + if len(q.excludeCredentialTypes) > 0 { + excluded := false + for _, t := range cred.Type { + if slices.Contains(q.excludeCredentialTypes, t.String()) { + excluded = true + break + } + } + if excluded { + continue + } + } + if q.expiresAt != nil { + if cred.ExpirationDate == nil || cred.ExpirationDate.IsZero() { + continue + } + if cred.ExpirationDate.After(*q.expiresAt) { + continue + } + } + results = append(results, cred) + } + } + return results, nil } func (m memoryWallet) Remove(_ context.Context, _ did.DID, _ ssi.URI) error { diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index e12b0f8019..839fa034fb 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -153,17 +153,22 @@ func (mr *MockWalletMockRecorder) Remove(ctx, holderDID, credentialID any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockWallet)(nil).Remove), ctx, holderDID, credentialID) } -// SearchCredential mocks base method. -func (m *MockWallet) SearchCredential(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { +// Search mocks base method. +func (m *MockWallet) Search(ctx context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchCredential", ctx, holderDID) + varargs := []any{ctx} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Search", varargs...) ret0, _ := ret[0].([]vc.VerifiableCredential) ret1, _ := ret[1].(error) return ret0, ret1 } -// SearchCredential indicates an expected call of SearchCredential. -func (mr *MockWalletMockRecorder) SearchCredential(ctx, holderDID any) *gomock.Call { +// Search indicates an expected call of Search. +func (mr *MockWalletMockRecorder) Search(ctx any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchCredential", reflect.TypeOf((*MockWallet)(nil).SearchCredential), ctx, holderDID) + varargs := append([]any{ctx}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockWallet)(nil).Search), varargs...) } diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index 7323332558..a8cdae4b6e 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -56,12 +56,19 @@ type sqlWallet struct { func NewSQLWallet( keyResolver resolver.KeyResolver, keyStore crypto.KeyStore, verifier verifier.Verifier, jsonldManager jsonld.JSONLD, storageEngine storage.Engine) Wallet { + db := storageEngine.GetSQLDatabase() + // Migrate the expiration_date column for credentials stored before the column existed. + // Idempotent; safe to run on every startup. Logged-and-continued on error to avoid blocking + // node start. + if err := store.BackfillExpirationDates(db); err != nil { + log.Logger().WithError(err).Warn("failed to migrate credential expiration_date column") + } return &sqlWallet{ keyResolver: keyResolver, keyStore: keyStore, verifier: verifier, jsonldManager: jsonldManager, - walletStore: walletStore{db: storageEngine.GetSQLDatabase()}, + walletStore: walletStore{db: db}, } } @@ -141,8 +148,8 @@ func (h sqlWallet) List(_ context.Context, holderDID did.DID) ([]vc.VerifiableCr return validCredentials, nil } -func (h sqlWallet) SearchCredential(_ context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { - return h.walletStore.list(holderDID) +func (h sqlWallet) Search(_ context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) { + return h.walletStore.search(buildSearchQuery(opts)) } func (h sqlWallet) Remove(ctx context.Context, holderDID did.DID, credentialID ssi.URI) error { @@ -197,12 +204,30 @@ func (s walletStore) count() (int64, error) { } func (s walletStore) list(holderDID did.DID) ([]vc.VerifiableCredential, error) { + return s.search(searchQuery{holderDID: &holderDID}) +} + +func (s walletStore) search(q searchQuery) ([]vc.VerifiableCredential, error) { + tx := s.db.Model(walletRecord{}).Preload("Credential") + // Join credential when any option filters on credential.* columns. + if len(q.excludeCredentialTypes) > 0 || q.expiresAt != nil { + tx = tx.Joins("JOIN credential ON credential.id = wallet_credential.credential_id") + } + if q.holderDID != nil { + tx = tx.Where("wallet_credential.holder_did = ?", q.holderDID.String()) + } + if len(q.excludeCredentialTypes) > 0 { + // Credentials with a NULL type column are kept; only known types are excluded. + tx = tx.Where("credential.type IS NULL OR credential.type NOT IN ?", q.excludeCredentialTypes) + } + if q.expiresAt != nil { + tx = tx.Where("credential.expiration_date IS NOT NULL AND credential.expiration_date <= ?", q.expiresAt.Unix()) + } var records []walletRecord - err := s.db.Model(walletRecord{}).Preload("Credential").Where("holder_did = ?", holderDID.String()).Find(&records).Error - if err != nil { + if err := tx.Find(&records).Error; err != nil { return nil, err } - results := make([]vc.VerifiableCredential, 0) + results := make([]vc.VerifiableCredential, 0, len(records)) for _, record := range records { verifiableCredential, err := vc.ParseVerifiableCredential(record.Credential.Raw) if err != nil { diff --git a/vcr/holder/sql_wallet_test.go b/vcr/holder/sql_wallet_test.go index 75d4767538..fc652a5da4 100644 --- a/vcr/holder/sql_wallet_test.go +++ b/vcr/holder/sql_wallet_test.go @@ -228,50 +228,110 @@ func Test_sqlWallet_List(t *testing.T) { }) } -func Test_sqlWallet_SearchCredential(t *testing.T) { +func Test_sqlWallet_Search(t *testing.T) { ctx := context.Background() storageEngine := storage.NewTestStorageEngine(t) + t.Run("empty", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) - list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA)) require.NoError(t, err) require.NotNil(t, list) assert.Empty(t, list) }) + t.Run("no options - returns all credentials in the wallet", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + credA := createCredential(vdr.TestMethodDIDA.String()) + credB := createCredential(vdr.TestMethodDIDB.String()) + require.NoError(t, sut.Put(ctx, credA, credB)) + + list, err := sut.Search(ctx) + require.NoError(t, err) + require.Len(t, list, 2) + }) t.Run("returns all credentials including expired/revoked", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) - // SearchCredential should not filter by validity, so we pass a testVerifier that would filter + // Search must not filter on validity, so we pass a testVerifier that would filter via List. sut := NewSQLWallet(nil, nil, testVerifier{err: types.ErrCredentialNotValidAtTime}, nil, storageEngine) expected1 := createCredential(vdr.TestMethodDIDA.String()) expected2 := createCredential(vdr.TestMethodDIDA.String()) - err := sut.Put(ctx, expected1, expected2) - require.NoError(t, err) + require.NoError(t, sut.Put(ctx, expected1, expected2)) - // SearchCredential should return all credentials, even though they would be filtered by List - list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA)) require.NoError(t, err) require.Len(t, list, 2) - // Compare with List which should filter them out + // Compare with List which should filter them out. filteredList, err := sut.List(ctx, vdr.TestDIDA) require.NoError(t, err) require.Len(t, filteredList, 0) }) - t.Run("returns credentials from specified holder only", func(t *testing.T) { + t.Run("HolderDID - returns credentials from specified holder only", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) credA := createCredential(vdr.TestMethodDIDA.String()) credB := createCredential(vdr.TestMethodDIDB.String()) - err := sut.Put(ctx, credA, credB) - require.NoError(t, err) + require.NoError(t, sut.Put(ctx, credA, credB)) - list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA)) require.NoError(t, err) require.Len(t, list, 1) assert.Equal(t, credA.ID.String(), list[0].ID.String()) }) + t.Run("ExpiresAt - includes credentials expiring at or before threshold, skips no-expiration", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + now := time.Now() + expired := now.Add(-24 * time.Hour) + soon := now.Add(24 * time.Hour) + farFuture := now.Add(365 * 24 * time.Hour) + expiredVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &expired) + soonVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &soon) + farVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &farFuture) + noExpVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), nil) + require.NoError(t, sut.Put(ctx, expiredVC, soonVC, farVC, noExpVC)) + + // Threshold = now + 7d -> expiredVC and soonVC match; farVC and noExpVC excluded. + list, err := sut.Search(ctx, ExpiresAt(now.Add(7*24*time.Hour))) + require.NoError(t, err) + require.Len(t, list, 2) + ids := []string{list[0].ID.String(), list[1].ID.String()} + assert.Contains(t, ids, expiredVC.ID.String()) + assert.Contains(t, ids, soonVC.ID.String()) + }) + t.Run("ExcludeCredentialTypes - drops matching types, keeps others", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + companyVC := createCredential(vdr.TestMethodDIDA.String()) // CompanyCredential + require.NoError(t, sut.Put(ctx, companyVC)) + + // Excluding the only stored type returns nothing. + list, err := sut.Search(ctx, ExcludeCredentialTypes("CompanyCredential")) + require.NoError(t, err) + assert.Empty(t, list) + + // Excluding an unrelated type leaves the credential in the result. + list, err = sut.Search(ctx, ExcludeCredentialTypes("OtherCredential")) + require.NoError(t, err) + require.Len(t, list, 1) + }) + t.Run("combined options AND together", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + expired := time.Now().Add(-24 * time.Hour) + expiredA := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &expired) + expiredB := createCredentialWithExpiration(vdr.TestMethodDIDB.String(), &expired) + require.NoError(t, sut.Put(ctx, expiredA, expiredB)) + + // HolderDID + ExpiresAt: only DIDA's expired credential. + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA), ExpiresAt(time.Now())) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, expiredA.ID.String(), list[0].ID.String()) + }) } func Test_sqlWallet_Diagnostics(t *testing.T) { @@ -349,6 +409,15 @@ func createCredential(keyID string) vc.VerifiableCredential { return testCredential } +// createCredentialWithExpiration builds a wallet-storable credential and sets its expirationDate +// (nil = no expirationDate, i.e. never expires). The credential ID stays the unique one assigned +// by createCredential, so Raw and the parsed-back VC agree on ID after a Put/Search round-trip. +func createCredentialWithExpiration(keyID string, expirationDate *time.Time) vc.VerifiableCredential { + cred := createCredential(keyID) + cred.ExpirationDate = expirationDate + return cred +} + func Test_sqlWallet_IsEmpty(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) t.Run("empty", func(t *testing.T) { diff --git a/vcr/holder/wallet_search.go b/vcr/holder/wallet_search.go new file mode 100644 index 0000000000..a4dc8458a6 --- /dev/null +++ b/vcr/holder/wallet_search.go @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package holder + +import ( + "time" + + "github.com/nuts-foundation/go-did/did" +) + +// SearchOption is a filter that narrows the results returned by Wallet.Search. Multiple options +// are combined with logical AND. +type SearchOption func(*searchQuery) + +// searchQuery holds the resolved filter state for a single Wallet.Search call. +type searchQuery struct { + holderDID *did.DID + excludeCredentialTypes []string + expiresAt *time.Time +} + +// HolderDID restricts results to credentials held by the given DID. +func HolderDID(d did.DID) SearchOption { + return func(q *searchQuery) { q.holderDID = &d } +} + +// ExcludeCredentialTypes drops credentials whose type matches any of the given values. Credentials +// without a stored type (NULL) are kept. +func ExcludeCredentialTypes(types ...string) SearchOption { + return func(q *searchQuery) { + if len(types) == 0 { + return + } + q.excludeCredentialTypes = append(q.excludeCredentialTypes, types...) + } +} + +// ExpiresAt restricts results to credentials whose expirationDate is set and falls at or before t. +// Credentials without an expirationDate are not returned. Callers compute t from their own clock +// so this is a fixed threshold from the wallet's perspective. +func ExpiresAt(t time.Time) SearchOption { + return func(q *searchQuery) { q.expiresAt = &t } +} + +// buildSearchQuery applies the options to a fresh searchQuery and returns it. +func buildSearchQuery(opts []SearchOption) searchQuery { + var q searchQuery + for _, opt := range opts { + opt(&q) + } + return q +} From 8bae467c16485852a29f491cb4d561296c6bf874 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 1 Jun 2026 06:58:09 +0200 Subject: [PATCH 4/7] docs: clarify within param, document expiring-credentials monitoring - 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 --- docs/_static/vcr/vcr_v2.yaml | 8 ++++--- docs/pages/deployment/monitoring.rst | 36 ++++++++++++++++++++++++++++ vcr/api/vcr/v2/generated.go | 6 +++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index 7523f98e29..9f16eadae6 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -627,13 +627,15 @@ paths: 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 (e.g. `24h`, `720h`, `30m`). Must be non-negative. - Defaults to 720h (30 days). Use `0s` to return only already-expired credentials. + 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: "720h" + example: "24h" - name: excludeTypes in: query description: | diff --git a/docs/pages/deployment/monitoring.rst b/docs/pages/deployment/monitoring.rst index 296c293de3..efe5b2aab9 100644 --- a/docs/pages/deployment/monitoring.rst +++ b/docs/pages/deployment/monitoring.rst @@ -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 ******* diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 665d32c2f8..1ba0fa3c5a 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -268,8 +268,10 @@ type VPVerificationResult struct { // GetExpiringCredentialsInWalletParams defines parameters for GetExpiringCredentialsInWallet. type GetExpiringCredentialsInWalletParams struct { // Within Time window relative to now in which a credential's `expirationDate` falls for it to be considered - // expiring. Accepts a Go duration string (e.g. `24h`, `720h`, `30m`). Must be non-negative. - // Defaults to 720h (30 days). Use `0s` to return only already-expired credentials. + // 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. Within *string `form:"within,omitempty" json:"within,omitempty"` // ExcludeTypes Credential type(s) to exclude from the result. A credential is excluded if any of its From cd3a3e09e9ed0e0dec7aa5bc21e40fcd21835ade Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 1 Jun 2026 07:08:30 +0200 Subject: [PATCH 5/7] fix(vcr): backfill expiration_date for JWT-encoded credentials 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 --- vcr/credential/store/sql.go | 27 +++++++++++------- vcr/credential/store/sql_test.go | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/vcr/credential/store/sql.go b/vcr/credential/store/sql.go index 2743052b8f..9c003c8b04 100644 --- a/vcr/credential/store/sql.go +++ b/vcr/credential/store/sql.go @@ -136,19 +136,29 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) } // BackfillExpirationDates populates the expiration_date column for credentials stored before the -// column existed. Idempotent: rows whose expiration_date is already set, and rows whose raw VC has -// no expirationDate (so the column legitimately stays NULL), are not visited on subsequent runs -// because the LIKE filter prunes them. Updates are batched in transactions of backfillBatchSize. +// column existed, by parsing each credential whose column is still NULL. Updates are batched in +// transactions of backfillBatchSize. +// +// Every NULL row must be parsed: NULL is ambiguous (credential never expires vs. not yet +// backfilled), and a JWT-encoded credential keeps its expiry in a base64 `exp` claim that no SQL +// text filter can match, so there is no cheap way to pre-select only the rows that have an +// expiration. Credentials that genuinely never expire keep expiration_date NULL and are therefore +// re-parsed on every startup; acceptable for a migration that is removed in v7. +// +// Pagination uses the id as a keyset cursor rather than relying on updated rows leaving the result +// set — never-expiring rows stay NULL, so only the advancing cursor guarantees each row is visited +// once and the loop terminates. // // TODO: remove in v7 — by then all v6 nodes will have backfilled their existing rows, and v7+ // stores populate expiration_date directly on Store(). func BackfillExpirationDates(db *gorm.DB) error { const backfillBatchSize = 500 + lastID := "" for { var records []CredentialRecord - // LIKE filter prunes rows that can't have an expirationDate, keeping each pass cheap. err := db.Model(&CredentialRecord{}). - Where("expiration_date IS NULL AND raw LIKE ?", "%expirationDate%"). + Where("expiration_date IS NULL AND id > ?", lastID). + Order("id"). Limit(backfillBatchSize). Find(&records).Error if err != nil { @@ -157,7 +167,6 @@ func BackfillExpirationDates(db *gorm.DB) error { if len(records) == 0 { return nil } - var updated int err = db.Transaction(func(tx *gorm.DB) error { for _, record := range records { parsed, err := vc.ParseVerifiableCredential(record.Raw) @@ -179,16 +188,14 @@ func BackfillExpirationDates(db *gorm.DB) error { Update("expiration_date", exp).Error; err != nil { return err } - updated++ } return nil }) if err != nil { return fmt.Errorf("backfill expiration_date: update: %w", err) } - // If we didn't update anything, the remaining matched rows can't be backfilled (unparseable - // or no real expirationDate) — exit to avoid spinning on them. Also exit on partial batch. - if updated == 0 || len(records) < backfillBatchSize { + lastID = records[len(records)-1].ID + if len(records) < backfillBatchSize { return nil } } diff --git a/vcr/credential/store/sql_test.go b/vcr/credential/store/sql_test.go index 37271115cf..a5d5d76b54 100644 --- a/vcr/credential/store/sql_test.go +++ b/vcr/credential/store/sql_test.go @@ -19,10 +19,15 @@ package store import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" "testing" "time" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/storage" @@ -239,6 +244,49 @@ func TestBackfillExpirationDates(t *testing.T) { require.NotNil(t, got.ExpirationDate) assert.Equal(t, exp.Unix(), *got.ExpirationDate) }) + + t.Run("backfills JWT-encoded credentials, whose raw has no 'expirationDate' literal", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 6, 15, 12, 0, 0, 0, time.UTC) + jwtVC := jwtCredentialWithExpiration(t, "did:example:jwt-bf#1", "did:example:jwt-holder", exp) + // The JWT stores its expiry in a base64 `exp` claim, so the raw text must not contain the + // literal "expirationDate" — a substring pre-filter would skip it. + require.NotContains(t, jwtVC.Raw(), "expirationDate") + _, err := CredentialStore{}.Store(db, jwtVC) + require.NoError(t, err) + // Simulate the pre-migration state: column unset. + require.NoError(t, db.Exec("UPDATE credential SET expiration_date = NULL WHERE id = ?", "did:example:jwt-bf#1").Error) + + require.NoError(t, BackfillExpirationDates(db)) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "did:example:jwt-bf#1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) +} + +// jwtCredentialWithExpiration builds a JWT-encoded Verifiable Credential with an `exp` claim. Its +// Raw() is the compact JWT, so the expiry only exists as a base64-encoded claim and never as a +// literal "expirationDate" in the stored text. +func jwtCredentialWithExpiration(t *testing.T, id, subjectID string, exp time.Time) vc.VerifiableCredential { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + token := jwt.New() + require.NoError(t, token.Set(jwt.JwtIDKey, id)) + require.NoError(t, token.Set(jwt.IssuerKey, "did:example:jwt-issuer")) + require.NoError(t, token.Set(jwt.SubjectKey, subjectID)) + require.NoError(t, token.Set(jwt.ExpirationKey, exp)) + require.NoError(t, token.Set("vc", map[string]interface{}{ + "type": "PersonCredential", + "credentialSubject": map[string]interface{}{"id": subjectID}, + })) + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + require.NoError(t, err) + parsed, err := vc.ParseVerifiableCredential(string(signedToken)) + require.NoError(t, err) + return *parsed } func sliceToMap(slice []CredentialPropertyRecord) map[string]string { From 6612c66cf8c75e8e83eb349953ea9443902d8582 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 1 Jun 2026 10:07:41 +0200 Subject: [PATCH 6/7] fix(migration): use SQL Server-compatible ADD column syntax `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 --- storage/sql_migrations/011_credential_expiration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/sql_migrations/011_credential_expiration.sql b/storage/sql_migrations/011_credential_expiration.sql index afd001ddef..45b9081a14 100644 --- a/storage/sql_migrations/011_credential_expiration.sql +++ b/storage/sql_migrations/011_credential_expiration.sql @@ -1,7 +1,7 @@ -- +goose Up -- expiration_date is the credential's expirationDate as seconds since Unix epoch, null if the -- credential does not expire. Existing rows are backfilled by the application after migration. -alter table credential add column expiration_date integer null; +alter table credential add expiration_date integer null; create index idx_credential_expiration_date on credential (expiration_date); -- +goose Down From 1121a934da06ce3e98ced95fe5a22065d4be18bf Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 1 Jun 2026 13:32:42 +0200 Subject: [PATCH 7/7] refactor(vcr): use never-expires sentinel instead of NULL for no expirationDate 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 --- .../011_credential_expiration.sql | 5 +- vcr/credential/store/sql.go | 47 ++++++++++++------- vcr/credential/store/sql_test.go | 16 +++---- vcr/holder/sql_wallet.go | 5 +- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/storage/sql_migrations/011_credential_expiration.sql b/storage/sql_migrations/011_credential_expiration.sql index 45b9081a14..21e296feb7 100644 --- a/storage/sql_migrations/011_credential_expiration.sql +++ b/storage/sql_migrations/011_credential_expiration.sql @@ -1,6 +1,7 @@ -- +goose Up --- expiration_date is the credential's expirationDate as seconds since Unix epoch, null if the --- credential does not expire. Existing rows are backfilled by the application after migration. +-- 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); diff --git a/vcr/credential/store/sql.go b/vcr/credential/store/sql.go index 9c003c8b04..273eded05d 100644 --- a/vcr/credential/store/sql.go +++ b/vcr/credential/store/sql.go @@ -39,8 +39,9 @@ type CredentialRecord struct { SubjectID string // Type contains the 'type' property of the Verifiable Credential (not being 'VerifiableCredential'). Type *string - // ExpirationDate is the credential's 'expirationDate' as seconds since Unix epoch, null if the - // credential does not expire. + // ExpirationDate is the credential's 'expirationDate' as seconds since Unix epoch. Credentials + // without an expirationDate carry the neverExpires sentinel rather than NULL; NULL means the row + // predates this column and has not been backfilled yet. ExpirationDate *int64 `gorm:"column:expiration_date"` // Raw contains the raw JSON of the Verifiable Credential. Raw string @@ -92,11 +93,13 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) break } } - // Set expiration date (seconds since Unix epoch), if present. + // Set expiration date (seconds since Unix epoch). Credentials without an expirationDate get the + // neverExpires sentinel rather than NULL, so the backfill never has to revisit them. + exp := neverExpires if credential.ExpirationDate != nil && !credential.ExpirationDate.IsZero() { - exp := credential.ExpirationDate.Unix() - newCredential.ExpirationDate = &exp + exp = credential.ExpirationDate.Unix() } + newCredential.ExpirationDate = &exp // Create key-value properties of the credential subject, which is then stored in the property table for searching. if len(credential.CredentialSubject) != 1 { return nil, fmt.Errorf("expected exactly one credential subject, got %d", len(credential.CredentialSubject)) @@ -135,19 +138,29 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) return &newCredential, nil } +// neverExpires is the expiration_date sentinel for credentials that have no expirationDate. Storing +// it instead of NULL lets BackfillExpirationDates run once and never revisit these rows on +// subsequent startups (NULL means "stored before this column existed, not yet backfilled"). The +// value is 9999-12-31T23:59:59Z, far enough in the future that the "expiring within X" range query +// never matches it, so a never-expiring credential is correctly excluded without query special-casing. +// +// TODO: remove together with BackfillExpirationDates in v7. v7+ stores can map "no expirationDate" +// back to NULL directly; a migration may NULL out any remaining sentinels. +const neverExpires int64 = 253402300799 + // BackfillExpirationDates populates the expiration_date column for credentials stored before the -// column existed, by parsing each credential whose column is still NULL. Updates are batched in -// transactions of backfillBatchSize. +// column existed, by parsing each credential whose column is still NULL and writing its expiration +// (or the neverExpires sentinel when it has none). Updates are batched in transactions of +// backfillBatchSize. // -// Every NULL row must be parsed: NULL is ambiguous (credential never expires vs. not yet -// backfilled), and a JWT-encoded credential keeps its expiry in a base64 `exp` claim that no SQL -// text filter can match, so there is no cheap way to pre-select only the rows that have an -// expiration. Credentials that genuinely never expire keep expiration_date NULL and are therefore -// re-parsed on every startup; acceptable for a migration that is removed in v7. +// Every NULL row must be parsed: a JWT-encoded credential keeps its expiry in a base64 `exp` claim +// that no SQL text filter can match, so there is no cheap way to pre-select only rows that have an +// expiration. Because each processed row is written to a non-NULL value (real date or sentinel), it +// drops out of the NULL set, so the backfill is effectively one-shot — after the first run only +// rows that fail to parse (which shouldn't happen) remain NULL. // // Pagination uses the id as a keyset cursor rather than relying on updated rows leaving the result -// set — never-expiring rows stay NULL, so only the advancing cursor guarantees each row is visited -// once and the loop terminates. +// set, so unparseable rows that stay NULL don't stall the walk. // // TODO: remove in v7 — by then all v6 nodes will have backfilled their existing rows, and v7+ // stores populate expiration_date directly on Store(). @@ -179,10 +192,10 @@ func BackfillExpirationDates(db *gorm.DB) error { Warn("backfill expiration_date: unable to parse stored credential") continue } - if parsed.ExpirationDate == nil || parsed.ExpirationDate.IsZero() { - continue + exp := neverExpires + if parsed.ExpirationDate != nil && !parsed.ExpirationDate.IsZero() { + exp = parsed.ExpirationDate.Unix() } - exp := parsed.ExpirationDate.Unix() if err := tx.Model(&CredentialRecord{}). Where("id = ?", record.ID). Update("expiration_date", exp).Error; err != nil { diff --git a/vcr/credential/store/sql_test.go b/vcr/credential/store/sql_test.go index a5d5d76b54..6a72e0cb07 100644 --- a/vcr/credential/store/sql_test.go +++ b/vcr/credential/store/sql_test.go @@ -165,7 +165,7 @@ func TestCredentialStore_Store_ExpirationDate(t *testing.T) { assert.Equal(t, exp.Unix(), *got.ExpirationDate) }) - t.Run("leaves expiration_date NULL when credential has none", func(t *testing.T) { + t.Run("writes the never-expires sentinel when credential has no expirationDate", func(t *testing.T) { setupStore(t, db) cred := createPersonCredential("noexp-1", "did:example:bob", nil) @@ -174,7 +174,8 @@ func TestCredentialStore_Store_ExpirationDate(t *testing.T) { var got CredentialRecord require.NoError(t, db.First(&got, "id = ?", "noexp-1").Error) - assert.Nil(t, got.ExpirationDate) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, neverExpires, *got.ExpirationDate) }) } @@ -216,19 +217,18 @@ func TestBackfillExpirationDates(t *testing.T) { assert.Equal(t, exp.Unix(), *got.ExpirationDate) }) - t.Run("leaves rows without expirationDate in raw untouched", func(t *testing.T) { + t.Run("backfills rows without expirationDate to the never-expires sentinel", func(t *testing.T) { setupStore(t, db) storeWithExpiration(t, "bf-2", nil) - // Column should already be NULL from Store(); confirm before and after backfill. - var before CredentialRecord - require.NoError(t, db.First(&before, "id = ?", "bf-2").Error) - require.Nil(t, before.ExpirationDate) + // Simulate the pre-migration state: an existing row with no raw expirationDate and a NULL column. + require.NoError(t, db.Exec("UPDATE credential SET expiration_date = NULL WHERE id = ?", "bf-2").Error) require.NoError(t, BackfillExpirationDates(db)) var after CredentialRecord require.NoError(t, db.First(&after, "id = ?", "bf-2").Error) - assert.Nil(t, after.ExpirationDate) + require.NotNil(t, after.ExpirationDate) + assert.Equal(t, neverExpires, *after.ExpirationDate) }) t.Run("idempotent: re-running is a no-op when nothing needs backfilling", func(t *testing.T) { diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index a8cdae4b6e..cad7310610 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -221,7 +221,10 @@ func (s walletStore) search(q searchQuery) ([]vc.VerifiableCredential, error) { tx = tx.Where("credential.type IS NULL OR credential.type NOT IN ?", q.excludeCredentialTypes) } if q.expiresAt != nil { - tx = tx.Where("credential.expiration_date IS NOT NULL AND credential.expiration_date <= ?", q.expiresAt.Unix()) + // Credentials without an expirationDate carry the never-expires sentinel (a far-future + // value), and legacy un-backfilled rows are NULL; <= excludes both, so neither shows up as + // expiring. + tx = tx.Where("credential.expiration_date <= ?", q.expiresAt.Unix()) } var records []walletRecord if err := tx.Find(&records).Error; err != nil {