From 5b3aefdf946339f024d17f809cbb101de9eb92d5 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Sun, 27 Sep 2026 13:54:56 +0800 Subject: [PATCH] fix: align receiver schema capabilities with project roles --- .github/release-notes/v0.10.3.md | 5 + .github/workflows/security-release-gate.yml | 2 +- scripts/version | 2 +- service/receiver_schema_roles_test.go | 109 ++++++++++++++++++++ service/routes.go | 40 ++++++- 5 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 .github/release-notes/v0.10.3.md create mode 100644 service/receiver_schema_roles_test.go diff --git a/.github/release-notes/v0.10.3.md b/.github/release-notes/v0.10.3.md new file mode 100644 index 0000000..4664a4a --- /dev/null +++ b/.github/release-notes/v0.10.3.md @@ -0,0 +1,5 @@ +# Webhook Automation Service v0.10.3 + +This patch release makes the Receiver schema reflect the authenticated project's actual write permissions. Owners and members retain create/delete capabilities; restricted and read-only roles receive read-only Receiver methods, matching the existing API enforcement. Schema responses are private and non-cacheable so a writable capability response cannot be reused for another role. + +The change covers both `/v1-webhooks/schemas` and `/v1-webhooks/schemas/receiver` without changing Receiver data, credentials, routes, or project isolation. Regression tests check both endpoints, mixed roles, and concurrent requests against a shared schema. diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 2dc3f61..6b771ce 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 90 env: - CANDIDATE_VERSION: 0.10.2 + CANDIDATE_VERSION: 0.10.3 GO_VERSION: 1.27.0 GO_LINUX_AMD64_SHA256: 675c26c449cbb18fc24b74650de1eabbae6e16f64326fd85a283fb3b58280685 TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 diff --git a/scripts/version b/scripts/version index acaf579..9e46b4a 100755 --- a/scripts/version +++ b/scripts/version @@ -3,7 +3,7 @@ version_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" version_repo_root="$(cd "${version_script_dir}/.." && pwd)" -VERSION="${VERSION:-0.10.2}" +VERSION="${VERSION:-0.10.3}" COMMIT="${COMMIT:-$(git -c safe.directory="${version_repo_root}" -C "${version_repo_root}" rev-parse HEAD)}" ARCH="${ARCH:-amd64}" diff --git a/service/receiver_schema_roles_test.go b/service/receiver_schema_roles_test.go new file mode 100644 index 0000000..c25a537 --- /dev/null +++ b/service/receiver_schema_roles_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "sync" + "testing" + + v1client "github.com/rancher/go-rancher/client" +) + +func receiverSchemaForRole(t *testing.T, handler http.Handler, roles, path string) v1client.Schema { + t.Helper() + response := httptest.NewRecorder() + handler.ServeHTTP(response, roleBoundaryRequest(http.MethodGet, path, roles, "")) + if response.Code != http.StatusOK { + t.Fatalf("GET %s for %q returned %d: %s", path, roles, response.Code, response.Body.String()) + } + if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != RoleAPIHeader { + t.Fatalf("role-specific schema has unsafe cache headers: %v", response.Header()) + } + var receiver v1client.Schema + if path == "/v1-webhooks/schemas/receiver" { + if err := json.Unmarshal(response.Body.Bytes(), &receiver); err != nil { + t.Fatal(err) + } + } else { + var collection v1client.Schemas + if err := json.Unmarshal(response.Body.Bytes(), &collection); err != nil { + t.Fatal(err) + } + receiver = collection.Schema("receiver") + } + if receiver.Id != "receiver" { + t.Fatalf("GET %s for %q did not return receiver schema", path, roles) + } + return receiver +} + +func TestReceiverSchemaMethodsMatchRoleBoundary(t *testing.T) { + handler := NewRouter(&RouteHandler{}) + for _, test := range []struct { + roles string + collection []string + resource []string + }{ + {"owner", []string{"GET", "POST"}, []string{"GET", "DELETE"}}, + {"member", []string{"GET", "POST"}, []string{"GET", "DELETE"}}, + {"restricted", []string{"GET"}, []string{"GET"}}, + {"readonly", []string{"GET"}, []string{"GET"}}, + {"owner,readonly", []string{"GET"}, []string{"GET"}}, + } { + for _, path := range []string{"/v1-webhooks/schemas", "/v1-webhooks/schemas/receiver"} { + t.Run(test.roles+path, func(t *testing.T) { + got := receiverSchemaForRole(t, handler, test.roles, path) + if !reflect.DeepEqual(got.CollectionMethods, test.collection) || !reflect.DeepEqual(got.ResourceMethods, test.resource) { + t.Fatalf("receiver methods for %q: collection=%v resource=%v", test.roles, got.CollectionMethods, got.ResourceMethods) + } + }) + } + } + base := schemas.Schema("receiver") + if !reflect.DeepEqual(base.CollectionMethods, []string{"GET", "POST"}) || !reflect.DeepEqual(base.ResourceMethods, []string{"GET", "DELETE"}) { + t.Fatalf("shared receiver schema was mutated: %+v", base) + } +} + +func TestConcurrentReceiverSchemaRequestsKeepCapabilitiesIsolated(t *testing.T) { + handler := NewRouter(&RouteHandler{}) + var group sync.WaitGroup + errors := make(chan error, 200) + check := func(roles, path string, collection, resource []string) { + defer group.Done() + response := httptest.NewRecorder() + handler.ServeHTTP(response, roleBoundaryRequest(http.MethodGet, path, roles, "")) + if response.Code != http.StatusOK { + errors <- fmt.Errorf("%s for %s returned %d", path, roles, response.Code) + return + } + var got v1client.Schema + if path == "/v1-webhooks/schemas" { + var all v1client.Schemas + if err := json.Unmarshal(response.Body.Bytes(), &all); err != nil { + errors <- err + return + } + got = all.Schema("receiver") + } else if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + errors <- err + return + } + if !reflect.DeepEqual(got.CollectionMethods, collection) || !reflect.DeepEqual(got.ResourceMethods, resource) { + errors <- fmt.Errorf("receiver methods for %s: %v %v", roles, got.CollectionMethods, got.ResourceMethods) + } + } + for i := 0; i < 100; i++ { + group.Add(2) + go check("owner", "/v1-webhooks/schemas", []string{"GET", "POST"}, []string{"GET", "DELETE"}) + go check("restricted", "/v1-webhooks/schemas/receiver", []string{"GET"}, []string{"GET"}) + } + group.Wait() + close(errors) + for err := range errors { + t.Error(err) + } +} diff --git a/service/routes.go b/service/routes.go index a4c27ad..fe208c3 100644 --- a/service/routes.go +++ b/service/routes.go @@ -53,11 +53,11 @@ func NewRouter(r *RouteHandler) *mux.Router { router.Methods("GET").Path("/v1-webhooks").Handler(VersionHandler(schemas)) router.Methods("GET").Path("/v1-webhooks/").Handler(VersionHandler(schemas)) - router.Methods("GET").Path("/v1-webhooks/schemas/").Handler(api.SchemasHandler(schemas)) - router.Methods("GET").Path("/v1-webhooks/schemas").Handler(api.SchemasHandler(schemas)) + router.Methods("GET").Path("/v1-webhooks/schemas/").Handler(roleAwareSchemasHandler(schemas, false)) + router.Methods("GET").Path("/v1-webhooks/schemas").Handler(roleAwareSchemasHandler(schemas, false)) - router.Methods("GET").Path("/v1-webhooks/schemas/{id}").Handler(api.SchemaHandler(schemas)) - router.Methods("GET").Path("/v1-webhooks/schemas/{id}/").Handler(api.SchemaHandler(schemas)) + router.Methods("GET").Path("/v1-webhooks/schemas/{id}").Handler(roleAwareSchemasHandler(schemas, true)) + router.Methods("GET").Path("/v1-webhooks/schemas/{id}/").Handler(roleAwareSchemasHandler(schemas, true)) router.Methods("POST").Path("/v1-webhooks/receivers").Handler(f(schemas, r.ConstructPayload)) router.Methods("POST").Path("/v1-webhooks/receivers/").Handler(f(schemas, r.ConstructPayload)) @@ -77,6 +77,38 @@ func NewRouter(r *RouteHandler) *mux.Router { return router } +// The receiver write boundary depends on the authenticated project's roles. +// Advertise exactly that boundary to clients, without mutating the shared +// schema (the Rancher schema handler adds links while rendering a response). +func roleAwareSchemasHandler(base *v1client.Schemas, single bool) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Capabilities vary by the authenticated project role; a shared cache + // must never serve an owner's writable schema to a read-only user. + rw.Header().Set("Cache-Control", "private, no-store") + rw.Header().Add("Vary", RoleAPIHeader) + scoped := *base + scoped.Data = make([]v1client.Schema, len(base.Data)) + readonly := hasReadonlyRole(r) + for i, original := range base.Data { + copy := original + copy.Links = make(map[string]string, len(original.Links)) + for key, value := range original.Links { + copy.Links[key] = value + } + if copy.Id == "receiver" && readonly { + copy.CollectionMethods = []string{"GET"} + copy.ResourceMethods = []string{"GET"} + } + scoped.Data[i] = copy + } + if single { + api.SchemaHandler(&scoped).ServeHTTP(rw, r) + } else { + api.SchemasHandler(&scoped).ServeHTTP(rw, r) + } + }) +} + func driverSchemas() *v1client.Schemas { schemas := &v1client.Schemas{} webhook := schemas.AddType("receiver", model.Webhook{})