From 8d0fcacc03eae4cf2fba346954d0c3e756f1ad20 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:12 +0200 Subject: [PATCH 1/5] Initialize branch for PR From c7fd6ae6a62547a861cbeb093b46bbfb5486c696 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 16:54:09 +0200 Subject: [PATCH 2/5] Rename PresentationDefinitions to FindCredentialProfile Rename the PDPBackend interface method and introduce new types (CredentialProfileMatch, ScopePolicy, credentialProfileConfig) to support mixed OAuth2 scopes. The policy config struct now uses explicit fields for organization/user PDs and scope_policy, defaulting to profile-only. All callers and mocks updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api.go | 4 +- auth/api/iam/api_test.go | 10 +-- auth/api/iam/openid4vp_test.go | 6 +- auth/api/iam/s2s_vptoken_test.go | 24 +++---- auth/api/iam/validation.go | 4 +- policy/cmd.go | 1 + policy/config.go | 8 +++ policy/interface.go | 37 +++++++++-- policy/local.go | 110 +++++++++++++++++++++++-------- policy/local_test.go | 24 ++++--- policy/mock.go | 15 ++--- 11 files changed, 167 insertions(+), 76 deletions(-) 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..c36f29ad91 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -79,7 +79,7 @@ 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) { return nil, oauth.OAuth2Error{ @@ -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..f44ff740a2 100644 --- a/policy/config.go +++ b/policy/config.go @@ -22,6 +22,14 @@ 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..95c83cbec7 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -29,10 +29,37 @@ const ModuleName = "policy" var ErrNotFound = errors.New("not found") -// PDPBackend is the interface for the policy backend -// Both the remote and local policy backend implement this interface +// 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..26082ea807 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,33 @@ 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 +func (b *LocalPDP) FindCredentialProfile(_ context.Context, scope string) (*CredentialProfileMatch, error) { + var profileScope string + var profile credentialProfileConfig + var otherScopes []string + for _, s := range strings.Split(scope, " ") { + if s == "" { + continue + } + if p, exists := b.mapping[s]; exists { + if profileScope != "" { + return nil, fmt.Errorf("%w: multiple credential profile scopes found", ErrNotFound) + } + 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 +149,64 @@ 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.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..bba316d8f2 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -54,24 +54,27 @@ 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) }) } @@ -88,8 +91,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 +101,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) } From 2d16cf7bcfcf453f735ff4a0ab1c772bf1748135 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 16:56:41 +0200 Subject: [PATCH 3/5] Add tests for multi-scope parsing in FindCredentialProfile Tests cover: multi-scope with one profile scope + other scopes, multiple profile scopes (error), no profile scope (error), and empty scope string (error). Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/local_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/policy/local_test.go b/policy/local_test.go index bba316d8f2..f1ca3dc109 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -76,6 +76,44 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { 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, []string{"patient/Observation.read", "launch/patient"}, 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, ErrNotFound) + assert.ErrorContains(t, err, "multiple credential profile scopes") + }) + 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 Test_LocalPDP_loadFromDirectory(t *testing.T) { From a0edb159bf84126e24b45e7faf0d0ac092bc1fbc Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 17:00:01 +0200 Subject: [PATCH 4/5] Add scope_policy config parsing and startup validation tests Tests cover: scope_policy parsed from JSON config (dynamic, passthrough), invalid scope_policy rejected at load time, dynamic without AuthZen endpoint fails at startup, passthrough without endpoint succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/local_test.go | 62 +++++++++++++++++++ policy/test/scope_policy/dynamic.json | 21 +++++++ policy/test/scope_policy/passthrough.json | 21 +++++++ policy/test/scope_policy_invalid/invalid.json | 21 +++++++ 4 files changed, 125 insertions(+) create mode 100644 policy/test/scope_policy/dynamic.json create mode 100644 policy/test/scope_policy/passthrough.json create mode 100644 policy/test/scope_policy_invalid/invalid.json diff --git a/policy/local_test.go b/policy/local_test.go index f1ca3dc109..e70e13e88a 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" @@ -116,6 +117,67 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { }) } +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{config: Config{Directory: "test/scope_policy"}} + + 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{ + Directory: "test/scope_policy", + AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, + }} + + 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) + }) +} + func Test_LocalPDP_loadFromDirectory(t *testing.T) { t.Run("no files", func(t *testing.T) { store := LocalPDP{} 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" + } +} From ba9c58b9272ed70a37a1840aeeff61e39c3c00fa Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 17:21:35 +0200 Subject: [PATCH 5/5] Apply self-review fixes - Introduce ErrAmbiguousScope for multiple credential profile scopes (instead of wrapping ErrNotFound which was semantically wrong) - Use strings.Fields instead of strings.Split for robust whitespace handling - Add nil-check: credential profile must define at least one of organization/user - Add doc comments on Config, ErrNotFound, FindCredentialProfile implementation - Use value receiver on toWalletOwnerMapping (small non-mutating struct) - Add test for consecutive spaces in scope string - Assert ScopePolicy in multi-scope test - Make Configure tests load single files instead of whole directory Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/validation.go | 2 +- policy/config.go | 1 + policy/interface.go | 6 +++++- policy/local.go | 13 +++++++------ policy/local_test.go | 28 +++++++++++++++++++++------- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go index c36f29ad91..2d19128a59 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -81,7 +81,7 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { 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, diff --git a/policy/config.go b/policy/config.go index f44ff740a2..21baa12b68 100644 --- a/policy/config.go +++ b/policy/config.go @@ -18,6 +18,7 @@ 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 diff --git a/policy/interface.go b/policy/interface.go index 95c83cbec7..00ab47661d 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -24,11 +24,15 @@ 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") +// 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 diff --git a/policy/local.go b/policy/local.go index 26082ea807..e5efbddea6 100644 --- a/policy/local.go +++ b/policy/local.go @@ -89,17 +89,15 @@ func (b *LocalPDP) Config() interface{} { return &b.config } +// 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.Split(scope, " ") { - if s == "" { - continue - } + for _, s := range strings.Fields(scope) { if p, exists := b.mapping[s]; exists { if profileScope != "" { - return nil, fmt.Errorf("%w: multiple credential profile scopes found", ErrNotFound) + return nil, ErrAmbiguousScope } profileScope = s profile = p @@ -175,6 +173,9 @@ func (b *LocalPDP) loadFromFile(filename string) error { 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) } @@ -190,7 +191,7 @@ type credentialProfileConfig struct { ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } -func (c *credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { +func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { m := pe.WalletOwnerMapping{} if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) diff --git a/policy/local_test.go b/policy/local_test.go index e70e13e88a..b25e931436 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -87,8 +87,20 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { 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") @@ -96,8 +108,7 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { _, err = store.FindCredentialProfile(context.Background(), "1 2") - assert.ErrorIs(t, err, ErrNotFound) - assert.ErrorContains(t, err, "multiple credential profile scopes") + assert.ErrorIs(t, err, ErrAmbiguousScope) }) t.Run("err - no credential profile scope", func(t *testing.T) { store := LocalPDP{} @@ -149,19 +160,22 @@ func TestLocalPDP_ScopePolicyConfig(t *testing.T) { func TestLocalPDP_Configure(t *testing.T) { t.Run("dynamic scope_policy without AuthZen endpoint fails", func(t *testing.T) { - store := LocalPDP{config: Config{Directory: "test/scope_policy"}} + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) - err := store.Configure(core.ServerConfig{}) + 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{ - Directory: "test/scope_policy", - AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, + AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, }} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) - err := store.Configure(core.ServerConfig{}) + err = store.Configure(core.ServerConfig{}) assert.NoError(t, err) })