From 92f54afee23494ff1c551ae014af4088ba5c6c3f Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Wed, 11 Nov 2026 12:45:26 +0100 Subject: [PATCH] feat: narrow the CFM agents' IssuerService scopes The registration and onboarding agents called the IssuerService admin API with issuer-admin-api:admin, granted on every tenant's participant context, because the tenant's token sub is no context the IssuerService knows. They now exchange their token under the issuer's own context, whose sub owns every path they address, and request only holders:write (create/delete holder), credentials:read (query) and credentials:write (revoke). issuer-admin-api:admin is dropped from the per-tenant agent mapping. Requires core-platform-distribution 0.0.40, which seeds the cfm-agents mapping on the issuer context. Co-Authored-By: Claude Opus 5.5 --- agent/common/issuerservice/issuer.go | 42 ++++++++++--------- agent/common/issuerservice/issuer_test.go | 36 ++++++++-------- .../jwtletagent/activity/activity.go | 2 - .../onboarding/activity/activity.go | 4 +- .../onboarding/activity/activity_test.go | 8 ++-- .../registration/activity/activity.go | 4 +- .../registration/activity/activity_test.go | 8 ++-- 7 files changed, 52 insertions(+), 52 deletions(-) diff --git a/agent/common/issuerservice/issuer.go b/agent/common/issuerservice/issuer.go index cfe75dbd..26b28517 100644 --- a/agent/common/issuerservice/issuer.go +++ b/agent/common/issuerservice/issuer.go @@ -27,13 +27,15 @@ import ( "github.com/eclipse-cfm/cfm/common/token" ) +// The tokens for the issuer-admin-api are exchanged under the issuer's participant context +// (HttpApiClient.IssuerID), so the token's sub owns every context these calls address and the +// resource scopes below suffice; no issuer-admin-api:admin elevation is needed. The cfm-agents +// ServiceAccount needs a jwtlet mapping on the issuer context granting these scopes, which the +// platform seeds. const ( - // ScopeApiAdmin is required for all issuer-admin-api operations here: the token is bound - // to the tenant's participant context, which the IssuerService does not know (it only has - // the issuer's context), so the caller resolution only passes with admin. Resource-level - // scopes (e.g. issuer-admin-api:credentials:read) are rejected with - // "No participant for 'sub = ...' found". - ScopeApiAdmin = "issuer-admin-api:admin" + ScopeHoldersWrite = "issuer-admin-api:holders:write" + ScopeCredentialsRead = "issuer-admin-api:credentials:read" + ScopeCredentialsWrite = "issuer-admin-api:credentials:write" ) // IssuerCredentialResourceDto represents a DTO for verifiable credentials that the IssuerService has issued to holders. @@ -47,14 +49,14 @@ type IssuerCredentialResourceDto struct { } type ApiClient interface { - CreateHolder(ctx context.Context, participantContextID string, did string, holderID string, name string, properties map[string]any) error - DeleteHolder(ctx context.Context, participantContextID string, holderID string) error + CreateHolder(ctx context.Context, did string, holderID string, name string, properties map[string]any) error + DeleteHolder(ctx context.Context, holderID string) error // RevokeCredential revokes an issued credential. credentialID is the ID of the IssuerService's credential resource // (IssuerCredentialResourceDto.ID), not the ID of the verifiable credential itself. - RevokeCredential(ctx context.Context, participantContextID string, credentialID string) error + RevokeCredential(ctx context.Context, credentialID string) error // QueryCredentialsByType returns the credentials of the given type that were issued to the holder. The IssuerService // records the credential subject's ID (the holder's DID) as the holder ID of an issued credential. - QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) + QueryCredentialsByType(ctx context.Context, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) } type HttpApiClient struct { @@ -64,8 +66,8 @@ type HttpApiClient struct { HttpClient *http.Client } -func (i HttpApiClient) QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) { - accessToken, err := i.TokenProvider.GetToken(ctx, ScopeApiAdmin, participantContextID) +func (i HttpApiClient) QueryCredentialsByType(ctx context.Context, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) { + accessToken, err := i.TokenProvider.GetToken(ctx, ScopeCredentialsRead, i.IssuerID) if err != nil { return nil, fmt.Errorf("failed to get API access token: %w", err) } @@ -113,8 +115,8 @@ func (i HttpApiClient) QueryCredentialsByType(ctx context.Context, participantCo return credentials, nil } -func (i HttpApiClient) DeleteHolder(ctx context.Context, participantContextID string, holderID string) error { - accessToken, err := i.TokenProvider.GetToken(ctx, ScopeApiAdmin, participantContextID) +func (i HttpApiClient) DeleteHolder(ctx context.Context, holderID string) error { + accessToken, err := i.TokenProvider.GetToken(ctx, ScopeHoldersWrite, i.IssuerID) if err != nil { return fmt.Errorf("failed to get API access token: %w", err) } @@ -137,8 +139,8 @@ func (i HttpApiClient) DeleteHolder(ctx context.Context, participantContextID st return nil } -func (i HttpApiClient) CreateHolder(ctx context.Context, participantContextID string, did string, holderID string, name string, properties map[string]any) error { - accessToken, err := i.TokenProvider.GetToken(ctx, ScopeApiAdmin, participantContextID) +func (i HttpApiClient) CreateHolder(ctx context.Context, did string, holderID string, name string, properties map[string]any) error { + accessToken, err := i.TokenProvider.GetToken(ctx, ScopeHoldersWrite, i.IssuerID) if err != nil { return fmt.Errorf("failed to get API access token: %w", err) } @@ -178,8 +180,8 @@ func (i HttpApiClient) CreateHolder(ctx context.Context, participantContextID st return nil } -func (i HttpApiClient) RevokeCredential(ctx context.Context, participantContextID string, credentialID string) error { - accessToken, err := i.TokenProvider.GetToken(ctx, ScopeApiAdmin, participantContextID) +func (i HttpApiClient) RevokeCredential(ctx context.Context, credentialID string) error { + accessToken, err := i.TokenProvider.GetToken(ctx, ScopeCredentialsWrite, i.IssuerID) if err != nil { return err } @@ -192,12 +194,12 @@ func (i HttpApiClient) RevokeCredential(ctx context.Context, participantContextI req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := i.HttpClient.Do(req) if err != nil { - return fmt.Errorf("failed to revoke credential with ID '%s' for participant context '%s': %w", credentialID, participantContextID, err) + return fmt.Errorf("failed to revoke credential with ID '%s' in issuer context '%s': %w", credentialID, i.IssuerID, err) } defer httpx.DrainAndClose(resp) if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to revoke credential with ID '%s' for participant context '%s': received status code %d with message [%s]", credentialID, participantContextID, resp.StatusCode, httpx.ReadErrorBody(resp)) + return fmt.Errorf("failed to revoke credential with ID '%s' in issuer context '%s': received status code %d with message [%s]", credentialID, i.IssuerID, resp.StatusCode, httpx.ReadErrorBody(resp)) } return nil } diff --git a/agent/common/issuerservice/issuer_test.go b/agent/common/issuerservice/issuer_test.go index b52f0d44..bcc4912c 100644 --- a/agent/common/issuerservice/issuer_test.go +++ b/agent/common/issuerservice/issuer_test.go @@ -53,7 +53,7 @@ func TestHttpApiClient_CreateHolder(t *testing.T) { defer server.Close() tp := mocks.NewMockTokenProvider(t) - tp.On("GetToken", mock.Anything, mock.Anything, mock.Anything).Return("test token", nil) + tp.On("GetToken", mock.Anything, ScopeHoldersWrite, "test-issuer").Return("test token", nil) client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, @@ -61,7 +61,7 @@ func TestHttpApiClient_CreateHolder(t *testing.T) { HttpClient: &http.Client{}, } - err := client.CreateHolder(t.Context(), "test-context-id", "did:web:test-participant", "did:web:test-participant", "test holder", nil) + err := client.CreateHolder(t.Context(), "did:web:test-participant", "did:web:test-participant", "test holder", nil) require.NoError(t, err) } @@ -76,7 +76,7 @@ func TestHttpApiClient_CreateHolder_AuthError(t *testing.T) { HttpClient: &http.Client{}, } - err := client.CreateHolder(t.Context(), "test-context-id", "did:web:test-participant", "did:web:test-participant", "test holder", nil) + err := client.CreateHolder(t.Context(), "did:web:test-participant", "did:web:test-participant", "test holder", nil) require.ErrorContains(t, err, "test error") } @@ -102,7 +102,7 @@ func TestHttpApiClient_CreateHolder_ApiReturnsError(t *testing.T) { HttpClient: &http.Client{}, } - err := client.CreateHolder(t.Context(), "", "did:web:test-participant", "did:web:test-participant", "test holder", nil) + err := client.CreateHolder(t.Context(), "did:web:test-participant", "did:web:test-participant", "test holder", nil) require.ErrorContains(t, err, "failed to create Holder") } @@ -117,7 +117,7 @@ func TestHttpApiClient_RevokeCredential(t *testing.T) { defer server.Close() tp := mocks.NewMockTokenProvider(t) - tp.On("GetToken", mock.Anything, ScopeApiAdmin, "test-participant").Return("test token", nil) + tp.On("GetToken", mock.Anything, ScopeCredentialsWrite, "test-issuer").Return("test token", nil) client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, @@ -125,7 +125,7 @@ func TestHttpApiClient_RevokeCredential(t *testing.T) { HttpClient: &http.Client{}, } - err := client.RevokeCredential(t.Context(), "test-participant", "test-credential-id") + err := client.RevokeCredential(t.Context(), "test-credential-id") require.NoError(t, err) } @@ -150,7 +150,7 @@ func TestHttpApiClient_RevokeCredential_ClientError(t *testing.T) { HttpClient: &http.Client{}, } - err := client.RevokeCredential(t.Context(), "did:web:test-participant", "test-credential-id") + err := client.RevokeCredential(t.Context(), "test-credential-id") require.ErrorContains(t, err, "failed to revoke credential") } @@ -175,7 +175,7 @@ func TestHttpApiClient_RevokeCredential_NotFound(t *testing.T) { HttpClient: &http.Client{}, } - err := client.RevokeCredential(t.Context(), "did:web:test-participant", "test-credential-id") + err := client.RevokeCredential(t.Context(), "test-credential-id") require.ErrorContains(t, err, "failed to revoke credential") } @@ -192,7 +192,7 @@ func TestHttpApiClient_DeleteHolder(t *testing.T) { defer server.Close() tp := mocks.NewMockTokenProvider(t) - tp.On("GetToken", mock.Anything, mock.Anything, mock.Anything).Return("test token", nil) + tp.On("GetToken", mock.Anything, ScopeHoldersWrite, "test-issuer").Return("test token", nil) client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, @@ -200,7 +200,7 @@ func TestHttpApiClient_DeleteHolder(t *testing.T) { HttpClient: &http.Client{}, } - err := client.DeleteHolder(t.Context(), "", "did:web:test-participant") + err := client.DeleteHolder(t.Context(), "did:web:test-participant") require.NoError(t, err) } @@ -225,7 +225,7 @@ func TestHttpApiClient_DeleteHolder_NotFound(t *testing.T) { HttpClient: &http.Client{}, } - err := client.DeleteHolder(t.Context(), "", "did:web:test-participant") + err := client.DeleteHolder(t.Context(), "did:web:test-participant") require.ErrorContains(t, err, "received status code 404") } @@ -250,7 +250,7 @@ func TestHttpApiClient_DeleteHolder_AuthError(t *testing.T) { HttpClient: &http.Client{}, } - err := client.DeleteHolder(t.Context(), "", "did:web:test-participant") + err := client.DeleteHolder(t.Context(), "did:web:test-participant") require.ErrorContains(t, err, "received status code 401") } @@ -294,7 +294,7 @@ func TestHttpApiClient_QueryCredentialsByType(t *testing.T) { defer server.Close() tp := mocks.NewMockTokenProvider(t) - tp.On("GetToken", mock.Anything, ScopeApiAdmin, "test-participant").Return("test token", nil) + tp.On("GetToken", mock.Anything, ScopeCredentialsRead, "test-issuer").Return("test token", nil) client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, @@ -302,7 +302,7 @@ func TestHttpApiClient_QueryCredentialsByType(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") require.NoError(t, err) require.Len(t, result, 1) assert.Equal(t, "cred-1", result[0].ID) @@ -319,7 +319,7 @@ func TestHttpApiClient_QueryCredentialsByType_AuthError(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") require.ErrorContains(t, err, "auth error") require.Nil(t, result) } @@ -345,7 +345,7 @@ func TestHttpApiClient_QueryCredentialsByType_ApiReturnsError(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") require.ErrorContains(t, err, "failed to query credentials on IssuerService") require.Nil(t, result) } @@ -373,7 +373,7 @@ func TestHttpApiClient_QueryCredentialsByType_InvalidJson(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") require.ErrorContains(t, err, "error parsing credentials response") require.Nil(t, result) } @@ -394,5 +394,5 @@ func TestHttpApiClient_RevokeCredential_EscapesPathSegments(t *testing.T) { HttpClient: &http.Client{}, } - require.NoError(t, client.RevokeCredential(t.Context(), "test-participant", "a/b")) + require.NoError(t, client.RevokeCredential(t.Context(), "a/b")) } diff --git a/agent/orchestration/jwtletagent/activity/activity.go b/agent/orchestration/jwtletagent/activity/activity.go index eef824c6..642d2fba 100644 --- a/agent/orchestration/jwtletagent/activity/activity.go +++ b/agent/orchestration/jwtletagent/activity/activity.go @@ -28,7 +28,6 @@ import ( "github.com/eclipse-cfm/cfm/agent/common/controlplane" "github.com/eclipse-cfm/cfm/agent/common/identityhub" - "github.com/eclipse-cfm/cfm/agent/common/issuerservice" "github.com/eclipse-cfm/cfm/agent/common/siglet" "github.com/eclipse-cfm/cfm/common/model" "github.com/eclipse-cfm/cfm/common/system" @@ -85,7 +84,6 @@ var agentScopes = []string{ identityhub.ScopeApiAdmin, identityhub.ScopeCredentialsRead, identityhub.ScopeCredentialsWrite, - issuerservice.ScopeApiAdmin, siglet.ScopeApiRead, siglet.ScopeApiWrite, "read", diff --git a/agent/orchestration/onboarding/activity/activity.go b/agent/orchestration/onboarding/activity/activity.go index 1e4aaf33..a594cd99 100644 --- a/agent/orchestration/onboarding/activity/activity.go +++ b/agent/orchestration/onboarding/activity/activity.go @@ -93,7 +93,7 @@ func (p OnboardingActivityProcessor) ProcessDispose(ctx api.ActivityContext) api for _, spec := range data.CredentialSpecs { credentialType := spec.Type - credentials, err := p.IssuerServiceApiClient.QueryCredentialsByType(ctx.Context(), participantContextID, holder.HolderID, credentialType) + credentials, err := p.IssuerServiceApiClient.QueryCredentialsByType(ctx.Context(), holder.HolderID, credentialType) if err != nil { return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error querying credentials by type %s for participant context %s: %w", credentialType, participantContextID, err)} @@ -106,7 +106,7 @@ func (p OnboardingActivityProcessor) ProcessDispose(ctx api.ActivityContext) api // for each credential, send a revocation request for _, credentialDto := range credentials { - err := p.IssuerServiceApiClient.RevokeCredential(ctx.Context(), participantContextID, credentialDto.ID) + err := p.IssuerServiceApiClient.RevokeCredential(ctx.Context(), credentialDto.ID) if err != nil { revocationErrors = append(revocationErrors, err) } diff --git a/agent/orchestration/onboarding/activity/activity_test.go b/agent/orchestration/onboarding/activity/activity_test.go index 32432186..13793a81 100644 --- a/agent/orchestration/onboarding/activity/activity_test.go +++ b/agent/orchestration/onboarding/activity/activity_test.go @@ -569,7 +569,7 @@ type MockIssuerServiceApiClient struct { queriedHolderIDs *[]string } -func (m MockIssuerServiceApiClient) QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { +func (m MockIssuerServiceApiClient) QueryCredentialsByType(ctx context.Context, holderID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { if m.queriedHolderIDs != nil { *m.queriedHolderIDs = append(*m.queriedHolderIDs, holderID) } @@ -580,16 +580,16 @@ func (m MockIssuerServiceApiClient) QueryCredentialsByType(ctx context.Context, } -func (m MockIssuerServiceApiClient) DeleteHolder(ctx context.Context, participantContextID string, holderID string) error { +func (m MockIssuerServiceApiClient) DeleteHolder(ctx context.Context, holderID string) error { //TODO implement me panic("implement me") } -func (m MockIssuerServiceApiClient) CreateHolder(ctx context.Context, participantContextID string, did string, holderID string, name string, properties map[string]any) error { +func (m MockIssuerServiceApiClient) CreateHolder(ctx context.Context, did string, holderID string, name string, properties map[string]any) error { return m.expectedError } -func (m MockIssuerServiceApiClient) RevokeCredential(ctx context.Context, participantContextID string, credentialID string) error { +func (m MockIssuerServiceApiClient) RevokeCredential(ctx context.Context, credentialID string) error { if m.revokedIDs != nil { *m.revokedIDs = append(*m.revokedIDs, credentialID) } diff --git a/agent/orchestration/registration/activity/activity.go b/agent/orchestration/registration/activity/activity.go index 53884468..156d82d3 100644 --- a/agent/orchestration/registration/activity/activity.go +++ b/agent/orchestration/registration/activity/activity.go @@ -66,7 +66,7 @@ func (p RegistrationActivityProcessor) ProcessDeploy(ctx api.ActivityContext) ap span.RecordError(err) return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error reading vpa data: %w", err)} } - if err := p.IssuerService.CreateHolder(ctx.Context(), regData.ParticipantContextId, regData.DID, holderID, regData.HolderName, properties); err != nil { + if err := p.IssuerService.CreateHolder(ctx.Context(), regData.DID, holderID, regData.HolderName, properties); err != nil { span.RecordError(err) // todo: inspect error if it is retryable return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error creating holder in ApiClient: %w", err)} @@ -81,7 +81,7 @@ func (p RegistrationActivityProcessor) ProcessDispose(ctx api.ActivityContext) a return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error processing Registration activity for orchestration %s: %w", ctx.OID(), err)} } holderID := regData.DID - if err := p.IssuerService.DeleteHolder(ctx.Context(), regData.ParticipantContextId, holderID); err != nil { + if err := p.IssuerService.DeleteHolder(ctx.Context(), holderID); err != nil { // todo: inspect error if it is retryable return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("registration rollback: error deleting holder in IssuerService: %w", err)} } diff --git a/agent/orchestration/registration/activity/activity_test.go b/agent/orchestration/registration/activity/activity_test.go index f0782b4e..240bb830 100644 --- a/agent/orchestration/registration/activity/activity_test.go +++ b/agent/orchestration/registration/activity/activity_test.go @@ -386,19 +386,19 @@ type MockIssuerService struct { recorded regData } -func (m *MockIssuerService) QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { +func (m *MockIssuerService) QueryCredentialsByType(ctx context.Context, holderID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { return nil, nil } -func (m *MockIssuerService) DeleteHolder(ctx context.Context, participantContextID string, holderID string) error { +func (m *MockIssuerService) DeleteHolder(ctx context.Context, holderID string) error { return m.expectedError } -func (m *MockIssuerService) RevokeCredential(ctx context.Context, participantContextID string, credentialID string) error { +func (m *MockIssuerService) RevokeCredential(ctx context.Context, credentialID string) error { return nil } -func (m *MockIssuerService) CreateHolder(ctx context.Context, participantContextID string, did string, holderID string, name string, properties map[string]any) error { +func (m *MockIssuerService) CreateHolder(ctx context.Context, did string, holderID string, name string, properties map[string]any) error { m.recorded.did = did m.recorded.holderID = holderID m.recorded.name = name