diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index f502cbef2..5f0fa625d 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -599,7 +599,9 @@ func (uc *WorkflowContractUseCase) findAndValidatePolicy(ctx context.Context, at return nil, err } - remotePolicy, err := uc.GetPolicy(ctx, pr.Provider, pr.Name, pr.OrgName, "", token) + // The control plane is a non-CLI caller, so resolve the true latest revision + // and skip CLI-version compatibility gating. + remotePolicy, err := uc.GetPolicy(ctx, pr.Provider, pr.Name, pr.OrgName, "", token, policies.WithIgnoreCLICompatibility()) if err != nil { return nil, err } @@ -637,7 +639,9 @@ func (uc *WorkflowContractUseCase) findAndValidatePolicyGroup(ctx context.Contex if pr.Provider == "" && pr.OrgName == "" && slices.Contains(batchPolicyGroupNames, pr.Name) { return nil, nil } - remoteGroup, err := uc.GetPolicyGroup(ctx, pr.Provider, pr.Name, pr.OrgName, "", token) + // The control plane is a non-CLI caller, so resolve the true latest revision + // and skip CLI-version compatibility gating. + remoteGroup, err := uc.GetPolicyGroup(ctx, pr.Provider, pr.Name, pr.OrgName, "", token, policies.WithIgnoreCLICompatibility()) if err != nil { return nil, NewErrValidation(fmt.Errorf("failed to get policy group: %w", err)) } @@ -779,14 +783,15 @@ func providerAuthOpts(ctx context.Context, token, currentOrgName string) policie } } -// GetPolicy retrieves a policy from a policy provider -func (uc *WorkflowContractUseCase) GetPolicy(ctx context.Context, providerName, policyName, policyOrgName, currentOrgName, token string) (*RemotePolicy, error) { +// GetPolicy retrieves a policy from a policy provider. +// See policies.WithIgnoreCLICompatibility for the available resolution options. +func (uc *WorkflowContractUseCase) GetPolicy(ctx context.Context, providerName, policyName, policyOrgName, currentOrgName, token string, opts ...policies.ResolveOption) (*RemotePolicy, error) { provider, err := uc.findProvider(providerName) if err != nil { return nil, err } - policy, ref, err := provider.Resolve(policyName, policyOrgName, providerAuthOpts(ctx, token, currentOrgName)) + policy, ref, err := provider.Resolve(policyName, policyOrgName, providerAuthOpts(ctx, token, currentOrgName), opts...) if err != nil { if errors.Is(err, policies.ErrNotFound) { return nil, NewErrNotFound(fmt.Sprintf("policy %q", policyName)) @@ -801,13 +806,15 @@ func (uc *WorkflowContractUseCase) GetPolicy(ctx context.Context, providerName, return &RemotePolicy{Policy: policy, ProviderRef: ref}, nil } -func (uc *WorkflowContractUseCase) GetPolicyGroup(ctx context.Context, providerName, groupName, groupOrgName, currentOrgName, token string) (*RemotePolicyGroup, error) { +// GetPolicyGroup retrieves a policy group from a policy provider. +// See policies.WithIgnoreCLICompatibility for the available resolution options. +func (uc *WorkflowContractUseCase) GetPolicyGroup(ctx context.Context, providerName, groupName, groupOrgName, currentOrgName, token string, opts ...policies.ResolveOption) (*RemotePolicyGroup, error) { provider, err := uc.findProvider(providerName) if err != nil { return nil, err } - group, ref, err := provider.ResolveGroup(groupName, groupOrgName, providerAuthOpts(ctx, token, currentOrgName)) + group, ref, err := provider.ResolveGroup(groupName, groupOrgName, providerAuthOpts(ctx, token, currentOrgName), opts...) if err != nil { if errors.Is(err, policies.ErrNotFound) { return nil, NewErrNotFound(fmt.Sprintf("policy group %q", groupName)) diff --git a/app/controlplane/pkg/policies/policyprovider.go b/app/controlplane/pkg/policies/policyprovider.go index b3eb50ad1..6a043aa5c 100644 --- a/app/controlplane/pkg/policies/policyprovider.go +++ b/app/controlplane/pkg/policies/policyprovider.go @@ -36,9 +36,14 @@ const ( validateAction = "validate" groupsEndpoint = "groups" - digestParam = "digest" - orgNameParam = "organization_name" - organizationHeader = "Chainloop-Organization" + digestParam = "digest" + orgNameParam = "organization_name" + // includeAllVersionsParam is the provider's query parameter that, when set, + // makes the provider skip CLI-version compatibility resolution and return the + // true latest revision. The wire name is "include_all_versions" but its real + // effect is "ignore CLI compatibility gating" (see WithIgnoreCLICompatibility). + includeAllVersionsParam = "include_all_versions" + organizationHeader = "Chainloop-Organization" ) // PolicyProvider represents an external policy provider @@ -88,8 +93,37 @@ var ( ErrUnauthorized = fmt.Errorf("unauthorized request to policy provider") ) -// Resolve calls the remote provider for retrieving a policy -func (p *PolicyProvider) Resolve(policyName, policyOrgName string, authOpts ProviderAuthOpts) (*schemaapi.Policy, *PolicyReference, error) { +// ResolveOption configures a policy or policy-group resolution request. +type ResolveOption func(*resolveOptions) + +type resolveOptions struct { + ignoreCLICompatibility bool +} + +func newResolveOptions(opts []ResolveOption) resolveOptions { + var o resolveOptions + for _, opt := range opts { + if opt != nil { + opt(&o) + } + } + return o +} + +// WithIgnoreCLICompatibility makes the provider skip CLI-version compatibility +// resolution and return the true latest revision instead of the latest revision +// compatible with the requesting CLI version. It is meant for non-CLI callers +// such as the web UI or the control plane. On the wire this sets the +// include_all_versions query parameter. +func WithIgnoreCLICompatibility() ResolveOption { + return func(o *resolveOptions) { + o.ignoreCLICompatibility = true + } +} + +// Resolve calls the remote provider for retrieving a policy. +// See WithIgnoreCLICompatibility for the available resolution options. +func (p *PolicyProvider) Resolve(policyName, policyOrgName string, authOpts ProviderAuthOpts, opts ...ResolveOption) (*schemaapi.Policy, *PolicyReference, error) { if policyName == "" || authOpts.Token == "" { return nil, nil, fmt.Errorf("both policyname and auth opts are mandatory") } @@ -108,7 +142,7 @@ func (p *PolicyProvider) Resolve(policyName, policyOrgName string, authOpts Prov } // we want to override the orgName with the one in the response // since we might have resolved it implicitly - providerDigest, orgName, err := p.queryProvider(url, digest, policyOrgName, authOpts, &policy) + providerDigest, orgName, err := p.queryProvider(url, digest, policyOrgName, authOpts, &policy, opts...) if err != nil { return nil, nil, fmt.Errorf("failed to resolve policy: %w", err) } @@ -189,8 +223,9 @@ func (p *PolicyProvider) ValidateAttachment(att *schemaapi.PolicyAttachment, aut return nil } -// ResolveGroup calls remote provider for retrieving a policy group definition -func (p *PolicyProvider) ResolveGroup(groupName, groupOrgName string, authOpts ProviderAuthOpts) (*schemaapi.PolicyGroup, *PolicyReference, error) { +// ResolveGroup calls remote provider for retrieving a policy group definition. +// See WithIgnoreCLICompatibility for the available resolution options. +func (p *PolicyProvider) ResolveGroup(groupName, groupOrgName string, authOpts ProviderAuthOpts, opts ...ResolveOption) (*schemaapi.PolicyGroup, *PolicyReference, error) { if groupName == "" || authOpts.Token == "" { return nil, nil, fmt.Errorf("both policyname and token are mandatory") } @@ -209,7 +244,7 @@ func (p *PolicyProvider) ResolveGroup(groupName, groupOrgName string, authOpts P } // we want to override the orgName with the one in the response // since we might have resolved it implicitly - providerDigest, orgName, err := p.queryProvider(url, digest, groupOrgName, authOpts, &group) + providerDigest, orgName, err := p.queryProvider(url, digest, groupOrgName, authOpts, &group, opts...) if err != nil { return nil, nil, fmt.Errorf("failed to resolve group: %w", err) } @@ -218,7 +253,9 @@ func (p *PolicyProvider) ResolveGroup(groupName, groupOrgName string, authOpts P } // returns digest, orgname, error -func (p *PolicyProvider) queryProvider(url *url.URL, digest, orgName string, authOpts ProviderAuthOpts, out proto.Message) (string, string, error) { +func (p *PolicyProvider) queryProvider(url *url.URL, digest, orgName string, authOpts ProviderAuthOpts, out proto.Message, opts ...ResolveOption) (string, string, error) { + options := newResolveOptions(opts) + query := url.Query() if digest != "" { query.Set(digestParam, digest) @@ -228,6 +265,10 @@ func (p *PolicyProvider) queryProvider(url *url.URL, digest, orgName string, aut query.Set(orgNameParam, orgName) } + if options.ignoreCLICompatibility { + query.Set(includeAllVersionsParam, "true") + } + url.RawQuery = query.Encode() req, err := http.NewRequest("GET", url.String(), nil) diff --git a/app/controlplane/pkg/policies/policyprovider_http_test.go b/app/controlplane/pkg/policies/policyprovider_http_test.go index 75f813f3c..dc1dd86eb 100644 --- a/app/controlplane/pkg/policies/policyprovider_http_test.go +++ b/app/controlplane/pkg/policies/policyprovider_http_test.go @@ -214,6 +214,58 @@ func TestProviderForwardsCLIVersionHeader(t *testing.T) { } } +func TestResolveForwardsIgnoreCLICompatibility(t *testing.T) { + testCases := []struct { + name string + opts []ResolveOption + wantParam string + }{ + { + name: "include_all_versions set when ignoring CLI compatibility", + opts: []ResolveOption{WithIgnoreCLICompatibility()}, + wantParam: "true", + }, + { + name: "param omitted when no options passed", + opts: nil, + wantParam: "", + }, + { + name: "nil option is ignored without panicking", + opts: []ResolveOption{nil}, + wantParam: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var policyParam, groupParam string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.URL.Query().Get("include_all_versions") + if strings.Contains(r.URL.Path, "/groups/") { + groupParam = got + } else { + policyParam = got + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + provider := &PolicyProvider{name: "test", url: server.URL} + authOpts := ProviderAuthOpts{Token: "test-token"} + + _, _, err := provider.Resolve("p1", "", authOpts, tc.opts...) + require.NoError(t, err) + _, _, err = provider.ResolveGroup("g1", "", authOpts, tc.opts...) + require.NoError(t, err) + + assert.Equal(t, tc.wantParam, policyParam, "Resolve") + assert.Equal(t, tc.wantParam, groupParam, "ResolveGroup") + }) + } +} + func TestValidateAttachmentHTTPStatusHandling(t *testing.T) { testCases := []struct { name string