From 8751894e9456a417e0c06ddc871720781ea52bca Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 25 May 2026 23:53:20 +0200 Subject: [PATCH 1/4] use dynamic configuration for authorized operations Signed-off-by: Jose I. Paris --- .../conf/controlplane/config/v1/conf.pb.go | 18 +++++++++++++++--- .../conf/controlplane/config/v1/conf.proto | 2 ++ .../operation_authorization_middleware.go | 4 ++-- app/controlplane/pkg/authz/authz.go | 12 +----------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index 038059240..e34afb9a3 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -321,7 +321,9 @@ type OperationAuthorizationProvider struct { // URL of the authorization endpoint Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` // Whether to enable the operation authorization - Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"` + Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"` + // authorized operations list + Operations []string `protobuf:"bytes,3,rep,name=operations,proto3" json:"operations,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -370,6 +372,13 @@ func (x *OperationAuthorizationProvider) GetEnabled() bool { return false } +func (x *OperationAuthorizationProvider) GetOperations() []string { + if x != nil { + return x.Operations + } + return nil +} + type FederatedAuthentication struct { state protoimpl.MessageState `protogen:"open.v1"` // URL of the federated verification endpoint @@ -1841,10 +1850,13 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + "\x0eauthentication\"6\n" + "\fAttestations\x12&\n" + - "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"V\n" + + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"v\n" + "\x1eOperationAuthorizationProvider\x12\x1a\n" + "\x03url\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x88\x01\x01R\x03url\x12\x18\n" + - "\aenabled\x18\x02 \x01(\bR\aenabled\"O\n" + + "\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1e\n" + + "\n" + + "operations\x18\x03 \x03(\tR\n" + + "operations\"O\n" + "\x17FederatedAuthentication\x12\x1a\n" + "\x03url\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x88\x01\x01R\x03url\x12\x18\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\"\xee\x01\n" + diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index 4b5f7a4ba..0442ab0e4 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -143,6 +143,8 @@ message OperationAuthorizationProvider { string url = 1 [(buf.validate.field).string.uri = true]; // Whether to enable the operation authorization bool enabled = 2; + // authorized operations list + repeated string operations = 3; } message FederatedAuthentication { diff --git a/app/controlplane/internal/usercontext/operation_authorization_middleware.go b/app/controlplane/internal/usercontext/operation_authorization_middleware.go index 490acb2fc..425f893bd 100644 --- a/app/controlplane/internal/usercontext/operation_authorization_middleware.go +++ b/app/controlplane/internal/usercontext/operation_authorization_middleware.go @@ -21,11 +21,11 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" errorsAPI "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" "github.com/go-kratos/kratos/v2/middleware" @@ -61,7 +61,7 @@ func WithOperationAuthorizationMiddleware(conf *conf.OperationAuthorizationProvi } operation := info.Operation() - if !authz.RequiresExternalAuthz(operation) { + if !slices.Contains(conf.GetOperations(), operation) { return handler(ctx, req) } diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index d4299f258..f16e55f9b 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -23,10 +23,8 @@ type Policy struct { } // OperationPolicy describes both the local Casbin policies required for an operation -// and whether the operation also requires external authorization. type OperationPolicy struct { - Policies []*Policy - ExternalAuthz bool + Policies []*Policy } type Role string @@ -459,14 +457,6 @@ var ServerOperationsMap = map[string]*OperationPolicy{ "/controlplane.v1.OrgInvitationService/Create": {Policies: []*Policy{PolicyOrganizationInvitationsCreate}}, } -// RequiresExternalAuthz returns whether the given operation requires external authorization. -func RequiresExternalAuthz(operation string) bool { - if entry, ok := ServerOperationsMap[operation]; ok { - return entry.ExternalAuthz - } - return false -} - // Implements https://pkg.go.dev/entgo.io/ent/schema/field#EnumValues // so they can be added to the database schema func (Role) Values() (roles []string) { From 85bdd894ed5d1edfa32c01df15227dfd2ba09055 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 26 May 2026 00:00:44 +0200 Subject: [PATCH 2/4] expose paramter to chart Signed-off-by: Jose I. Paris --- app/controlplane/configs/config.devel.yaml | 2 ++ deployment/chainloop/Chart.yaml | 2 +- deployment/chainloop/values.yaml | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controlplane/configs/config.devel.yaml b/app/controlplane/configs/config.devel.yaml index 07976cfca..2f16f7789 100644 --- a/app/controlplane/configs/config.devel.yaml +++ b/app/controlplane/configs/config.devel.yaml @@ -118,6 +118,8 @@ enable_profiler: true # operation_authorization_provider: # enabled: true # url: http://localhost:8002/v1/authorize +# operations: +# - /controlplane.v1.WorkflowRunService/Create ui_dashboard_url: http://localhost:3000 diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml index 196515b67..1def9ec28 100644 --- a/deployment/chainloop/Chart.yaml +++ b/deployment/chainloop/Chart.yaml @@ -7,7 +7,7 @@ description: Chainloop is an open source software supply chain control plane, a type: application # Bump the patch (not minor, not major) version on each change in the Chart Source code -version: 1.385.0 +version: 1.385.1 # Do not update appVersion, this is handled automatically by the release process appVersion: v1.98.4 diff --git a/deployment/chainloop/values.yaml b/deployment/chainloop/values.yaml index 498692255..e11ff3eb6 100644 --- a/deployment/chainloop/values.yaml +++ b/deployment/chainloop/values.yaml @@ -183,9 +183,11 @@ controlplane: ## @extra controlplane.operationAuthorizationProvider Enable external operation authorization ## @param controlplane.operationAuthorizationProvider.enabled Enable operation authorization ## @param controlplane.operationAuthorizationProvider.url URL of the authorization endpoint + ## @param controlplane.operationAuthorizationProvider.operations List of gRPC operations that require external authorization (e.g. "/controlplane.v1.WorkflowRunService/Create") operationAuthorizationProvider: enabled: false url: "" + operations: [] ## @skip controlplane.restrictOrgCreation Restrict organization creation to instance admins restrictOrgCreation: false From b1579ff6e1a64587a1b8980730c53320fdc527a3 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 26 May 2026 09:23:32 +0200 Subject: [PATCH 3/4] fix tests Signed-off-by: Jose I. Paris --- ...operation_authorization_middleware_test.go | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/app/controlplane/internal/usercontext/operation_authorization_middleware_test.go b/app/controlplane/internal/usercontext/operation_authorization_middleware_test.go index b0b60e4fc..1f19314fa 100644 --- a/app/controlplane/internal/usercontext/operation_authorization_middleware_test.go +++ b/app/controlplane/internal/usercontext/operation_authorization_middleware_test.go @@ -24,7 +24,6 @@ import ( "testing" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/go-kratos/kratos/v2/log" "github.com/go-kratos/kratos/v2/transport" "github.com/stretchr/testify/assert" @@ -33,14 +32,6 @@ import ( const testExternalAuthzOp = "/test.v1.ExternalAuthzTestService/Target" -func registerTestExternalAuthzOp(t *testing.T) { - t.Helper() - authz.ServerOperationsMap[testExternalAuthzOp] = &authz.OperationPolicy{ExternalAuthz: true} - t.Cleanup(func() { - delete(authz.ServerOperationsMap, testExternalAuthzOp) - }) -} - // fakeTransport implements transport.Transporter for testing middleware operation matching. type fakeTransport struct { operation string @@ -106,7 +97,6 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { }) t.Run("target operation allowed", func(t *testing.T) { - registerTestExternalAuthzOp(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req operationAuthRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) @@ -117,7 +107,7 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { })) defer srv.Close() - cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL} + cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL, Operations: []string{testExternalAuthzOp}} m := WithOperationAuthorizationMiddleware(cfg, logHelper) ctx := ctxWithOperation(context.Background(), testExternalAuthzOp) @@ -128,14 +118,13 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { }) t.Run("target operation denied", func(t *testing.T) { - registerTestExternalAuthzOp(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(operationAuthResponse{Allowed: false, Reason: "org limit reached"}) //nolint:errcheck })) defer srv.Close() - cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL} + cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL, Operations: []string{testExternalAuthzOp}} m := WithOperationAuthorizationMiddleware(cfg, logHelper) ctx := ctxWithOperation(context.Background(), testExternalAuthzOp) @@ -147,8 +136,7 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { }) t.Run("provider unreachable is fail-closed", func(t *testing.T) { - registerTestExternalAuthzOp(t) - cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: "http://127.0.0.1:1"} + cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: "http://127.0.0.1:1", Operations: []string{testExternalAuthzOp}} m := WithOperationAuthorizationMiddleware(cfg, logHelper) ctx := ctxWithOperation(context.Background(), testExternalAuthzOp) @@ -160,13 +148,12 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { }) t.Run("provider returns 500 is fail-closed", func(t *testing.T) { - registerTestExternalAuthzOp(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() - cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL} + cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL, Operations: []string{testExternalAuthzOp}} m := WithOperationAuthorizationMiddleware(cfg, logHelper) ctx := ctxWithOperation(context.Background(), testExternalAuthzOp) @@ -178,7 +165,6 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { }) t.Run("bearer token is forwarded", func(t *testing.T) { - registerTestExternalAuthzOp(t) var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") @@ -187,7 +173,7 @@ func TestWithOperationAuthorizationMiddleware(t *testing.T) { })) defer srv.Close() - cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL} + cfg := &conf.OperationAuthorizationProvider{Enabled: true, Url: srv.URL, Operations: []string{testExternalAuthzOp}} m := WithOperationAuthorizationMiddleware(cfg, logHelper) ft := &fakeTransport{ From da19987cbcb596458320b96b7dd1be4c75f3b513 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 26 May 2026 09:37:18 +0200 Subject: [PATCH 4/4] merge main Signed-off-by: Jose I. Paris --- app/controlplane/pkg/authz/authz.go | 2 +- app/controlplane/pkg/authz/authz_test.go | 30 ------------------------ 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 2b20f985f..f16e55f9b 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -373,7 +373,7 @@ var ServerOperationsMap = map[string]*OperationPolicy{ // CAS Backend listing "/controlplane.v1.CASBackendService/List": {Policies: []*Policy{PolicyCASBackendList}}, "/controlplane.v1.CASBackendService/Revalidate": {Policies: []*Policy{PolicyCASBackendUpdate}}, - "/controlplane.v1.CASBackendService/Create": {Policies: []*Policy{PolicyCASBackendCreate}, ExternalAuthz: true}, + "/controlplane.v1.CASBackendService/Create": {Policies: []*Policy{PolicyCASBackendCreate}}, // Available integrations "/controlplane.v1.IntegrationsService/ListAvailable": {Policies: []*Policy{PolicyAvailableIntegrationList, PolicyAvailableIntegrationRead}}, // Registered integrations diff --git a/app/controlplane/pkg/authz/authz_test.go b/app/controlplane/pkg/authz/authz_test.go index b06e6bae9..d85cef9a7 100644 --- a/app/controlplane/pkg/authz/authz_test.go +++ b/app/controlplane/pkg/authz/authz_test.go @@ -129,36 +129,6 @@ func TestDoSync(t *testing.T) { assert.Equal(t, "delete", got[0][2]) } -func TestRequiresExternalAuthz(t *testing.T) { - testCases := []struct { - name string - operation string - want bool - }{ - { - name: "CAS backend creation is forwarded to the external authorizer", - operation: "/controlplane.v1.CASBackendService/Create", - want: true, - }, - { - name: "operations without external authz flag are not forwarded", - operation: "/controlplane.v1.WorkflowService/List", - want: false, - }, - { - name: "unknown operations are not forwarded", - operation: "/controlplane.v1.UnknownService/Unknown", - want: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, RequiresExternalAuthz(tc.operation)) - }) - } -} - func testEnforcer(t *testing.T) (*CasbinEnforcer, io.Closer) { f, err := os.CreateTemp(t.TempDir(), "policy*.csv") if err != nil {