diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index bedbba113d..6b8125bf02 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -703,7 +703,7 @@ func (r Wrapper) PresentationDefinition(ctx context.Context, request Presentatio return PresentationDefinition200JSONResponse(PresentationDefinition{}), nil } - mapping, err := r.policyBackend.PresentationDefinitions(ctx, request.Params.Scope) + match, err := r.policyBackend.FindCredentialProfile(ctx, request.Params.Scope) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.InvalidScope, @@ -715,7 +715,7 @@ func (r Wrapper) PresentationDefinition(ctx context.Context, request Presentatio if request.Params.WalletOwnerType != nil { walletOwnerType = *request.Params.WalletOwnerType } - result, exists := mapping[walletOwnerType] + result, exists := match.WalletOwnerMapping[walletOwnerType] if !exists { return nil, oauthError(oauth.InvalidRequest, fmt.Sprintf("no presentation definition found for '%s' wallet", walletOwnerType)) } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5be4de20e1..ba9be2cd51 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -190,7 +190,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "example-scope").Return(walletOwnerMapping, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope"}}) @@ -215,7 +215,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerUser: pe.PresentationDefinition{Id: "test"}} test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "example-scope").Return(walletOwnerMapping, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -227,7 +227,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("err - unknown wallet type", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "example-scope").Return(walletOwnerMapping, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -238,7 +238,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("error - unknown scope", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "unknown").Return(nil, policy.ErrNotFound) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "unknown").Return(nil, policy.ErrNotFound) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "unknown"}}) @@ -289,7 +289,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { OpenIDProvider: serverMetadata, }, } - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.iamClient.EXPECT().OpenIDConfiguration(gomock.Any(), holderURL.String()).Return(&configuration, nil) ctx.jar.EXPECT().Create(verifierDID, verifierURL.String(), holderClientID, gomock.Any()).DoAndReturn(func(client did.DID, clientID string, audience string, modifier requestObjectModifier) jarRequest { req := createJarRequest(client, clientID, audience, modifier) diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 1f0135363c..52e73713ff 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -107,7 +107,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("unknown scope", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(pe.WalletOwnerMapping{}, policy.ErrNotFound) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) params := defaultParams() params[oauth.ScopeParam] = "unknown" @@ -126,7 +126,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to generate authorization request", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(&oauth.OpenIDConfiguration{ Metadata: oauth.EntityStatementMetadata{ @@ -142,7 +142,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to resolve OpenID configuration", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(nil, assert.AnError) diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 7cc4504b7c..a8f9e0ed22 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -118,7 +118,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -165,7 +165,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID)) }, verifiableCredential) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -200,7 +200,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("replay attack (nonce is reused)", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil).Times(2) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil).Times(2) _, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) @@ -211,7 +211,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = nil @@ -224,7 +224,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = new(string) @@ -237,7 +237,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Remove("nonce") @@ -249,7 +249,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", "") @@ -261,7 +261,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP nonce is not a string", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", true) @@ -297,7 +297,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("VP verification fails", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -342,7 +342,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("unsupported scope", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "everything").Return(nil, policy.ErrNotFound) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "everything").Return(nil, policy.ErrNotFound) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, "everything", submissionJSON, presentation.Raw()) @@ -364,7 +364,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential) ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)") @@ -375,7 +375,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}} httpRequest.Header.Set("DPoP", "invalid") contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go index 7809a3ab4f..2d19128a59 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -79,9 +79,9 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta } func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { - mapping, err := r.policyBackend.PresentationDefinitions(ctx, scope) + match, err := r.policyBackend.FindCredentialProfile(ctx, scope) if err != nil { - if errors.Is(err, policy.ErrNotFound) { + if errors.Is(err, policy.ErrNotFound) || errors.Is(err, policy.ErrAmbiguousScope) { return nil, oauth.OAuth2Error{ Code: oauth.InvalidScope, InternalError: err, @@ -94,5 +94,5 @@ func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope strin Description: fmt.Sprintf("failed to retrieve presentation definition for scope (%s): %s", scope, err.Error()), } } - return mapping, err + return match.WalletOwnerMapping, nil } diff --git a/policy/cmd.go b/policy/cmd.go index 9110d48ac0..b339d06e00 100644 --- a/policy/cmd.go +++ b/policy/cmd.go @@ -27,5 +27,6 @@ func FlagSet() *pflag.FlagSet { defCfg := defaultConfig() flagSet := pflag.NewFlagSet("policy", pflag.ContinueOnError) flagSet.String("policy.directory", defCfg.Directory, "Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping.") + flagSet.String("policy.authzen.endpoint", defCfg.AuthZen.Endpoint, "Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'.") return flagSet } diff --git a/policy/config.go b/policy/config.go index 6cf98eb173..21baa12b68 100644 --- a/policy/config.go +++ b/policy/config.go @@ -18,10 +18,19 @@ package policy +// Config holds the configuration for the policy module. type Config struct { // Directory is the directory where the policy files are stored // policy files include a scope to presentation definition mapping Directory string `koanf:"directory"` + // AuthZen contains configuration for the AuthZen PDP integration + AuthZen AuthZenConfig `koanf:"authzen"` +} + +// AuthZenConfig contains configuration for an AuthZen-compatible PDP endpoint. +type AuthZenConfig struct { + // Endpoint is the base URL of the AuthZen PDP + Endpoint string `koanf:"endpoint"` } func defaultConfig() Config { diff --git a/policy/interface.go b/policy/interface.go index 993815c269..00ab47661d 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -24,15 +24,46 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" ) -// ModuleName is the name of the policy module +// ModuleName is the name of the policy module. const ModuleName = "policy" +// ErrNotFound is returned when no credential profile matches the requested scope. var ErrNotFound = errors.New("not found") -// PDPBackend is the interface for the policy backend -// Both the remote and local policy backend implement this interface +// ErrAmbiguousScope is returned when multiple credential profile scopes are found in a single request. +var ErrAmbiguousScope = errors.New("multiple credential profile scopes found") + +// ScopePolicy defines how extra scopes (beyond the credential profile scope) are handled. +type ScopePolicy string + +const ( + // ScopePolicyProfileOnly only accepts the credential profile scope. Extra scopes cause an error. + ScopePolicyProfileOnly ScopePolicy = "profile-only" + // ScopePolicyPassthrough grants all requested scopes without evaluation. + ScopePolicyPassthrough ScopePolicy = "passthrough" + // ScopePolicyDynamic evaluates extra scopes via an external AuthZen PDP. + ScopePolicyDynamic ScopePolicy = "dynamic" +) + +// CredentialProfileMatch is the result of matching a scope string against the policy configuration. +// It contains the matched credential profile (WalletOwnerMapping + ScopePolicy) and the +// remaining scopes that did not match any credential profile. +type CredentialProfileMatch struct { + // CredentialProfileScope is the scope that matched a credential profile. + CredentialProfileScope string + // WalletOwnerMapping contains the PresentationDefinitions per wallet owner type for the matched credential profile. + WalletOwnerMapping pe.WalletOwnerMapping + // ScopePolicy is the configured scope policy for the matched credential profile. + ScopePolicy ScopePolicy + // OtherScopes contains the scopes from the request that did not match any credential profile. + OtherScopes []string +} + +// PDPBackend is the interface for the policy backend. +// Both the remote and local policy backend implement this interface. type PDPBackend interface { - // PresentationDefinitions returns the PresentationDefinitions (mapped to a WalletOwnerType) for the given scope - // scopes are space delimited. It's up to the backend to decide how to handle this - PresentationDefinitions(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) + // FindCredentialProfile resolves a scope string against the policy configuration. + // It parses the space-delimited scope string, identifies exactly one credential profile scope, + // and returns the matched profile along with any remaining scopes. + FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) } diff --git a/policy/local.go b/policy/local.go index 81719eb7d8..e5efbddea6 100644 --- a/policy/local.go +++ b/policy/local.go @@ -30,6 +30,15 @@ import ( "strings" ) +func (sp ScopePolicy) valid() bool { + switch sp { + case ScopePolicyProfileOnly, ScopePolicyPassthrough, ScopePolicyDynamic: + return true + default: + return false + } +} + var _ PDPBackend = (*LocalPDP)(nil) // New creates a new local policy backend @@ -37,13 +46,12 @@ func New() *LocalPDP { return &LocalPDP{} } -// LocalPDP is a backend for presentation definitions -// It loads a file with the mapping from oauth scope to PEX Policy. -// It allows access when the requester can present a submission according to the Presentation Definition. +// LocalPDP is a backend for presentation definitions. +// It loads policy files that map OAuth scopes to credential profiles (PresentationDefinitions + scope policy). type LocalPDP struct { config Config - // mapping holds the oauth scope to PEX Policy mapping - mapping map[string]validatingWalletOwnerMapping + // mapping holds the credential profile configuration per scope + mapping map[string]credentialProfileConfig } func (b *LocalPDP) Name() string { @@ -67,7 +75,13 @@ func (b *LocalPDP) Configure(_ core.ServerConfig) error { return fmt.Errorf("failed to load policy from directory: %w", err) } } - + if b.config.AuthZen.Endpoint == "" { + for scope, profile := range b.mapping { + if profile.ScopePolicy == ScopePolicyDynamic { + return fmt.Errorf("credential profile %q has scope_policy %q but no AuthZen endpoint is configured (policy.authzen.endpoint)", scope, ScopePolicyDynamic) + } + } + } return nil } @@ -75,16 +89,31 @@ func (b *LocalPDP) Config() interface{} { return &b.config } -func (b *LocalPDP) PresentationDefinitions(_ context.Context, scope string) (pe.WalletOwnerMapping, error) { - result := pe.WalletOwnerMapping{} - mapping, exists := b.mapping[scope] - if !exists { - return nil, ErrNotFound +// FindCredentialProfile implements PDPBackend. +func (b *LocalPDP) FindCredentialProfile(_ context.Context, scope string) (*CredentialProfileMatch, error) { + var profileScope string + var profile credentialProfileConfig + var otherScopes []string + for _, s := range strings.Fields(scope) { + if p, exists := b.mapping[s]; exists { + if profileScope != "" { + return nil, ErrAmbiguousScope + } + profileScope = s + profile = p + } else { + otherScopes = append(otherScopes, s) + } } - for walletOwnerType, policy := range mapping { - result[walletOwnerType] = policy + if profileScope == "" { + return nil, ErrNotFound } - return result, nil + return &CredentialProfileMatch{ + CredentialProfileScope: profileScope, + WalletOwnerMapping: profile.toWalletOwnerMapping(), + ScopePolicy: profile.ScopePolicy, + OtherScopes: otherScopes, + }, nil } // loadFromDirectory traverses all .json files in the given directory and loads them @@ -118,43 +147,67 @@ func (b *LocalPDP) loadFromDirectory(directory string) error { return nil } -// LoadFromFile loads the mapping from the given file func (b *LocalPDP) loadFromFile(filename string) error { - // read the bytes from the file reader, err := os.Open(filename) if err != nil { return err } defer reader.Close() - bytes, err := io.ReadAll(reader) + data, err := io.ReadAll(reader) if err != nil { return err } - // unmarshal the bytes into the mapping - result := make(map[string]validatingWalletOwnerMapping) - err = json.Unmarshal(bytes, &result) - if err != nil { + result := make(map[string]credentialProfileConfig) + if err = json.Unmarshal(data, &result); err != nil { return fmt.Errorf("failed to unmarshal PEX Policy mapping file %s: %w", filename, err) } if b.mapping == nil { - b.mapping = make(map[string]validatingWalletOwnerMapping) + b.mapping = make(map[string]credentialProfileConfig) } - for scope, defs := range result { + for scope, profile := range result { if _, exists := b.mapping[scope]; exists { return fmt.Errorf("mapping for scope '%s' already exists (file=%s)", scope, filename) } - b.mapping[scope] = defs + // Default to profile-only when scope_policy is not specified + if profile.ScopePolicy == "" { + profile.ScopePolicy = ScopePolicyProfileOnly + } + if profile.Organization == nil && profile.User == nil { + return fmt.Errorf("credential profile %q must define at least one of 'organization' or 'user' (file=%s)", scope, filename) + } + if !profile.ScopePolicy.valid() { + return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) + } + b.mapping[scope] = profile } return nil } -// validatingPresentationDefinition is an alias for PresentationDefinition that validates the JSON on unmarshal. -type validatingWalletOwnerMapping pe.WalletOwnerMapping +// credentialProfileConfig holds the configuration for a single credential profile. +type credentialProfileConfig struct { + Organization *validatingPresentationDefinition `json:"organization,omitempty"` + User *validatingPresentationDefinition `json:"user,omitempty"` + ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` +} + +func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { + m := pe.WalletOwnerMapping{} + if c.Organization != nil { + m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) + } + if c.User != nil { + m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) + } + return m +} + +// validatingPresentationDefinition validates the PresentationDefinition against the v2 JSON schema on unmarshal. +type validatingPresentationDefinition pe.PresentationDefinition -func (v *validatingWalletOwnerMapping) UnmarshalJSON(data []byte) error { - if err := v2.Validate(data, v2.WalletOwnerMapping); err != nil { +func (v *validatingPresentationDefinition) UnmarshalJSON(data []byte) error { + if err := v2.Validate(data, v2.PresentationDefinition); err != nil { return err } - return json.Unmarshal(data, (*pe.WalletOwnerMapping)(v)) + return json.Unmarshal(data, (*pe.PresentationDefinition)(v)) } diff --git a/policy/local_test.go b/policy/local_test.go index ca0a357a11..b25e931436 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -20,6 +20,7 @@ package policy import ( "context" + "github.com/nuts-foundation/nuts-node/core" "testing" "github.com/stretchr/testify/assert" @@ -54,24 +55,140 @@ func TestStore_LoadFromFile(t *testing.T) { }) } -func TestStore_PresentationDefinitions(t *testing.T) { +func TestLocalPDP_FindCredentialProfile(t *testing.T) { t.Run("err - not found", func(t *testing.T) { store := LocalPDP{} - _, err := store.PresentationDefinitions(context.Background(), "example-scope2") + _, err := store.FindCredentialProfile(context.Background(), "unknown-scope") - assert.Equal(t, ErrNotFound, err) + assert.ErrorIs(t, err, ErrNotFound) }) - t.Run("returns the presentation definition if the scope exists", func(t *testing.T) { + t.Run("returns match for existing scope", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) - result, err := store.PresentationDefinitions(context.Background(), "example-scope") + match, err := store.FindCredentialProfile(context.Background(), "example-scope") require.NoError(t, err) - assert.NotNil(t, result) + assert.Equal(t, "example-scope", match.CredentialProfileScope) + assert.NotNil(t, match.WalletOwnerMapping) + assert.Equal(t, ScopePolicyProfileOnly, match.ScopePolicy) + assert.Empty(t, match.OtherScopes) + }) + t.Run("multi-scope with one profile scope returns match and other scopes", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/definition_mapping.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "example-scope patient/Observation.read launch/patient") + + require.NoError(t, err) + assert.Equal(t, "example-scope", match.CredentialProfileScope) + assert.NotNil(t, match.WalletOwnerMapping) + assert.Equal(t, ScopePolicyProfileOnly, match.ScopePolicy) + assert.Equal(t, []string{"patient/Observation.read", "launch/patient"}, match.OtherScopes) + }) + t.Run("handles consecutive spaces and whitespace in scope string", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/definition_mapping.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), " example-scope extra ") + + require.NoError(t, err) + assert.Equal(t, "example-scope", match.CredentialProfileScope) + assert.Equal(t, []string{"extra"}, match.OtherScopes) + }) + t.Run("err - multiple credential profile scopes", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromDirectory("test/2_files") + require.NoError(t, err) + + _, err = store.FindCredentialProfile(context.Background(), "1 2") + + assert.ErrorIs(t, err, ErrAmbiguousScope) + }) + t.Run("err - no credential profile scope", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/definition_mapping.json") + require.NoError(t, err) + + _, err = store.FindCredentialProfile(context.Background(), "unknown-a unknown-b") + + assert.ErrorIs(t, err, ErrNotFound) + }) + t.Run("err - empty scope string", func(t *testing.T) { + store := LocalPDP{} + + _, err := store.FindCredentialProfile(context.Background(), "") + + assert.ErrorIs(t, err, ErrNotFound) + }) +} + +func TestLocalPDP_ScopePolicyConfig(t *testing.T) { + t.Run("scope_policy parsed from config", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "dynamic-scope") + + require.NoError(t, err) + assert.Equal(t, ScopePolicyDynamic, match.ScopePolicy) + }) + t.Run("passthrough scope_policy parsed from config", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/passthrough.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "passthrough-scope") + + require.NoError(t, err) + assert.Equal(t, ScopePolicyPassthrough, match.ScopePolicy) + }) + t.Run("invalid scope_policy rejected at load time", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/scope_policy_invalid/invalid.json") + + assert.ErrorContains(t, err, `invalid scope_policy "bogus"`) + }) +} + +func TestLocalPDP_Configure(t *testing.T) { + t.Run("dynamic scope_policy without AuthZen endpoint fails", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) + + err = store.Configure(core.ServerConfig{}) + + assert.ErrorContains(t, err, "no AuthZen endpoint is configured") + }) + t.Run("dynamic scope_policy with AuthZen endpoint succeeds", func(t *testing.T) { + store := LocalPDP{config: Config{ + AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, + }} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) + + err = store.Configure(core.ServerConfig{}) + + assert.NoError(t, err) + }) + t.Run("passthrough scope_policy without AuthZen endpoint succeeds", func(t *testing.T) { + store := LocalPDP{config: Config{Directory: "test/scope_policy"}} + // Load only the passthrough config, not the dynamic one + store.config.Directory = "" + err := store.loadFromFile("test/scope_policy/passthrough.json") + require.NoError(t, err) + + err = store.Configure(core.ServerConfig{}) + + assert.NoError(t, err) }) } @@ -88,8 +205,9 @@ func Test_LocalPDP_loadFromDirectory(t *testing.T) { err := store.loadFromDirectory("test") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "example-scope") + match, err := store.FindCredentialProfile(context.Background(), "example-scope") require.NoError(t, err) + assert.Equal(t, "example-scope", match.CredentialProfileScope) }) t.Run("2 files, 3 scopes", func(t *testing.T) { store := LocalPDP{} @@ -97,11 +215,11 @@ func Test_LocalPDP_loadFromDirectory(t *testing.T) { err := store.loadFromDirectory("test/2_files") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "1") + _, err = store.FindCredentialProfile(context.Background(), "1") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "2") + _, err = store.FindCredentialProfile(context.Background(), "2") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "3") + _, err = store.FindCredentialProfile(context.Background(), "3") require.NoError(t, err) }) t.Run("2 files, duplicate scope", func(t *testing.T) { diff --git a/policy/mock.go b/policy/mock.go index a406fb20cf..8e05e7600b 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -13,7 +13,6 @@ import ( context "context" reflect "reflect" - pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -41,17 +40,17 @@ func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder { return m.recorder } -// PresentationDefinitions mocks base method. -func (m *MockPDPBackend) PresentationDefinitions(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { +// FindCredentialProfile mocks base method. +func (m *MockPDPBackend) FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PresentationDefinitions", ctx, scope) - ret0, _ := ret[0].(pe.WalletOwnerMapping) + ret := m.ctrl.Call(m, "FindCredentialProfile", ctx, scope) + ret0, _ := ret[0].(*CredentialProfileMatch) ret1, _ := ret[1].(error) return ret0, ret1 } -// PresentationDefinitions indicates an expected call of PresentationDefinitions. -func (mr *MockPDPBackendMockRecorder) PresentationDefinitions(ctx, scope any) *gomock.Call { +// FindCredentialProfile indicates an expected call of FindCredentialProfile. +func (mr *MockPDPBackendMockRecorder) FindCredentialProfile(ctx, scope any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinitions", reflect.TypeOf((*MockPDPBackend)(nil).PresentationDefinitions), ctx, scope) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindCredentialProfile", reflect.TypeOf((*MockPDPBackend)(nil).FindCredentialProfile), ctx, scope) } diff --git a/policy/test/scope_policy/dynamic.json b/policy/test/scope_policy/dynamic.json new file mode 100644 index 0000000000..c3efea205b --- /dev/null +++ b/policy/test/scope_policy/dynamic.json @@ -0,0 +1,21 @@ +{ + "dynamic-scope": { + "organization": { + "id": "pd_dynamic", + "input_descriptors": [ + { + "id": "id_org", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": {"type": "string", "const": "TestCredential"} + } + ] + } + } + ] + }, + "scope_policy": "dynamic" + } +} diff --git a/policy/test/scope_policy/passthrough.json b/policy/test/scope_policy/passthrough.json new file mode 100644 index 0000000000..95ca51d68a --- /dev/null +++ b/policy/test/scope_policy/passthrough.json @@ -0,0 +1,21 @@ +{ + "passthrough-scope": { + "organization": { + "id": "pd_passthrough", + "input_descriptors": [ + { + "id": "id_org", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": {"type": "string", "const": "TestCredential"} + } + ] + } + } + ] + }, + "scope_policy": "passthrough" + } +} diff --git a/policy/test/scope_policy_invalid/invalid.json b/policy/test/scope_policy_invalid/invalid.json new file mode 100644 index 0000000000..5db3bb3d8d --- /dev/null +++ b/policy/test/scope_policy_invalid/invalid.json @@ -0,0 +1,21 @@ +{ + "invalid-scope": { + "organization": { + "id": "pd_invalid", + "input_descriptors": [ + { + "id": "id_org", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": {"type": "string", "const": "TestCredential"} + } + ] + } + } + ] + }, + "scope_policy": "bogus" + } +}