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