Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/release-notes/v0.10.3.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/security-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/version
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
109 changes: 109 additions & 0 deletions service/receiver_schema_roles_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 36 additions & 4 deletions service/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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{})
Expand Down
Loading