From beefa624b0b2e25c9563d5f8a1a6ba914e2da83b Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 05:28:12 +0000 Subject: [PATCH 01/14] schema_registry: add URL-rewriting helpers for context-prefixed routes Introduces context_router.h with inline helpers that will be used by context-prefixed route wrappers: - normalize_context(): canonicalize a URL path context parameter by stripping outer ':' delimiters, adding '.' prefix, and rejecting embedded colons (400 Bad Request via new context_invalid error code) - starts_with_context(): detect subjects already qualified with a context prefix Aliases like "staging", ":.staging:", and ".staging" all resolve to the canonical form ".staging". Includes gtest coverage for both helpers. Includes gtest coverage for all helpers. (cherry picked from commit cceffc0499f84547e79f338d18a5c58fdcba3a69) --- src/v/pandaproxy/schema_registry/BUILD | 1 + .../schema_registry/context_router.h | 49 +++++++++++++ src/v/pandaproxy/schema_registry/error.cc | 4 ++ src/v/pandaproxy/schema_registry/error.h | 1 + src/v/pandaproxy/schema_registry/errors.h | 6 ++ src/v/pandaproxy/schema_registry/test/BUILD | 15 ++++ .../schema_registry/test/context_router.cc | 68 +++++++++++++++++++ 7 files changed, 144 insertions(+) create mode 100644 src/v/pandaproxy/schema_registry/context_router.h create mode 100644 src/v/pandaproxy/schema_registry/test/context_router.cc diff --git a/src/v/pandaproxy/schema_registry/BUILD b/src/v/pandaproxy/schema_registry/BUILD index 4fa9dd2855146..239d2ecf20fcb 100644 --- a/src/v/pandaproxy/schema_registry/BUILD +++ b/src/v/pandaproxy/schema_registry/BUILD @@ -82,6 +82,7 @@ redpanda_cc_library( "avro.h", "compatibility.h", "configuration.h", + "context_router.h", "error.h", "errors.h", "exceptions.h", diff --git a/src/v/pandaproxy/schema_registry/context_router.h b/src/v/pandaproxy/schema_registry/context_router.h new file mode 100644 index 0000000000000..dccab81de2bb9 --- /dev/null +++ b/src/v/pandaproxy/schema_registry/context_router.h @@ -0,0 +1,49 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +#pragma once + +#include "base/seastarx.h" +#include "pandaproxy/schema_registry/errors.h" + +#include +#include + +#include + +#include + +namespace pandaproxy::schema_registry { + +/// \brief Normalize a context name from a URL path parameter. +inline ss::sstring normalize_context(std::string_view ctx) { + if (ctx.starts_with(':')) { + ctx.remove_prefix(1); + } + + if (ctx.ends_with(':')) { + ctx.remove_suffix(1); + } + + if (ctx.find(':') != std::string_view::npos) { + throw as_exception(context_invalid(ctx)); + } + + if (!ctx.starts_with('.')) { + return {fmt::format(".{}", ctx)}; + } + return ss::sstring(ctx); +} + +/// \brief Check if a string already has a context prefix. +inline bool starts_with_context(std::string_view s) { + return s.starts_with(":.") || s.starts_with(":*:"); +} + +} // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/error.cc b/src/v/pandaproxy/schema_registry/error.cc index f0562f109a91d..3cbc508113224 100644 --- a/src/v/pandaproxy/schema_registry/error.cc +++ b/src/v/pandaproxy/schema_registry/error.cc @@ -96,6 +96,8 @@ struct error_category final : std::error_category { return "The specified context is not empty"; case error_code::subject_invalid: return "The specified subject is not valid"; + case error_code::context_invalid: + return "The specified context name is not valid"; } return "(unrecognized error)"; } @@ -164,6 +166,8 @@ struct error_category final : std::error_category { return reply_error_code::context_not_empty; // 42211 case error_code::subject_invalid: return reply_error_code::subject_invalid; // 42208 + case error_code::context_invalid: + return reply_error_code::bad_request; // 400 } return {}; } diff --git a/src/v/pandaproxy/schema_registry/error.h b/src/v/pandaproxy/schema_registry/error.h index f7dd7fc936e5c..5c11cb2de460f 100644 --- a/src/v/pandaproxy/schema_registry/error.h +++ b/src/v/pandaproxy/schema_registry/error.h @@ -47,6 +47,7 @@ enum class error_code { writes_disabled, context_not_empty, subject_invalid, + context_invalid, }; std::error_code make_error_code(error_code); diff --git a/src/v/pandaproxy/schema_registry/errors.h b/src/v/pandaproxy/schema_registry/errors.h index d1f7aafa6bd98..d88bac31d0985 100644 --- a/src/v/pandaproxy/schema_registry/errors.h +++ b/src/v/pandaproxy/schema_registry/errors.h @@ -261,6 +261,12 @@ inline error_info context_not_empty(const context& ctx) { fmt::format("The specified context '{}' is not empty.", ctx())}; } +inline error_info context_invalid(std::string_view ctx) { + return error_info{ + error_code::context_invalid, + fmt::format("The specified context '{}' is not valid.", ctx)}; +} + inline bool failed_subject_schema_lookup(std::error_code ec) { return ec == error_code::subject_not_found || ec == error_code::subject_version_not_found; diff --git a/src/v/pandaproxy/schema_registry/test/BUILD b/src/v/pandaproxy/schema_registry/test/BUILD index 77aee20a089b2..0a1228e952237 100644 --- a/src/v/pandaproxy/schema_registry/test/BUILD +++ b/src/v/pandaproxy/schema_registry/test/BUILD @@ -348,6 +348,21 @@ redpanda_cc_gtest( ], ) +redpanda_cc_gtest( + name = "context_router_test", + timeout = "short", + srcs = [ + "context_router.cc", + ], + deps = [ + "//src/v/pandaproxy/schema_registry:core", + "//src/v/test_utils:gtest", + "@fmt", + "@googletest//:gtest", + "@seastar", + ], +) + redpanda_cc_btest( name = "test_json_schema", timeout = "short", diff --git a/src/v/pandaproxy/schema_registry/test/context_router.cc b/src/v/pandaproxy/schema_registry/test/context_router.cc new file mode 100644 index 0000000000000..b979f4d10016e --- /dev/null +++ b/src/v/pandaproxy/schema_registry/test/context_router.cc @@ -0,0 +1,68 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +#include "pandaproxy/schema_registry/context_router.h" + +#include "pandaproxy/schema_registry/exceptions.h" + +#include + +#include + +namespace pandaproxy::schema_registry { + +TEST(ContextRouterTest, StartsWithContextDefault) { + EXPECT_FALSE(starts_with_context("")); + EXPECT_FALSE(starts_with_context("my-topic")); + EXPECT_FALSE(starts_with_context("plain-subject")); +} + +TEST(ContextRouterTest, StartsWithContextQualified) { + EXPECT_TRUE(starts_with_context(":.staging:my-topic")); + EXPECT_TRUE(starts_with_context(":.prod:")); + EXPECT_TRUE(starts_with_context(":.:my-topic")); +} + +TEST(ContextRouterTest, StartsWithContextWildcard) { + EXPECT_TRUE(starts_with_context(":*:")); + EXPECT_TRUE(starts_with_context(":*:my-topic")); +} + +TEST(ContextRouterTest, StartsWithContextEdgeCases) { + EXPECT_FALSE(starts_with_context(":")); + EXPECT_FALSE(starts_with_context(":foo")); + EXPECT_FALSE(starts_with_context(":*")); +} + +TEST(ContextRouterTest, NormalizeContextWithDot) { + EXPECT_EQ(normalize_context(".staging"), ".staging"); + EXPECT_EQ(normalize_context(".prod"), ".prod"); + EXPECT_EQ(normalize_context("."), "."); +} + +TEST(ContextRouterTest, NormalizeContextWithoutDot) { + EXPECT_EQ(normalize_context("staging"), ".staging"); + EXPECT_EQ(normalize_context("prod"), ".prod"); + EXPECT_EQ(normalize_context(""), "."); +} + +TEST(ContextRouterTest, NormalizeContextStripColons) { + EXPECT_EQ(normalize_context(":.staging"), ".staging"); + EXPECT_EQ(normalize_context(".staging:"), ".staging"); + EXPECT_EQ(normalize_context(":.staging:"), ".staging"); + EXPECT_EQ(normalize_context(":staging:"), ".staging"); +} + +TEST(ContextRouterTest, NormalizeContextRejectsEmbeddedColons) { + EXPECT_THROW(normalize_context(".:."), exception); + EXPECT_THROW(normalize_context("a:b"), exception); + EXPECT_THROW(normalize_context(":.a:b:"), exception); +} + +} // namespace pandaproxy::schema_registry From 75fe8a7559657df0338cf785bf8a7ed70f8a1a9f Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 02:53:59 +0000 Subject: [PATCH 02/14] utils: add extend_variant_t type trait Adds a type-level utility for extending a std::variant with additional alternative types without repeating the original type list. This enables composing variant types where a superset variant needs all alternatives from a base variant plus extras. (cherry picked from commit c1506c29a3a5a8e8f1f365f6a29abb5e07ac816f) --- src/v/utils/tests/BUILD | 13 +++++++++++++ src/v/utils/tests/variant_test.cc | 30 ++++++++++++++++++++++++++++++ src/v/utils/variant.h | 16 ++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/v/utils/tests/variant_test.cc diff --git a/src/v/utils/tests/BUILD b/src/v/utils/tests/BUILD index 6c08f0d347c3b..4b7439ba31bcc 100644 --- a/src/v/utils/tests/BUILD +++ b/src/v/utils/tests/BUILD @@ -628,3 +628,16 @@ redpanda_cc_gtest( "@googletest//:gtest", ], ) + +redpanda_cc_gtest( + name = "variant_test", + timeout = "short", + srcs = [ + "variant_test.cc", + ], + deps = [ + "//src/v/test_utils:gtest", + "//src/v/utils:variant", + "@googletest//:gtest", + ], +) diff --git a/src/v/utils/tests/variant_test.cc b/src/v/utils/tests/variant_test.cc new file mode 100644 index 0000000000000..d5a0f123e6327 --- /dev/null +++ b/src/v/utils/tests/variant_test.cc @@ -0,0 +1,30 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +#include "utils/variant.h" + +#include + +#include +#include +#include + +static_assert(std::is_same_v< + extend_variant_t, bool>, + std::variant>); + +static_assert(std::is_same_v< + extend_variant_t, double, std::string>, + std::variant>); + +static_assert(std::is_same_v< + extend_variant_t>, + std::variant>); + +TEST(VariantTest, CompileOnly) {} diff --git a/src/v/utils/variant.h b/src/v/utils/variant.h index bc5e1dc3176a4..e177fa46e9519 100644 --- a/src/v/utils/variant.h +++ b/src/v/utils/variant.h @@ -35,3 +35,19 @@ using variant_of_identities = decltype(util::detail::variant_of_tags_impl( template using tuple_of_identities = decltype(util::detail::tuple_of_tags_impl( std::type_identity{})); + +namespace util::detail { + +template +struct extend_variant; + +template +struct extend_variant, Extra...> { + using type = std::variant; +}; + +} // namespace util::detail + +template +using extend_variant_t = + typename util::detail::extend_variant::type; From 800487b3af21f55c5808631622cebe7509e3b419 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 01:02:22 +0000 Subject: [PATCH 03/14] sr/context: add auth resource type for context-prefixed routes Splits the auth resource type into `route_resource` (route registration time) and `resource` (authorization time). The new `context_prefix_subject` variant in `route_resource` qualifies the subject with the {context} path param before the ACL check runs, then resolves to `context_subject`. This ensures a user with ACLs on "foo" (default context) cannot access :.staging:foo via the /contexts/.staging/subjects/foo/... URL. (cherry picked from commit 5551d4325d8f79a0666ca2d30164dd72a3510db6) --- src/v/pandaproxy/schema_registry/BUILD | 1 + src/v/pandaproxy/schema_registry/auth.h | 16 +++++++++++--- .../schema_registry/authorization.cc | 22 +++++++++++++------ src/v/pandaproxy/schema_registry/service.cc | 2 +- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/v/pandaproxy/schema_registry/BUILD b/src/v/pandaproxy/schema_registry/BUILD index 239d2ecf20fcb..ac44445fd7765 100644 --- a/src/v/pandaproxy/schema_registry/BUILD +++ b/src/v/pandaproxy/schema_registry/BUILD @@ -252,6 +252,7 @@ redpanda_cc_library( ":types", "//src/v/config:startup_config", "//src/v/kafka/client", + "//src/v/utils:variant", ], ) diff --git a/src/v/pandaproxy/schema_registry/auth.h b/src/v/pandaproxy/schema_registry/auth.h index 461f43a24f27f..8dfda7aba9b9e 100644 --- a/src/v/pandaproxy/schema_registry/auth.h +++ b/src/v/pandaproxy/schema_registry/auth.h @@ -15,6 +15,7 @@ #include "pandaproxy/schema_registry/types.h" #include "pandaproxy/server.h" #include "security/acl.h" +#include "utils/variant.h" #include @@ -42,10 +43,19 @@ class auth { // AuthZ is required to be performed in the handler as the resource is // unknown using deferred = named_type; + // AuthZ will be performed against the context-qualified subject extracted + // from both the {context} and {subject} path parameters + using context_prefix_subject + = named_type; using op = security::acl_operation; + /// Authorization-time resource type. using resource = std::variant; + /// Route-registration-time resource type — includes + /// `context_prefix_subject`, which is resolved to `context_subject` before + /// authorization. + using route_resource = extend_variant_t; using regular_function_handler = ss::noncopyable_function< ss::future(server::request_t, server::reply_t)>; @@ -55,14 +65,14 @@ class auth { using function_handler = std::variant; - auth(level lvl, std::optional op, resource res) + auth(level lvl, std::optional op, route_resource res) : _lvl{lvl} , _op{op} , _res{std::move(res)} {} level get_level() const { return _lvl; } std::optional get_op() const { return _op; } - const resource& get_resource() const { return _res; } + const route_resource& get_resource() const { return _res; } bool is_deferred() const { return std::holds_alternative(get_resource()); } @@ -77,7 +87,7 @@ class auth { private: level _lvl; std::optional _op; - resource _res; + route_resource _res; }; } // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/authorization.cc b/src/v/pandaproxy/schema_registry/authorization.cc index 4e1a439bfd322..baeacb64774d3 100644 --- a/src/v/pandaproxy/schema_registry/authorization.cc +++ b/src/v/pandaproxy/schema_registry/authorization.cc @@ -13,6 +13,7 @@ #include "container/chunked_hash_map.h" #include "pandaproxy/api/api-doc/schema_registry.json.hh" #include "pandaproxy/parsing/httpd.h" +#include "pandaproxy/schema_registry/context_router.h" #include "pandaproxy/schema_registry/service.h" #include "pandaproxy/schema_registry/sharded_store.h" #include "pandaproxy/schema_registry/types.h" @@ -54,15 +55,22 @@ namespace { auth::resource extract_resource_from_request(const server::request_t& rq, const auth& auth) { - auto resource = auth.get_resource(); - ss::visit( - resource, - [&rq](context_subject& ctx_sub) { - ctx_sub = context_subject::from_string( + return ss::visit( + auth.get_resource(), + [&rq](const context_subject&) -> auth::resource { + return context_subject::from_string( parse::request_param(*rq.req, "subject")); }, - [](const auto&) {}); - return resource; + [&rq](const auth::context_prefix_subject&) -> auth::resource { + auto ctx = normalize_context( + parse::request_param(*rq.req, "context")); + auto sub = parse::request_param(*rq.req, "subject"); + if (!starts_with_context(sub)) { + sub = fmt::format(":{}:{}", ctx, sub); + } + return context_subject::from_string(sub); + }, + [](const auto& res) -> auth::resource { return res; }); } void throw_unauthorized() { diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index ea60c7fb44b0a..a2d16a9a4e4aa 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -128,7 +128,7 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { const ss::httpd::path_description& path, auth::level lvl, std::optional op, - auth::resource res, + auth::route_resource res, auth::function_handler h) { return server::route_t{ path, From e017e70cb8ee253eec0ca2bc3438148606d6b716 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Wed, 29 Apr 2026 19:00:08 +0000 Subject: [PATCH 04/14] sr/dt: support base path prefix in schema registry test client Add an optional `base_path` argument to `SchemaRegistryRedpandaClient` that, when set, is prepended to every request path. This lets tests target context-prefixed routes such as `/contexts/.staging/...` without modifying call sites or duplicating the client. `SchemaRegistryEndpoints` exposes the same parameter, and `base_path` is stored via a property that strips leading and trailing slashes so callers can pass either form. Individual `request()` calls can also override the configured prefix via a `base_path=` kwarg: pass `""` to issue a single request without the prefix, or any other string to swap it for that call. The administrative endpoints (`status/ready`, `security/acls`, `contexts`) are server-global and have no context-prefixed routes, so their helpers hardcode `base_path=""` to avoid accidental prefixing when callers have set `self.base_path` for the surrounding test. (cherry picked from commit 913cedb4c824ce1f1ab56b1ada46ad3dc7ba51e2) --- tests/rptest/tests/schema_registry_test.py | 59 +++++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index cbbb2b1056726..3003519b279c4 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -891,24 +891,49 @@ class ReferenceFormat(str, Enum): class SchemaRegistryRedpandaClient: """ A client for acessing the schema registry. + + base_path is an optional URI prefix prepended to every request path, + e.g. "contexts/.staging" to scope all requests to a context-prefixed + base URL. """ def __init__( self, redpanda: RedpandaService, + base_path: str = "", ): self.redpanda = redpanda self.logger = redpanda.logger + self.base_path = base_path http.client.HTTPConnection.debuglevel = 1 http.client.print = lambda *args: self.logger.debug(" ".join(args)) - def request(self, verb, path, hostname=None, tls_enabled: bool = False, **kwargs): + @property + def base_path(self) -> str: + return self._base_path + + @base_path.setter + def base_path(self, value: str) -> None: + self._base_path = value.strip("/") + + def request( + self, + verb, + path, + hostname=None, + tls_enabled: bool = False, + base_path: str | None = None, + **kwargs, + ): """ :param verb: String, as for first arg to requests.request :param path: URI path without leading slash :param timeout: Optional requests timeout in seconds + :param base_path: Per-call override for self.base_path. Pass "" to + issue the request without the configured prefix; pass None + (default) to use self.base_path. :return: """ @@ -920,8 +945,12 @@ def request(self, verb, path, hostname=None, tls_enabled: bool = False, **kwargs node = nodes[0] hostname = node.account.hostname + effective_base_path = ( + self.base_path if base_path is None else base_path.strip("/") + ) scheme = "https" if tls_enabled else "http" - uri = f"{scheme}://{hostname}:8081/{path}" + full_path = f"{effective_base_path}/{path}" if effective_base_path else path + uri = f"{scheme}://{hostname}:8081/{full_path}" if "timeout" not in kwargs: kwargs["timeout"] = 60 @@ -1277,16 +1306,22 @@ def get_status_ready( self, headers=HTTP_GET_HEADERS, tls_enabled: bool = False, **kwargs ): return self.request( - "GET", "status/ready", headers=headers, tls_enabled=tls_enabled, **kwargs + "GET", + "status/ready", + base_path="", + headers=headers, + tls_enabled=tls_enabled, + **kwargs, ) def get_security_acls(self, **kwargs): - return self.request("GET", "security/acls", **kwargs) + return self.request("GET", "security/acls", base_path="", **kwargs) def post_security_acls(self, data, **kwargs): return self.request( "POST", "security/acls", + base_path="", json=data, headers={"Content-Type": "application/json"}, **kwargs, @@ -1296,18 +1331,25 @@ def delete_security_acls(self, data, **kwargs): return self.request( "DELETE", "security/acls", + base_path="", json=data, headers={"Content-Type": "application/json"}, **kwargs, ) def get_contexts(self, headers: Headers = HTTP_GET_HEADERS, **kwargs: Any): - return self.request("GET", "contexts", headers=headers, **kwargs) + return self.request("GET", "contexts", base_path="", headers=headers, **kwargs) def delete_context( self, context: str, headers: Headers = HTTP_DELETE_HEADERS, **kwargs: Any ): - return self.request("DELETE", f"contexts/{context}", headers=headers, **kwargs) + return self.request( + "DELETE", + f"contexts/{context}", + base_path="", + headers=headers, + **kwargs, + ) def create_acl( self, @@ -1345,6 +1387,7 @@ def __init__( context: TestContext, schema_registry_config: SchemaRegistryConfig = SchemaRegistryConfig(), num_brokers: int = 3, + base_path: str = "", extra_rp_conf: Optional[dict[str, Any]] = None, **kwargs: Any, ): @@ -1361,7 +1404,9 @@ def __init__( **kwargs, ) - self.sr_client = SchemaRegistryRedpandaClient(redpanda=self.redpanda) + self.sr_client = SchemaRegistryRedpandaClient( + redpanda=self.redpanda, base_path=base_path + ) def assert_equal(self, first, second, msg=None): assert first == second, msg or f"{first} != {second}" From 55e69667b693dc201a14860e66376b3eff40e809 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Fri, 1 May 2026 20:52:39 +0000 Subject: [PATCH 05/14] sr/context: add context-prefixed routes for {subject} path param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers 15 context-prefixed routes that have a {subject} path parameter. Each route extracts the context from the URL prefix, scopes the subject with it via scope_subject_param(), and delegates to the existing handler. Covers: subject CRUD, versions, compatibility, config/{subject}, and mode/{subject} — all via /contexts/{context}/... URLs. Includes unit tests for scope_subject_param() and ducktape coverage for all 15 endpoints and ACL isolation. (cherry picked from commit 0acf252efdce4842d017cec07d19213f63b63006) --- .../api/api-doc/schema_registry.json | 1016 +++++++++++++++++ .../schema_registry/context_router.h | 15 + src/v/pandaproxy/schema_registry/service.cc | 143 +++ .../schema_registry/test/context_router.cc | 28 + tests/rptest/tests/schema_registry_test.py | 257 +++++ 5 files changed, 1459 insertions(+) diff --git a/src/v/pandaproxy/api/api-doc/schema_registry.json b/src/v/pandaproxy/api/api-doc/schema_registry.json index 7a65f88b28a4c..325280c10b509 100644 --- a/src/v/pandaproxy/api/api-doc/schema_registry.json +++ b/src/v/pandaproxy/api/api-doc/schema_registry.json @@ -1433,6 +1433,1022 @@ } } }, + "/contexts/{context}/subjects/{subject}": { + "post": { + "summary": "Check if a schema is already registered for the subject (context-prefixed).", + "operationId": "ctx_post_subject", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "normalize", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "deleted", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "format", + "in": "query", + "required": false, + "type": "string", + "description": "Redpanda version 25.2 or later. For Avro and Protobuf schemas only. Supported values: an empty string `''` returns the schema in its current format (default), and `serialized` (Protobuf only) returns the schema in its Base64-encoded wire binary format. Unsupported values return a 501 error." + }, + { + "name": "schema_def", + "in": "body", + "schema": { + "$ref": "#/definitions/schema_def_request" + } + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/stored_schema_response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "409": { + "description": "Incompatible schema", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid schema", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "501": { + "description": "Not Implemented: The specified format parameter value is not supported", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "delete": { + "summary": "Delete all schemas for the subject (context-prefixed).", + "operationId": "ctx_delete_subject", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "permanent", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "404": { + "description": "Not Found: Subject not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/subjects/{subject}/versions": { + "get": { + "summary": "Retrieve a list of versions for a subject (context-prefixed).", + "operationId": "ctx_get_subject_versions", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "deleted", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "404": { + "description": "Not Found: Subject not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "post": { + "summary": "Create a new schema for the subject (context-prefixed).", + "operationId": "ctx_post_subject_versions", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "normalize", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/stored_schema_request" + } + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/post_subject_versions_response" + } + }, + "409": { + "description": "Incompatible schema", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid schema", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/subjects/{subject}/versions/{version}": { + "get": { + "summary": "Retrieve a schema for the subject and version (context-prefixed).", + "operationId": "ctx_get_subject_versions_version", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "version", + "in": "path", + "description": "The schema version to retrieve. Use an integer for a specific version or 'latest' for the most recent version.", + "required": true, + "type": "string" + }, + { + "name": "deleted", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "format", + "in": "query", + "required": false, + "type": "string", + "description": "Redpanda version 25.2 or later. For Avro and Protobuf schemas only. Supported values: an empty string `''` returns the schema in its current format (default), and `serialized` (Protobuf only) returns the schema in its Base64-encoded wire binary format. Unsupported values return a 501 error." + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/get_subject_versions_version_response" + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid version", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "501": { + "description": "Not Implemented: The specified format parameter value is not supported", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "delete": { + "summary": "Delete a schema for the subject and version (context-prefixed).", + "operationId": "ctx_delete_subject_version", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "version", + "in": "path", + "description": "The schema version to delete. Use an integer for a specific version or 'latest' for the most recent version.", + "required": true, + "type": "string" + }, + { + "name": "permanent", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "integer" + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid version", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/subjects/{subject}/versions/{version}/schema": { + "get": { + "summary": "Retrieve the raw schema for the subject (context-prefixed).", + "operationId": "ctx_get_subject_versions_version_schema", + "description": "Returns the specified version of the schema in its original format, without backslashes.", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "version", + "in": "path", + "description": "The schema version to retrieve. Use an integer for a specific version or 'latest' for the most recent version.", + "required": true, + "type": "string" + }, + { + "name": "deleted", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "format", + "in": "query", + "required": false, + "type": "string", + "description": "Redpanda version 25.2 or later. For Avro and Protobuf schemas only. Supported values: an empty string `''` returns the schema in its current format (default), and `serialized` (Protobuf only) returns the schema in its Base64-encoded wire binary format. Unsupported values return a 501 error." + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid version", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "501": { + "description": "Not Implemented: The specified format parameter value is not supported", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/subjects/{subject}/versions/{version}/referencedby": { + "get": { + "summary": "Retrieve a list of schema IDs that reference the subject and version (context-prefixed).", + "operationId": "ctx_get_subject_versions_version_referenced_by", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "version", + "in": "path", + "description": "The schema version to check. Use an integer for a specific version or 'latest' for the most recent version.", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid version", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/subjects/{subject}/versions/{version}/referencedBy": { + "get": { + "summary": "Retrieve a list of schema ids that reference the subject and version (context-prefixed).", + "deprecated": true, + "operationId": "ctx_get_subject_versions_version_referenced_by_deprecated", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "version", + "in": "path", + "description": "The schema version to check. Use an integer for a specific version or 'latest' for the most recent version.", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid version", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/compatibility/subjects/{subject}/versions/{version}": { + "post": { + "summary": "Test compatibility of a schema for the subject and version (context-prefixed).", + "operationId": "ctx_compatibility_subject_version", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject name.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "version", + "in": "path", + "description": "The schema version to check compatibility against. Use an integer for a specific version or 'latest' for the most recent version.", + "required": true, + "type": "string" + }, + { + "name": "schema_def", + "in": "body", + "schema": { + "$ref": "#/definitions/schema_def_request" + } + }, + { + "name": "verbose", + "in": "query", + "description": "If true, includes more verbose information about the compatibility check, for example the reason the check failed.", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/is_compatibile" + } + }, + "409": { + "description": "Incompatible schema", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Invalid schema", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/config/{subject}": { + "get": { + "summary": "Get the compatibility level for a subject (context-prefixed).", + "operationId": "ctx_get_config_subject", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject to get the config for.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "defaultToGlobal", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/get_compatibility" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "put": { + "summary": "Set the compatibility level for a subject (context-prefixed).", + "operationId": "ctx_put_config_subject", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject to set the config for.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "config", + "in": "body", + "schema": { + "$ref": "#/definitions/put_compatibility" + } + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/put_compatibility" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "delete": { + "summary": "Delete the compatibility level for a subject (context-prefixed).", + "operationId": "ctx_delete_config_subject", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject to delete the config for.", + "in": "path", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/get_compatibility" + } + }, + "404": { + "description": "Not Found: Subject not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/mode/{subject}": { + "get": { + "summary": "Get the mode for a subject (context-prefixed).", + "operationId": "ctx_get_mode_subject", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject to get the mode for.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "defaultToGlobal", + "description": "If true, return the global mode if the subject doesn't have a mode set.", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mode" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "put": { + "summary": "Set the mode for a subject (context-prefixed).", + "operationId": "ctx_put_mode_subject", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject to set the mode for.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "mode", + "in": "body", + "schema": { + "$ref": "#/definitions/mode" + } + }, + { + "name": "force", + "description": "If true, override the emptiness check when setting a subject's mode to IMPORT", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mode" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "delete": { + "summary": "Delete the mode for a subject (context-prefixed).", + "operationId": "ctx_delete_mode_subject", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "subject", + "description": "The subject to delete the mode for.", + "in": "path", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mode" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, "/security/acls": { "get": { "summary": "List ACLs", diff --git a/src/v/pandaproxy/schema_registry/context_router.h b/src/v/pandaproxy/schema_registry/context_router.h index dccab81de2bb9..2e02e28ad256a 100644 --- a/src/v/pandaproxy/schema_registry/context_router.h +++ b/src/v/pandaproxy/schema_registry/context_router.h @@ -46,4 +46,19 @@ inline bool starts_with_context(std::string_view s) { return s.starts_with(":.") || s.starts_with(":*:"); } +/// \brief Scope the "subject" path parameter by prepending the context. +/// +/// ctx is expected to be in the form ".name" (with leading dot). The +/// resulting subject is ":.ctx:subject". +inline void +scope_subject_param(ss::http::request& req, const ss::sstring& ctx) { + auto sub = req.get_path_param("subject"); + if (!starts_with_context(sub)) { + auto nctx = normalize_context(ctx); + req.param.set( + ss::sstring("subject"), + ss::sstring(fmt::format("/:{0}:{1}", nctx, sub))); + } +} + } // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index a2d16a9a4e4aa..f692679582fe9 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -27,8 +27,10 @@ #include "model/namespace.h" #include "pandaproxy/api/api-doc/schema_registry.json.hh" #include "pandaproxy/logger.h" +#include "pandaproxy/parsing/httpd.h" #include "pandaproxy/schema_registry/auth.h" #include "pandaproxy/schema_registry/configuration.h" +#include "pandaproxy/schema_registry/context_router.h" #include "pandaproxy/schema_registry/handlers.h" #include "pandaproxy/schema_registry/storage.h" #include "pandaproxy/schema_registry/types.h" @@ -366,6 +368,147 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { security::default_cluster_name, delete_security_acls)); + // Context-prefixed route helpers: extract the {context} path parameter, + // apply the given URL-rewriting function, then delegate to the handler. + auto ctx_route = [](auto scope_fn, auto handler) { + return [=]( + server::request_t rq, + server::reply_t rp) -> ss::future { + auto ctx = parse::request_param(*rq.req, "context"); + scope_fn(*rq.req, ctx); + return handler(std::move(rq), std::move(rp)); + }; + }; + + auto ctx_deferred_route = [](auto scope_fn, auto handler) { + return [=]( + server::request_t rq, + server::reply_t rp, + std::optional auth_result) + -> ss::future { + auto ctx = parse::request_param(*rq.req, "context"); + scope_fn(*rq.req, ctx); + return handler( + std::move(rq), std::move(rp), std::move(auth_result)); + }; + }; + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_post_subject, + auth::level::user, + acl_operation::read, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, post_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_delete_subject, + auth::level::user, + acl_operation::remove, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, delete_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_subject_versions, + auth::level::user, + acl_operation::describe, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, get_subject_versions))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_post_subject_versions, + auth::level::user, + acl_operation::write, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, post_subject_versions))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_subject_versions_version, + auth::level::user, + acl_operation::read, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, get_subject_versions_version))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_subject_versions_version_schema, + auth::level::user, + acl_operation::read, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, get_subject_versions_version_schema))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json:: + ctx_get_subject_versions_version_referenced_by, + auth::level::user, + acl_operation::describe, + registry_resource{}, + ctx_route( + scope_subject_param, get_subject_versions_version_referenced_by))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json:: + ctx_get_subject_versions_version_referenced_by_deprecated, + auth::level::user, + acl_operation::describe, + registry_resource{}, + ctx_route( + scope_subject_param, get_subject_versions_version_referenced_by))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_delete_subject_version, + auth::level::user, + acl_operation::remove, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, delete_subject_version))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_compatibility_subject_version, + auth::level::user, + acl_operation::read, + auth::context_prefix_subject{}, + ctx_route(scope_subject_param, compatibility_subject_version))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_config_subject, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_param, get_config_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_put_config_subject, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_param, put_config_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_delete_config_subject, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_param, delete_config_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_mode_subject, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_param, get_mode_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_put_mode_subject, + auth::level::superuser, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_param, put_mode_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_delete_mode_subject, + auth::level::superuser, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_param, delete_mode_subject))); + return routes; } diff --git a/src/v/pandaproxy/schema_registry/test/context_router.cc b/src/v/pandaproxy/schema_registry/test/context_router.cc index b979f4d10016e..02845a5f3419f 100644 --- a/src/v/pandaproxy/schema_registry/test/context_router.cc +++ b/src/v/pandaproxy/schema_registry/test/context_router.cc @@ -65,4 +65,32 @@ TEST(ContextRouterTest, NormalizeContextRejectsEmbeddedColons) { EXPECT_THROW(normalize_context(":.a:b:"), exception); } +TEST(ContextRouterTest, ScopeSubjectParam) { + ss::http::request req; + req.param.set("subject", "/my-topic"); + scope_subject_param(req, ".staging"); + EXPECT_EQ(req.get_path_param("subject"), ":.staging:my-topic"); +} + +TEST(ContextRouterTest, ScopeSubjectParamAlreadyQualified) { + ss::http::request req; + req.param.set("subject", "/:.prod:my-topic"); + scope_subject_param(req, ".staging"); + EXPECT_EQ(req.get_path_param("subject"), ":.prod:my-topic"); +} + +TEST(ContextRouterTest, ScopeSubjectParamWildcard) { + ss::http::request req; + req.param.set("subject", "/:*:my-topic"); + scope_subject_param(req, ".staging"); + EXPECT_EQ(req.get_path_param("subject"), ":*:my-topic"); +} + +TEST(ContextRouterTest, ScopeSubjectParamNoDot) { + ss::http::request req; + req.param.set("subject", "/my-topic"); + scope_subject_param(req, "staging"); + EXPECT_EQ(req.get_path_param("subject"), ":.staging:my-topic"); +} + } // namespace pandaproxy::schema_registry diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index 3003519b279c4..88ec40e5a8d3e 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -6864,6 +6864,117 @@ def test_reserved_subject_names_rejected(self): result = self.sr_client.set_mode_subject(subject=subject, data=mode_data) self.assert_not_equal(result.status_code, 422) + @cluster(num_nodes=3) + def test_context_prefix_subject_operations(self): + """ + Verify all context-prefixed /contexts/{context}/... routes that + contain a {subject} path parameter. Each route should scope the + subject with the context and delegate to the existing handler. + """ + subject = "ctx-prefix-test" + ctx = ".staging" + schema_data = json.dumps({"schema": schema1_def}) + compat_schema_data = json.dumps({"schema": schema2_def}) + self.sr_client.base_path = f"contexts/{ctx}" + + # Register a schema via context-prefixed POST versions + result = self.sr_client.post_subjects_subject_versions( + subject=subject, data=schema_data + ) + assert result.status_code == requests.codes.ok, ( + f"POST versions failed: {result.text}" + ) + schema_id = result.json()["id"] + assert schema_id == 1 + + # GET versions via context prefix + result = self.sr_client.get_subjects_subject_versions(subject=subject) + assert result.status_code == requests.codes.ok + assert result.json() == [1] + + # GET specific version via context prefix + result = self.sr_client.get_subjects_subject_versions_version( + subject=subject, version=1 + ) + assert result.status_code == requests.codes.ok + assert result.json()["version"] == 1 + + # GET version schema via context prefix + result = self.sr_client.get_subjects_subject_versions_version_schema( + subject=subject, version=1 + ) + assert result.status_code == requests.codes.ok + + # GET referencedby via context prefix (empty, no references) + result = self.sr_client.get_subjects_subject_versions_version_referenced_by( + subject=subject, version=1 + ) + assert result.status_code == requests.codes.ok + assert result.json() == [] + + # POST lookup (post_subject) via context prefix + result = self.sr_client.post_subjects_subject(subject=subject, data=schema_data) + assert result.status_code == requests.codes.ok + assert result.json()["id"] == schema_id + + # Compatibility check via context prefix + result = self.sr_client.post_compatibility_subject_version( + subject=subject, version=1, data=compat_schema_data + ) + assert result.status_code == requests.codes.ok + assert result.json()["is_compatible"] is True + + # PUT config/{subject} via context prefix + result = self.sr_client.set_config_subject( + subject=subject, data=json.dumps({"compatibility": "FULL"}) + ) + assert result.status_code == requests.codes.ok + + # GET config/{subject} via context prefix + result = self.sr_client.get_config_subject(subject=subject) + assert result.status_code == requests.codes.ok + assert result.json()["compatibilityLevel"] == "FULL" + + # DELETE config/{subject} via context prefix + result = self.sr_client.delete_config_subject(subject=subject) + assert result.status_code == requests.codes.ok + + # PUT mode/{subject} via context prefix + result = self.sr_client.set_mode_subject( + subject=subject, data=json.dumps({"mode": "READONLY"}) + ) + assert result.status_code == requests.codes.ok + + # GET mode/{subject} via context prefix + result = self.sr_client.get_mode_subject(subject=subject) + assert result.status_code == requests.codes.ok + assert result.json()["mode"] == "READONLY" + + # DELETE mode/{subject} via context prefix + result = self.sr_client.delete_mode_subject(subject=subject) + assert result.status_code == requests.codes.ok + + # Verify isolation: default context should NOT see the subject + result = self.sr_client.get_subjects(base_path="") + assert result.status_code == requests.codes.ok + assert subject not in result.json(), ( + f"Subject {subject} should not be visible in default context" + ) + + # DELETE version via context prefix + result = self.sr_client.delete_subject_version(subject=subject, version=1) + assert result.status_code == requests.codes.ok + + # DELETE subject via context prefix + result = self.sr_client.delete_subject(subject=subject, permanent=True) + assert result.status_code == requests.codes.ok + + # Invalid context name (embedded colon) returns 400 + result = self.sr_client.get_subjects(base_path="contexts/a:b") + assert result.status_code == requests.codes.bad_request, ( + f"Expected 400 for invalid context name, got {result.status_code}" + ) + class SchemaRegistryBasicAuthTest(SchemaRegistryEndpoints): """ @@ -10662,3 +10773,149 @@ def test_nonexistent_schema_id_returns_403_not_404(self): 99999, subject="sub1", auth=self.user_auth ) self.assert_equal(result.status_code, 403) + + @cluster(num_nodes=1) + def test_context_prefix_acl_isolation(self): + """ + A user with ACLs on 'foo' (default context) must NOT be able to + access /contexts/.ctx1/subjects/foo/... — the ACL on the unqualified + subject should not grant access to the context-qualified subject. + """ + # Grant READ on unqualified "sub1" (default context) + self._post_acl(self._create_acl("sub1", "SUBJECT", "LITERAL", "READ")) + self.sr_client.base_path = "contexts/.ctx1" + + # Access via context prefix should be denied — ACL is on "sub1", + # not ":.ctx1:sub1" + result = self.sr_client.get_subjects_subject_versions( + subject="sub1", auth=self.user_auth + ) + self.assert_equal(result.status_code, 403) + + # Grant READ on the context-qualified subject + self._post_acl(self._create_acl(":.ctx1:sub1", "SUBJECT", "LITERAL", "READ")) + + # Now access via context prefix should succeed + result = self.sr_client.get_subjects_subject_versions( + subject="sub1", auth=self.user_auth + ) + self.assert_equal(result.status_code, 200) + + @cluster(num_nodes=1) + def test_context_prefix_acl_with_prefix_pattern(self): + """ + A prefix ACL on ':.ctx1:' grants access to all subjects in .ctx1 + via context-prefixed URLs, but not to subjects in other contexts. + """ + # Grant prefix ACL covering all subjects in .ctx1 + self._post_acl(self._create_acl(":.ctx1:", "SUBJECT", "PREFIXED", "READ")) + + # Access subjects in .ctx1 via prefix URL — should succeed + self.sr_client.base_path = "contexts/.ctx1" + result = self.sr_client.get_subjects_subject_versions( + subject="sub1", auth=self.user_auth + ) + self.assert_equal(result.status_code, 200) + + result = self.sr_client.get_subjects_subject_versions( + subject="sub2", auth=self.user_auth + ) + self.assert_equal(result.status_code, 200) + + # Access a subject in a different context — should be denied + result = self.sr_client.get_subjects_subject_versions( + subject="sub1", auth=self.user_auth, base_path="contexts/.ctx2" + ) + self.assert_equal(result.status_code, 403) + + @cluster(num_nodes=1) + def test_context_prefix_all_subject_operations_protected(self): + """ + All context-prefixed subject endpoints must authorize against the + context-qualified subject, not the bare subject name. + """ + # Grant ACL on unqualified "sub1" with ALL operations + self._post_acl(self._create_acl("sub1", "SUBJECT", "LITERAL", "ALL")) + + # Each of these should be denied because the ACL is on "sub1", + # not ":.ctx1:sub1" + schema_data = json.dumps({"schema": schema1_def}) + self.sr_client.base_path = "contexts/.ctx1" + + # POST subject (lookup) + result = self.sr_client.post_subjects_subject( + subject="sub1", data=schema_data, auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"POST subjects/sub1 should be 403, got {result.status_code}", + ) + + # GET subject versions + result = self.sr_client.get_subjects_subject_versions( + subject="sub1", auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"GET versions should be 403, got {result.status_code}", + ) + + # POST subject versions (register) + result = self.sr_client.post_subjects_subject_versions( + subject="sub1", data=schema_data, auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"POST versions should be 403, got {result.status_code}", + ) + + # GET subject versions version + result = self.sr_client.get_subjects_subject_versions_version( + subject="sub1", version=1, auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"GET version should be 403, got {result.status_code}", + ) + + # GET subject versions version schema + result = self.sr_client.get_subjects_subject_versions_version_schema( + subject="sub1", version=1, auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"GET version/schema should be 403, got {result.status_code}", + ) + + # DELETE subject version + result = self.sr_client.delete_subject_version( + subject="sub1", version=1, auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"DELETE version should be 403, got {result.status_code}", + ) + + # DELETE subject + result = self.sr_client.delete_subject(subject="sub1", auth=self.user_auth) + self.assert_equal( + result.status_code, + 403, + f"DELETE subject should be 403, got {result.status_code}", + ) + + # Compatibility check + result = self.sr_client.post_compatibility_subject_version( + subject="sub1", version=1, data=schema_data, auth=self.user_auth + ) + self.assert_equal( + result.status_code, + 403, + f"Compatibility check should be 403, got {result.status_code}", + ) From 42e278ea8ab88bdcb4ab782a4cccbb97ab42afb6 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Wed, 29 Apr 2026 20:34:26 +0000 Subject: [PATCH 06/14] sr/context: add context-prefixed routes for ?subject query param Registers 4 context-prefixed routes for /schemas/ids/{id} and its sub-resources (/schema, /versions, /subjects). Adds a scope_subject_query helper that injects the context as a "subject" query parameter, scoping schema lookups to the specified context. Includes unit tests for scope_subject_query and ducktape coverage for schema lookup and sub-resource queries. (cherry picked from commit a93647dd4a203e30aa04011f088a47a9f5ba1f39) --- .../api/api-doc/schema_registry.json | 232 ++++++++++++++++++ .../schema_registry/context_router.h | 12 + src/v/pandaproxy/schema_registry/service.cc | 28 +++ .../schema_registry/test/context_router.cc | 26 ++ tests/rptest/tests/schema_registry_test.py | 78 ++++++ 5 files changed, 376 insertions(+) diff --git a/src/v/pandaproxy/api/api-doc/schema_registry.json b/src/v/pandaproxy/api/api-doc/schema_registry.json index 325280c10b509..b10d732acaeda 100644 --- a/src/v/pandaproxy/api/api-doc/schema_registry.json +++ b/src/v/pandaproxy/api/api-doc/schema_registry.json @@ -2449,6 +2449,238 @@ } } }, + "/contexts/{context}/schemas/ids/{id}": { + "get": { + "summary": "Get a schema by ID (context-prefixed).", + "operationId": "ctx_get_schemas_ids_id", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "integer" + }, + { + "name": "format", + "in": "query", + "required": false, + "type": "string", + "description": "Redpanda version 25.2 or later. For Avro and Protobuf schemas only. Supported values: an empty string `''` returns the schema in its current format (default), and `serialized` (Protobuf only) returns the schema in its Base64-encoded wire binary format. Unsupported values return a 501 error." + }, + { + "name": "referenceFormat", + "in": "query", + "required": false, + "type": "string", + "description": "If set to 'qualified', schema references are returned in context-qualified form. Otherwise, unqualified references are returned." + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/schema_def_response" + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "501": { + "description": "Not Implemented: The specified format parameter value is not supported", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/schemas/ids/{id}/schema": { + "get": { + "summary": "Get the raw schema by ID (context-prefixed).", + "operationId": "ctx_get_schemas_ids_id_schema", + "description": "Retrieves only the schema identified by the input ID, without additional metadata.", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "integer" + }, + { + "name": "format", + "in": "query", + "required": false, + "type": "string", + "description": "Desired output format, dependent on schema type." + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "Raw schema string", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/schemas/ids/{id}/versions": { + "get": { + "summary": "Get a list of subject-version for the schema ID (context-prefixed).", + "operationId": "ctx_get_schemas_ids_id_versions", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "integer" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + } + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/schemas/ids/{id}/subjects": { + "get": { + "summary": "Retrieve a list of subjects associated with some schema ID (context-prefixed).", + "operationId": "ctx_get_schemas_ids_id_subjects", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "integer" + }, + { + "name": "deleted", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found: Schema not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, "/security/acls": { "get": { "summary": "List ACLs", diff --git a/src/v/pandaproxy/schema_registry/context_router.h b/src/v/pandaproxy/schema_registry/context_router.h index 2e02e28ad256a..c20f2ebdc4681 100644 --- a/src/v/pandaproxy/schema_registry/context_router.h +++ b/src/v/pandaproxy/schema_registry/context_router.h @@ -61,4 +61,16 @@ scope_subject_param(ss::http::request& req, const ss::sstring& ctx) { } } +/// \brief Inject or prepend context into the "subject" query parameter. +inline void +scope_subject_query(ss::http::request& req, const ss::sstring& ctx) { + auto nctx = normalize_context(ctx); + auto existing = req.get_query_param("subject"); + if (existing.empty()) { + req.set_query_param("subject", fmt::format(":{0}:", nctx)); + } else if (!starts_with_context(existing)) { + req.set_query_param("subject", fmt::format(":{0}:{1}", nctx, existing)); + } +} + } // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index f692679582fe9..cb6bf6efc81ab 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -509,6 +509,34 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { auth::deferred{}, ctx_deferred_route(scope_subject_param, delete_mode_subject))); + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_schemas_ids_id, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_query, get_schemas_ids_id))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_schemas_ids_id_schema, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_query, get_schemas_ids_id_schema))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_schemas_ids_id_versions, + auth::level::user, + acl_operation::describe, + registry_resource{}, + ctx_route(scope_subject_query, get_schemas_ids_id_versions))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_schemas_ids_id_subjects, + auth::level::user, + acl_operation::describe, + registry_resource{}, + ctx_route(scope_subject_query, get_schemas_ids_id_subjects))); + return routes; } diff --git a/src/v/pandaproxy/schema_registry/test/context_router.cc b/src/v/pandaproxy/schema_registry/test/context_router.cc index 02845a5f3419f..dde9c81d3dc5c 100644 --- a/src/v/pandaproxy/schema_registry/test/context_router.cc +++ b/src/v/pandaproxy/schema_registry/test/context_router.cc @@ -93,4 +93,30 @@ TEST(ContextRouterTest, ScopeSubjectParamNoDot) { EXPECT_EQ(req.get_path_param("subject"), ":.staging:my-topic"); } +TEST(ContextRouterTest, ScopeSubjectQueryAbsent) { + ss::http::request req; + scope_subject_query(req, ".staging"); + EXPECT_EQ(req.get_query_param("subject"), ":.staging:"); +} + +TEST(ContextRouterTest, ScopeSubjectQueryPlain) { + ss::http::request req; + req.set_query_param("subject", "my-topic"); + scope_subject_query(req, ".staging"); + EXPECT_EQ(req.get_query_param("subject"), ":.staging:my-topic"); +} + +TEST(ContextRouterTest, ScopeSubjectQueryAlreadyQualified) { + ss::http::request req; + req.set_query_param("subject", ":.prod:my-topic"); + scope_subject_query(req, ".staging"); + EXPECT_EQ(req.get_query_param("subject"), ":.prod:my-topic"); +} + +TEST(ContextRouterTest, ScopeSubjectQueryNoDot) { + ss::http::request req; + scope_subject_query(req, "staging"); + EXPECT_EQ(req.get_query_param("subject"), ":.staging:"); +} + } // namespace pandaproxy::schema_registry diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index 88ec40e5a8d3e..c07614c309c0c 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -6975,6 +6975,63 @@ def test_context_prefix_subject_operations(self): f"Expected 400 for invalid context name, got {result.status_code}" ) + @cluster(num_nodes=3) + def test_context_prefix_schema_by_id(self): + """ + Verify the context-prefixed /contexts/{context}/schemas/ids/{id} and + sub-resource routes. The wrapper injects the context as a subject + query parameter, scoping schema lookups to the specified context. + """ + subject = "ctx-schema-id-test" + ctx = ".staging" + schema_data = json.dumps({"schema": schema1_def}) + self.sr_client.base_path = f"contexts/{ctx}" + + # Register a schema in the .staging context so we have an ID to look up. + result = self.sr_client.post_subjects_subject_versions( + subject=subject, data=schema_data + ) + assert result.status_code == requests.codes.ok, ( + f"POST versions failed: {result.text}" + ) + schema_id = result.json()["id"] + + # GET /contexts/{ctx}/schemas/ids/{id} + result = self.sr_client.get_schemas_ids_id(id=schema_id) + assert result.status_code == requests.codes.ok, ( + f"GET schemas/ids/{schema_id} failed: {result.text}" + ) + assert "schema" in result.json() + + # GET /contexts/{ctx}/schemas/ids/{id}/schema + result = self.sr_client.get_schemas_ids_id_schema(id=schema_id) + assert result.status_code == requests.codes.ok, ( + f"GET schemas/ids/{schema_id}/schema failed: {result.text}" + ) + + # GET /contexts/{ctx}/schemas/ids/{id}/versions + result = self.sr_client.get_schemas_ids_id_versions(id=schema_id) + assert result.status_code == requests.codes.ok, ( + f"GET schemas/ids/{schema_id}/versions failed: {result.text}" + ) + versions = result.json() + assert len(versions) >= 1 + qualified_subject = f":.{ctx.lstrip('.')}:{subject}" + subjects_in_versions = [v["subject"] for v in versions] + assert qualified_subject in subjects_in_versions, ( + f"Expected {qualified_subject} in versions {subjects_in_versions}" + ) + + # GET /contexts/{ctx}/schemas/ids/{id}/subjects + result = self.sr_client.get_schemas_ids_id_subjects(id=schema_id) + assert result.status_code == requests.codes.ok, ( + f"GET schemas/ids/{schema_id}/subjects failed: {result.text}" + ) + subjects = result.json() + assert qualified_subject in subjects, ( + f"Expected {qualified_subject} in subjects {subjects}" + ) + class SchemaRegistryBasicAuthTest(SchemaRegistryEndpoints): """ @@ -10919,3 +10976,24 @@ def test_context_prefix_all_subject_operations_protected(self): 403, f"Compatibility check should be 403, got {result.status_code}", ) + + # Schema-by-ID routes use deferred auth with scope_subject_query. + # The context prefix injects subject=:.ctx1: which should not match + # the ACL on unqualified "sub1". + sid = self.schema_id_ctx1 + + # GET /contexts/.ctx1/schemas/ids/{id} + result = self.sr_client.get_schemas_ids_id(sid, auth=self.user_auth) + self.assert_equal( + result.status_code, + 403, + f"GET schemas/ids/{sid} should be 403, got {result.status_code}", + ) + + # GET /contexts/.ctx1/schemas/ids/{id}/schema + result = self.sr_client.get_schemas_ids_id_schema(sid, auth=self.user_auth) + self.assert_equal( + result.status_code, + 403, + f"GET schemas/ids/{sid}/schema should be 403, got {result.status_code}", + ) From 3739d0967949a7f32851bf8e4b846f83c79f6767 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 00:46:46 +0000 Subject: [PATCH 07/14] sr/context: add context-prefixed routes for ?subjectPrefix query param Adds scope_subject_prefix_query(), which injects or prepends the normalized context into the subjectPrefix query parameter. The context-prefixed GET /contexts/{context}/subjects route uses this helper to scope subject listings to the specified context. Includes unit tests for scope_subject_prefix_query and ducktape coverage verifying subject isolation across contexts. (cherry picked from commit c44d2c39136fd073fd3998555c93e8723fcfd792) --- .../api/api-doc/schema_registry.json | 43 +++++++++++++++++++ .../schema_registry/context_router.h | 13 ++++++ src/v/pandaproxy/schema_registry/service.cc | 7 +++ .../schema_registry/test/context_router.cc | 26 +++++++++++ tests/rptest/tests/schema_registry_test.py | 42 ++++++++++++++++++ 5 files changed, 131 insertions(+) diff --git a/src/v/pandaproxy/api/api-doc/schema_registry.json b/src/v/pandaproxy/api/api-doc/schema_registry.json index b10d732acaeda..5c411813ae542 100644 --- a/src/v/pandaproxy/api/api-doc/schema_registry.json +++ b/src/v/pandaproxy/api/api-doc/schema_registry.json @@ -2449,6 +2449,49 @@ } } }, + "/contexts/{context}/subjects": { + "get": { + "summary": "Retrieve a list of subjects in a context (context-prefixed).", + "operationId": "ctx_get_subjects", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "deleted", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, "/contexts/{context}/schemas/ids/{id}": { "get": { "summary": "Get a schema by ID (context-prefixed).", diff --git a/src/v/pandaproxy/schema_registry/context_router.h b/src/v/pandaproxy/schema_registry/context_router.h index c20f2ebdc4681..36c7eb0a66cf9 100644 --- a/src/v/pandaproxy/schema_registry/context_router.h +++ b/src/v/pandaproxy/schema_registry/context_router.h @@ -73,4 +73,17 @@ scope_subject_query(ss::http::request& req, const ss::sstring& ctx) { } } +/// \brief Inject or prepend context into the "subjectPrefix" query parameter. +inline void +scope_subject_prefix_query(ss::http::request& req, const ss::sstring& ctx) { + auto nctx = normalize_context(ctx); + auto existing = req.get_query_param("subjectPrefix"); + if (existing.empty()) { + req.set_query_param("subjectPrefix", fmt::format(":{0}:", nctx)); + } else if (!starts_with_context(existing)) { + req.set_query_param( + "subjectPrefix", fmt::format(":{0}:{1}", nctx, existing)); + } +} + } // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index cb6bf6efc81ab..27379fdeeaa36 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -537,6 +537,13 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { registry_resource{}, ctx_route(scope_subject_query, get_schemas_ids_id_subjects))); + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_subjects, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(scope_subject_prefix_query, get_subjects))); + return routes; } diff --git a/src/v/pandaproxy/schema_registry/test/context_router.cc b/src/v/pandaproxy/schema_registry/test/context_router.cc index dde9c81d3dc5c..f36a491056bf0 100644 --- a/src/v/pandaproxy/schema_registry/test/context_router.cc +++ b/src/v/pandaproxy/schema_registry/test/context_router.cc @@ -119,4 +119,30 @@ TEST(ContextRouterTest, ScopeSubjectQueryNoDot) { EXPECT_EQ(req.get_query_param("subject"), ":.staging:"); } +TEST(ContextRouterTest, ScopeSubjectPrefixQueryAbsent) { + ss::http::request req; + scope_subject_prefix_query(req, ".staging"); + EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.staging:"); +} + +TEST(ContextRouterTest, ScopeSubjectPrefixQueryPlain) { + ss::http::request req; + req.set_query_param("subjectPrefix", "my-"); + scope_subject_prefix_query(req, ".staging"); + EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.staging:my-"); +} + +TEST(ContextRouterTest, ScopeSubjectPrefixQueryAlreadyQualified) { + ss::http::request req; + req.set_query_param("subjectPrefix", ":.prod:"); + scope_subject_prefix_query(req, ".staging"); + EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.prod:"); +} + +TEST(ContextRouterTest, ScopeSubjectPrefixQueryNoDot) { + ss::http::request req; + scope_subject_prefix_query(req, "staging"); + EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.staging:"); +} + } // namespace pandaproxy::schema_registry diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index c07614c309c0c..b22e9580fb34e 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -7032,6 +7032,48 @@ def test_context_prefix_schema_by_id(self): f"Expected {qualified_subject} in subjects {subjects}" ) + @cluster(num_nodes=3) + def test_context_prefix_subject_listing(self): + """ + Verify the context-prefixed GET /contexts/{context}/subjects route. + The wrapper injects the context into the subjectPrefix query parameter, + scoping the subject listing to the specified context. + """ + ctx = ".listing" + schema_data = json.dumps({"schema": schema1_def}) + self.sr_client.base_path = f"contexts/{ctx}" + + # Register two subjects in the .listing context + for subj in ("topic-a", "topic-b"): + result = self.sr_client.post_subjects_subject_versions( + subject=subj, data=schema_data + ) + assert result.status_code == requests.codes.ok, ( + f"POST {subj} failed: {result.text}" + ) + + # Register a subject in the default context + result = self.sr_client.post_subjects_subject_versions( + subject="default-only-subject", data=schema_data, base_path="" + ) + assert result.status_code == requests.codes.ok, ( + f"POST default subject failed: {result.text}" + ) + + # GET /contexts/{ctx}/subjects — only context subjects should appear + result = self.sr_client.get_subjects() + assert result.status_code == requests.codes.ok, ( + f"GET contexts/{ctx}/subjects failed: {result.text}" + ) + listed = result.json() + assert "default-only-subject" not in listed, ( + f"Default-context subject should not appear: {listed}" + ) + ctx_name = ctx.lstrip(".") + for subj in ("topic-a", "topic-b"): + qualified = f":.{ctx_name}:{subj}" + assert qualified in listed, f"Expected {qualified} in {listed}" + class SchemaRegistryBasicAuthTest(SchemaRegistryEndpoints): """ From b16763d2a0619744a33aae31b4d10df5c14ceedb Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 00:50:45 +0000 Subject: [PATCH 08/14] sr/context: add context-prefixed routes for config, mode, and types Registers context-prefixed routes for context-level config/mode and schema types: - GET/PUT/DELETE /contexts/{context}/config - GET/PUT/DELETE /contexts/{context}/mode - GET /contexts/{context}/schemas/types (pass-through) Adds inject_context_as_subject(), which sets the subject path parameter to a context-only qualified subject (e.g., ":.staging:"). The config and mode wrappers use this to delegate to the existing config/mode subject handlers. Schema types are global so the context is accepted for compatibility but ignored. Includes unit tests for inject_context_as_subject and ducktape coverage for all operations. (cherry picked from commit 8e2b44b2c76ba5c87ea8ce77062472be24df7db7) --- .../api/api-doc/schema_registry.json | 307 ++++++++++++++++++ .../schema_registry/context_router.h | 9 + src/v/pandaproxy/schema_registry/service.cc | 55 ++++ .../schema_registry/test/context_router.cc | 12 + tests/rptest/tests/schema_registry_test.py | 118 +++++++ 5 files changed, 501 insertions(+) diff --git a/src/v/pandaproxy/api/api-doc/schema_registry.json b/src/v/pandaproxy/api/api-doc/schema_registry.json index 5c411813ae542..41c2510c49b31 100644 --- a/src/v/pandaproxy/api/api-doc/schema_registry.json +++ b/src/v/pandaproxy/api/api-doc/schema_registry.json @@ -2492,6 +2492,313 @@ } } }, + "/contexts/{context}/schemas/types": { + "get": { + "summary": "Get the supported schema types (context-prefixed).", + "operationId": "ctx_get_schemas_types", + "parameters": [ + { + "name": "context", + "description": "The context to scope the operation to (e.g., .staging). Accepted for Confluent compatibility but ignored — schema types are global.", + "in": "path", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/config": { + "get": { + "summary": "Get the compatibility level for a context.", + "operationId": "ctx_get_config", + "parameters": [ + { + "name": "context", + "description": "The context to get the config for (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "defaultToGlobal", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/get_compatibility" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "put": { + "summary": "Set the compatibility level for a context.", + "operationId": "ctx_put_config", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to set the config for (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "config", + "in": "body", + "schema": { + "$ref": "#/definitions/put_compatibility" + } + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/put_compatibility" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "delete": { + "summary": "Delete the compatibility level for a context.", + "operationId": "ctx_delete_config", + "parameters": [ + { + "name": "context", + "description": "The context to delete the config for (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/get_compatibility" + } + }, + "404": { + "description": "Not Found: Subject not found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, + "/contexts/{context}/mode": { + "get": { + "summary": "Get the mode for a context.", + "operationId": "ctx_get_mode", + "parameters": [ + { + "name": "context", + "description": "The context to get the mode for (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "defaultToGlobal", + "description": "If true, return the global mode if the context doesn't have a mode set.", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mode" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "put": { + "summary": "Set the mode for a context.", + "operationId": "ctx_put_mode", + "consumes": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "parameters": [ + { + "name": "context", + "description": "The context to set the mode for (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "mode", + "in": "body", + "schema": { + "$ref": "#/definitions/mode" + } + }, + { + "name": "force", + "description": "If true, override the emptiness check when setting the context mode to IMPORT", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mode" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + }, + "delete": { + "summary": "Delete the mode for a context.", + "operationId": "ctx_delete_mode", + "parameters": [ + { + "name": "context", + "description": "The context to delete the mode for (e.g., .staging).", + "in": "path", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/vnd.schemaregistry.v1+json", + "application/vnd.schemaregistry+json", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mode" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/error_body" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/error_body" + } + } + } + } + }, "/contexts/{context}/schemas/ids/{id}": { "get": { "summary": "Get a schema by ID (context-prefixed).", diff --git a/src/v/pandaproxy/schema_registry/context_router.h b/src/v/pandaproxy/schema_registry/context_router.h index 36c7eb0a66cf9..1111d3a12d457 100644 --- a/src/v/pandaproxy/schema_registry/context_router.h +++ b/src/v/pandaproxy/schema_registry/context_router.h @@ -86,4 +86,13 @@ scope_subject_prefix_query(ss::http::request& req, const ss::sstring& ctx) { } } +/// \brief Inject the context as a context-only qualified subject path +/// parameter. +inline void +inject_context_as_subject(ss::http::request& req, const ss::sstring& ctx) { + auto nctx = normalize_context(ctx); + req.param.set( + ss::sstring("subject"), ss::sstring(fmt::format("/:{0}:", nctx))); +} + } // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index 27379fdeeaa36..e54a5f96fd046 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -544,6 +544,61 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { auth::deferred{}, ctx_deferred_route(scope_subject_prefix_query, get_subjects))); + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_config, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(inject_context_as_subject, get_config_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_put_config, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(inject_context_as_subject, put_config_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_mode, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(inject_context_as_subject, get_mode_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_put_mode, + auth::level::superuser, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(inject_context_as_subject, put_mode_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_get_schemas_types, + auth::level::publik, + acl_operation::read, + auth::none{}, + // Schema types are global — the handler ignores the context. Validate + // it anyway for consistency with other /contexts/{context}/... routes. + ctx_route( + [](ss::http::request&, const ss::sstring& ctx) { + normalize_context(ctx); + }, + get_schemas_types))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_delete_config, + auth::level::user, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(inject_context_as_subject, delete_config_subject))); + + routes.routes.emplace_back(wrap( + ss::httpd::schema_registry_json::ctx_delete_mode, + auth::level::superuser, + std::nullopt, + auth::deferred{}, + ctx_deferred_route(inject_context_as_subject, delete_mode_subject))); + return routes; } diff --git a/src/v/pandaproxy/schema_registry/test/context_router.cc b/src/v/pandaproxy/schema_registry/test/context_router.cc index f36a491056bf0..d64c36d922b2d 100644 --- a/src/v/pandaproxy/schema_registry/test/context_router.cc +++ b/src/v/pandaproxy/schema_registry/test/context_router.cc @@ -145,4 +145,16 @@ TEST(ContextRouterTest, ScopeSubjectPrefixQueryNoDot) { EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.staging:"); } +TEST(ContextRouterTest, InjectContextAsSubject) { + ss::http::request req; + inject_context_as_subject(req, ".staging"); + EXPECT_EQ(req.get_path_param("subject"), ":.staging:"); +} + +TEST(ContextRouterTest, InjectContextAsSubjectNoDot) { + ss::http::request req; + inject_context_as_subject(req, "staging"); + EXPECT_EQ(req.get_path_param("subject"), ":.staging:"); +} + } // namespace pandaproxy::schema_registry diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index b22e9580fb34e..6854105c00a60 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -997,6 +997,9 @@ def get_config(self, headers=HTTP_GET_HEADERS, **kwargs): def set_config(self, data, headers=HTTP_POST_HEADERS, **kwargs): return self.request("PUT", "config", headers=headers, data=data, **kwargs) + def delete_config(self, headers: Headers = HTTP_DELETE_HEADERS, **kwargs: Any): + return self.request("DELETE", "config", headers=headers, **kwargs) + def get_config_subject( self, subject, fallback=False, headers=HTTP_GET_HEADERS, **kwargs ): @@ -1035,6 +1038,9 @@ def set_mode(self, data, force=False, headers=HTTP_POST_HEADERS, **kwargs): **kwargs, ) + def delete_mode(self, headers: Headers = HTTP_DELETE_HEADERS, **kwargs: Any): + return self.request("DELETE", "mode", headers=headers, **kwargs) + def get_mode_subject( self, subject, fallback=False, headers=HTTP_GET_HEADERS, **kwargs ): @@ -7074,6 +7080,118 @@ def test_context_prefix_subject_listing(self): qualified = f":.{ctx_name}:{subj}" assert qualified in listed, f"Expected {qualified} in {listed}" + @cluster(num_nodes=3) + def test_context_prefix_config_and_mode(self): + """ + Verify GET/PUT /contexts/{context}/config and /contexts/{context}/mode. + The wrapper injects the context as a context-only qualified subject + (e.g., ':.cfgmode:') and delegates to the existing config/mode + subject handlers. + """ + ctx = ".cfgmode" + schema_data = json.dumps({"schema": schema1_def}) + self.sr_client.base_path = f"contexts/{ctx}" + + # Materialize the context by registering a schema + result = self.sr_client.post_subjects_subject_versions( + subject="cfg-subject", data=schema_data + ) + assert result.status_code == requests.codes.ok, ( + f"POST schema failed: {result.text}" + ) + + # PUT /contexts/{ctx}/config + result = self.sr_client.set_config(data=json.dumps({"compatibility": "FULL"})) + assert result.status_code == requests.codes.ok, ( + f"PUT config failed: {result.text}" + ) + + # GET /contexts/{ctx}/config + result = self.sr_client.get_config() + assert result.status_code == requests.codes.ok, ( + f"GET config failed: {result.text}" + ) + assert result.json()["compatibilityLevel"] == "FULL", ( + f"Unexpected config response: {result.json()}" + ) + + # PUT /contexts/{ctx}/mode + result = self.sr_client.set_mode(data=json.dumps({"mode": "READONLY"})) + assert result.status_code == requests.codes.ok, ( + f"PUT mode failed: {result.text}" + ) + + # GET /contexts/{ctx}/mode + result = self.sr_client.get_mode() + assert result.status_code == requests.codes.ok, ( + f"GET mode failed: {result.text}" + ) + assert result.json()["mode"] == "READONLY", ( + f"Unexpected mode response: {result.json()}" + ) + + @cluster(num_nodes=3) + def test_context_prefix_schema_types(self): + """ + Verify GET /contexts/{context}/schemas/types passes through to the + global schema-types handler. The context is accepted for Confluent + compatibility but ignored. + """ + self.sr_client.base_path = "contexts/.staging" + result = self.sr_client.get_schemas_types() + assert result.status_code == requests.codes.ok, ( + f"GET schemas/types failed: {result.text}" + ) + types = result.json() + assert "AVRO" in types, f"Expected AVRO in schema types: {types}" + + # Invalid context name (embedded colon) returns 400 + self.sr_client.base_path = "contexts/a:b" + result = self.sr_client.get_schemas_types() + assert result.status_code == requests.codes.bad_request, ( + f"Expected 400 for invalid context name, got {result.status_code}" + ) + + @cluster(num_nodes=3) + def test_context_prefix_delete_config_and_mode(self): + """ + Verify DELETE /contexts/{context}/config and /contexts/{context}/mode. + The wrapper injects the context as a context-only qualified subject + and delegates to the existing delete_config_subject and + delete_mode_subject handlers. + """ + ctx = ".delcfg" + schema_data = json.dumps({"schema": schema1_def}) + self.sr_client.base_path = f"contexts/{ctx}" + + # Materialize the context + result = self.sr_client.post_subjects_subject_versions( + subject="del-subject", data=schema_data + ) + assert result.status_code == requests.codes.ok, ( + f"POST schema failed: {result.text}" + ) + + # Set config, then DELETE it + result = self.sr_client.set_config(data=json.dumps({"compatibility": "FULL"})) + assert result.status_code == requests.codes.ok, ( + f"PUT config failed: {result.text}" + ) + result = self.sr_client.delete_config() + assert result.status_code == requests.codes.ok, ( + f"DELETE config failed: {result.text}" + ) + + # Set mode, then DELETE it + result = self.sr_client.set_mode(data=json.dumps({"mode": "READONLY"})) + assert result.status_code == requests.codes.ok, ( + f"PUT mode failed: {result.text}" + ) + result = self.sr_client.delete_mode() + assert result.status_code == requests.codes.ok, ( + f"DELETE mode failed: {result.text}" + ) + class SchemaRegistryBasicAuthTest(SchemaRegistryEndpoints): """ From 80e7b866d3c9ebc1dde26fe60ee6e9cfa2e70f0a Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 00:51:50 +0000 Subject: [PATCH 09/14] sr/context: fix context normalization in DELETE /contexts/{context} While implementing context-prefixed route handlers, noticed that DELETE /contexts/{context} was not normalizing the context path parameter before the default-context check. Apply normalize_context() to the context path parameter before the default-context check. This ensures alias forms like "staging", ":.staging:", and ".staging" all resolve to the same canonical context for deletion. Includes ducktape coverage cycling through alias forms. (cherry picked from commit 7c2d3d8bfa0f6bb2962c837a49d3281baf062439) --- src/v/pandaproxy/schema_registry/handlers.cc | 3 ++- tests/rptest/tests/schema_registry_test.py | 28 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/v/pandaproxy/schema_registry/handlers.cc b/src/v/pandaproxy/schema_registry/handlers.cc index 3fc25ea8e12ac..cccfb800b22c1 100644 --- a/src/v/pandaproxy/schema_registry/handlers.cc +++ b/src/v/pandaproxy/schema_registry/handlers.cc @@ -20,6 +20,7 @@ #include "pandaproxy/logger.h" #include "pandaproxy/parsing/httpd.h" #include "pandaproxy/schema_registry/authorization.h" +#include "pandaproxy/schema_registry/context_router.h" #include "pandaproxy/schema_registry/error.h" #include "pandaproxy/schema_registry/errors.h" #include "pandaproxy/schema_registry/exceptions.h" @@ -1514,7 +1515,7 @@ delete_context(server::request_t rq, server::reply_t rp) { parse_accept_header(rq, rp); auto ctx_str = parse::request_param(*rq.req, "context"); - auto ctx = context{ctx_str}; + auto ctx = context{normalize_context(ctx_str)}; if (ctx == default_context) { throw as_exception( diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index 6854105c00a60..0de15f8fae908 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -5713,6 +5713,34 @@ def test_context_list_delete(self): result = self.sr_client.delete_context(".nonexistent") self.assert_equal(result.status_code, 404) + # Test context alias normalization for delete: + # All alias forms for the same context should resolve and delete it. + alias_ctx = ".alias-ctx" + alias_subject = f":{alias_ctx}:alias-sub" + for delete_alias in ["alias-ctx", ":.alias-ctx:", ".alias-ctx"]: + result = self.sr_client.post_subjects_subject_versions( + subject=alias_subject, + data=json.dumps({"schema": schema1_def}), + ) + self.assert_equal(result.status_code, requests.codes.ok) + result = self.sr_client.delete_subject(subject=alias_subject) + self.assert_equal(result.status_code, requests.codes.ok) + result = self.sr_client.delete_subject( + subject=alias_subject, permanent=True + ) + self.assert_equal(result.status_code, requests.codes.ok) + + result = self.sr_client.delete_context(delete_alias) + self.assert_equal( + result.status_code, + 204, + f"delete_context({delete_alias!r}) should succeed", + ) + + # Verify default context rejection works with alias form ":.:" + result = self.sr_client.delete_context(":.:") + self.assert_equal(result.status_code, 422) + @cluster(num_nodes=1) def test_get_schema_by_id_with_subject(self): """Test GET /schemas/ids/{id} with subject query parameter for context lookup.""" From 957d6da9c8b2d07fbd7c4e99b195cbce7b168b99 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 00:52:02 +0000 Subject: [PATCH 10/14] tests/go: upgrade confluent-kafka-go from v2.0.2 to v2.3.0 The v2.0.2 Schema Registry client stripped the path component from URLs (confluent-kafka-go#943), preventing context-prefixed URLs like /contexts/.serde from working. Fixed in v2.1.0 via PR #950. (cherry picked from commit 18720e284c6e18d3b6a952e692583a4b8b9fdaf9) --- tests/go/go-kafka-serde/go.mod | 16 ++-- tests/go/go-kafka-serde/go.sum | 135 +++++++++++---------------------- 2 files changed, 54 insertions(+), 97 deletions(-) diff --git a/tests/go/go-kafka-serde/go.mod b/tests/go/go-kafka-serde/go.mod index ed7bc7558bee2..3b04746484212 100644 --- a/tests/go/go-kafka-serde/go.mod +++ b/tests/go/go-kafka-serde/go.mod @@ -3,20 +3,20 @@ module com.redpanda/go-kafka-serde go 1.19 require ( - github.com/confluentinc/confluent-kafka-go/v2 v2.0.2 + github.com/confluentinc/confluent-kafka-go/v2 v2.3.0 github.com/google/uuid v1.3.0 github.com/redpanda-data/kgo-verifier v0.0.0-20230314144634-a2b6c6ddb0aa - github.com/sirupsen/logrus v1.9.0 + github.com/sirupsen/logrus v1.9.3 golang.org/x/exp v0.0.0-20230321023759-10a507213a29 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - google.golang.org/protobuf v1.30.0 + google.golang.org/protobuf v1.33.0 ) require ( github.com/actgardner/gogen-avro/v10 v10.2.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/heetch/avro v0.3.1 // indirect - github.com/jhump/protoreflect v1.12.0 // indirect - golang.org/x/sys v0.1.0 // indirect - google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/heetch/avro v0.4.4 // indirect + github.com/jhump/protoreflect v1.14.1 // indirect + golang.org/x/sys v0.6.0 // indirect + google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633 // indirect ) diff --git a/tests/go/go-kafka-serde/go.sum b/tests/go/go-kafka-serde/go.sum index 3c7e119bade58..a612f144aa82f 100644 --- a/tests/go/go-kafka-serde/go.sum +++ b/tests/go/go-kafka-serde/go.sum @@ -1,47 +1,41 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/actgardner/gogen-avro/v10 v10.1.0/go.mod h1:o+ybmVjEa27AAr35FRqU98DJu1fXES56uXniYFv4yDA= +github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= +github.com/Microsoft/hcsshim v0.9.4 h1:mnUj0ivWy6UzbB1uLFqKR6F+ZyiDc7j4iGgHTpO+5+I= github.com/actgardner/gogen-avro/v10 v10.2.1 h1:z3pOGblRjAJCYpkIJ8CmbMJdksi4rAhaygw0dyXZ930= github.com/actgardner/gogen-avro/v10 v10.2.1/go.mod h1:QUhjeHPchheYmMDni/Nx7VB0RsT/ee8YIgGY/xpEQgQ= -github.com/actgardner/gogen-avro/v9 v9.1.0/go.mod h1:nyTj6wPqDJoxM3qdnjcLv+EnMDSDFqE0qDpva2QRmKc= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/confluentinc/confluent-kafka-go/v2 v2.0.2 h1:YmUjjRp1mSTqTxtHQYMQKBLa2hfgIZz9PSqoSRDkwf4= -github.com/confluentinc/confluent-kafka-go/v2 v2.0.2/go.mod h1:qWGwym8EpAsIP5lZsTKhYTnYSGqkbxEfRB4A489Jo64= +github.com/confluentinc/confluent-kafka-go/v2 v2.3.0 h1:icCHutJouWlQREayFwCc7lxDAhws08td+W3/gdqgZts= +github.com/confluentinc/confluent-kafka-go/v2 v2.3.0/go.mod h1:/VTy8iEpe6mD9pkCH5BhijlUl8ulUXymKv1Qig5Rgb8= +github.com/containerd/cgroups v1.0.4 h1:jN/mbWBEaz+T1pi5OFtnkQ+8qnmEbAr1Oo1FRm5B0dA= +github.com/containerd/containerd v1.6.8 h1:h4dOFDwzHmqFEP754PgfgTeVXFnLiRc6kiqC7tplDJs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= +github.com/docker/docker v20.10.17+incompatible h1:JYCuMrWaVNophQTOrMMoSwudOVEfcegoZZrleKc1xwE= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss= github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= @@ -49,13 +43,10 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -66,25 +57,17 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20211008130755-947d60d73cc0/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hamba/avro v1.5.6/go.mod h1:3vNT0RLXXpFm2Tb/5KC71ZRJlOroggq1Rcitb6k4Fr8= -github.com/heetch/avro v0.3.1 h1:i6DyUBDIwzt6Fs78dYBIXYd5XrYUs/ir4+39WbHQhJE= -github.com/heetch/avro v0.3.1/go.mod h1:4xn38Oz/+hiEUTpbVfGVLfvOg0yKLlRP7Q9+gJJILgA= -github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= -github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= -github.com/invopop/jsonschema v0.4.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0= +github.com/heetch/avro v0.4.4 h1:5PmgDy1cX/MegMy6btJ4bUFHgT5GLfSYfc5U7+JUQzg= +github.com/heetch/avro v0.4.4/go.mod h1:c0whqijPh/C+RwnXzAHFit01tdtf7gMeEHYSbICxJjU= github.com/jhump/gopoet v0.0.0-20190322174617-17282ff210b3/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= github.com/jhump/gopoet v0.1.0/go.mod h1:me9yfT6IJSlOL3FCfrg+L6yzUEZ+5jW6WHt4Sk+UPUI= github.com/jhump/goprotoc v0.5.0/go.mod h1:VrbvcYrQOrTi3i0Vf+m+oqQWk9l72mjkJCYo7UvLHRQ= github.com/jhump/protoreflect v1.11.0/go.mod h1:U7aMIjN0NWq9swDP7xDdoMfRHb35uiuTd3Z9nFXJf5E= -github.com/jhump/protoreflect v1.12.0 h1:1NQ4FpWMgn3by/n1X0fbeKEUxP1wBt7+Oitpv01HR10= -github.com/jhump/protoreflect v1.12.0/go.mod h1:JytZfP5d0r8pVNLZvai7U/MCuTWITgrI4tTg7puQFKI= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jhump/protoreflect v1.14.1 h1:N88q7JkxTHWFEqReuTsYH1dPIwXxA0ITNQp7avLY10s= +github.com/jhump/protoreflect v1.14.1/go.mod h1:JytZfP5d0r8pVNLZvai7U/MCuTWITgrI4tTg7puQFKI= github.com/juju/qthttptest v0.1.1/go.mod h1:aTlAv8TYaflIiTDIQYzxnl1QdPjAg8Q8qJMErpKy6A4= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -95,38 +78,36 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/linkedin/goavro v2.1.0+incompatible/go.mod h1:bBCwI2eGYpUI/4820s67MElg9tdeLbINjLjiM2xZFYM= -github.com/linkedin/goavro/v2 v2.10.0/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= -github.com/linkedin/goavro/v2 v2.10.1/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= github.com/linkedin/goavro/v2 v2.11.1/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/nrwiersma/avro-benchmarks v0.0.0-20210913175520-21aec48c8f76/go.mod h1:iKyFMidsk/sVYONJRE372sJuX/QTRPacU7imPqqsu7g= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/moby/sys/mount v0.3.3 h1:fX1SVkXFJ47XWDoeFW4Sq7PdQJnV2QIDZAqjNqgEjUs= +github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 h1:rc3tiVYb5z54aKaDfakKn0dDjIyPpTtszkjuMzyt7ec= +github.com/opencontainers/runc v1.1.3 h1:vIXrkId+0/J2Ymu2m7VjGvbSlAId9XNRPhn2p4b+d8w= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/redpanda-data/kgo-verifier v0.0.0-20230314144634-a2b6c6ddb0aa h1:fZv0iaZdp+h9vV5hZ6Q8+FcdtAbvIzEol7qtlEqCuy8= github.com/redpanda-data/kgo-verifier v0.0.0-20230314144634-a2b6c6ddb0aa/go.mod h1:y+uJpvjaT3ClodoBpqSTe/zHTcF1nUutyeMEQQyjTNc= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= -github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= -github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/testcontainers/testcontainers-go v0.14.0 h1:h0D5GaYG9mhOWr2qHdEKDXpkce/VlvaYOCzTRi6UBi8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -139,7 +120,6 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -147,15 +127,10 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= @@ -164,21 +139,12 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -189,24 +155,19 @@ golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roY golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 h1:DJUvgAPiJWeMBiT+RzBVcJGQN7bAEWS5UEoMshES9xs= -google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633 h1:0BOZf6qNozI3pkN3fJLwNubheHJYHhMh91GRFOWWK08= +google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -218,11 +179,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/avro.v0 v0.0.0-20171217001914-a730b5802183/go.mod h1:FvqrFXt+jCsyQibeRv4xxEJBL5iG2DDW5aeJwzDiq4A= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -232,11 +190,10 @@ gopkg.in/httprequest.v1 v1.2.1/go.mod h1:x2Otw96yda5+8+6ZeWwHIJTFkEHWP/qP8pJOzqE gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 58a71511021a53e8676138e3ad631278dd25a3f5 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Thu, 16 Apr 2026 00:52:17 +0000 Subject: [PATCH 11/14] sr/context: add serde client e2e test for context-prefixed URLs A serde client configured with schema.registry.url pointing to /contexts/.serde performs a full produce/consume round-trip. Verifies schemas are registered in the target context and isolated from the default context. Parametrized across Python, Go, and Java clients to cover all language ecosystems. (cherry picked from commit cb5aff6ec964b353a9bfdd2db0b3dc6acd975e30) --- tests/rptest/tests/schema_registry_test.py | 46 +++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/rptest/tests/schema_registry_test.py b/tests/rptest/tests/schema_registry_test.py index 0de15f8fae908..9c2d95a32d3e8 100644 --- a/tests/rptest/tests/schema_registry_test.py +++ b/tests/rptest/tests/schema_registry_test.py @@ -1442,8 +1442,9 @@ def _get_serde_client( subject_name_strategy: Optional[str] = None, payload_class: Optional[str] = None, compression_type: Optional[TopicSpec.CompressionTypes] = None, + schema_registry_url: Optional[str] = None, ): - schema_reg = self.redpanda.schema_reg().split(",", 1)[0] + schema_reg = schema_registry_url or self.redpanda.schema_reg().split(",", 1)[0] sec_cfg = self.redpanda.kafka_client_security().to_dict() return SerdeClient( @@ -7220,6 +7221,49 @@ def test_context_prefix_delete_config_and_mode(self): f"DELETE mode failed: {result.text}" ) + @cluster(num_nodes=4) + @parametrize(client_type=SerdeClientType.Python) + @parametrize(client_type=SerdeClientType.Golang) + @parametrize(client_type=SerdeClientType.Java) + def test_context_prefix_serde_client(self, client_type): + """ + Verify a serde client can target a context by setting the schema + registry URL to /contexts/{context}. This is the acceptance test + for CORE-15191. + """ + topic = f"serde-context-prefix-{client_type.name.lower()}" + ctx = ".serde" + self._create_topic(topic=topic) + + # Build context-prefixed SR URL + schema_reg_base = self.redpanda.schema_reg().split(",", 1)[0] + context_sr_url = f"{schema_reg_base}/contexts/{ctx}" + + client = self._get_serde_client( + SchemaType.AVRO, + client_type, + topic, + 5, + schema_registry_url=context_sr_url, + ) + client.start() + client.wait() + + # Verify schemas landed in the context + result = self.sr_client.get_subjects(subject_prefix=f":{ctx}:") + assert result.status_code == 200, result.text + subjects = result.json() + expected_subject = f":{ctx}:{topic}-value" + assert expected_subject in subjects, ( + f"Expected {expected_subject} in {subjects}" + ) + + # Verify default context does NOT have this subject + result = self.sr_client.get_subjects_subject_versions(subject=f"{topic}-value") + assert result.status_code == 404, ( + f"Expected 404 for default context, got {result.status_code}" + ) + class SchemaRegistryBasicAuthTest(SchemaRegistryEndpoints): """ From 7fac0c681b74614fc96c0dcdfcd620aa04fe3d02 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Tue, 28 Apr 2026 22:15:43 +0000 Subject: [PATCH 12/14] sr/context: extract parse_normalized_context helper Several handlers parse the {context} path parameter and feed it to normalize_context. Pull both steps into one helper used at the route boundary, and have the scope_subject_* helpers take an already-normalized context (std::string_view) so a single normalize call validates the context before any URL rewriting. (cherry picked from commit 955d7bec2bc5fc0b31b1bb0a87c298dd830b064e) --- src/v/pandaproxy/schema_registry/BUILD | 1 + .../schema_registry/authorization.cc | 3 +- .../schema_registry/context_router.h | 42 +++++++++++-------- src/v/pandaproxy/schema_registry/handlers.cc | 3 +- src/v/pandaproxy/schema_registry/service.cc | 19 ++++----- .../schema_registry/test/context_router.cc | 31 ++++---------- 6 files changed, 45 insertions(+), 54 deletions(-) diff --git a/src/v/pandaproxy/schema_registry/BUILD b/src/v/pandaproxy/schema_registry/BUILD index ac44445fd7765..1a5879fcfa08e 100644 --- a/src/v/pandaproxy/schema_registry/BUILD +++ b/src/v/pandaproxy/schema_registry/BUILD @@ -163,6 +163,7 @@ redpanda_cc_library( "//src/v/kafka/client:configuration", "//src/v/kafka/client:exceptions", "//src/v/pandaproxy:json", + "//src/v/pandaproxy:parsing", "@seastar", ], ) diff --git a/src/v/pandaproxy/schema_registry/authorization.cc b/src/v/pandaproxy/schema_registry/authorization.cc index baeacb64774d3..9c9e8d31b92b6 100644 --- a/src/v/pandaproxy/schema_registry/authorization.cc +++ b/src/v/pandaproxy/schema_registry/authorization.cc @@ -62,8 +62,7 @@ extract_resource_from_request(const server::request_t& rq, const auth& auth) { parse::request_param(*rq.req, "subject")); }, [&rq](const auth::context_prefix_subject&) -> auth::resource { - auto ctx = normalize_context( - parse::request_param(*rq.req, "context")); + auto ctx = parse_normalized_context(*rq.req); auto sub = parse::request_param(*rq.req, "subject"); if (!starts_with_context(sub)) { sub = fmt::format(":{}:{}", ctx, sub); diff --git a/src/v/pandaproxy/schema_registry/context_router.h b/src/v/pandaproxy/schema_registry/context_router.h index 1111d3a12d457..af981898e0548 100644 --- a/src/v/pandaproxy/schema_registry/context_router.h +++ b/src/v/pandaproxy/schema_registry/context_router.h @@ -10,6 +10,7 @@ #pragma once #include "base/seastarx.h" +#include "pandaproxy/parsing/httpd.h" #include "pandaproxy/schema_registry/errors.h" #include @@ -41,6 +42,11 @@ inline ss::sstring normalize_context(std::string_view ctx) { return ss::sstring(ctx); } +/// \brief Parse the "context" path parameter and normalize it. +inline ss::sstring parse_normalized_context(const ss::http::request& req) { + return normalize_context(parse::request_param(req, "context")); +} + /// \brief Check if a string already has a context prefix. inline bool starts_with_context(std::string_view s) { return s.starts_with(":.") || s.starts_with(":*:"); @@ -48,51 +54,51 @@ inline bool starts_with_context(std::string_view s) { /// \brief Scope the "subject" path parameter by prepending the context. /// -/// ctx is expected to be in the form ".name" (with leading dot). The -/// resulting subject is ":.ctx:subject". -inline void -scope_subject_param(ss::http::request& req, const ss::sstring& ctx) { +/// ctx must already be normalized (in the form ".name"). The resulting +/// subject is ":.ctx:subject". +inline void scope_subject_param(ss::http::request& req, std::string_view ctx) { auto sub = req.get_path_param("subject"); if (!starts_with_context(sub)) { - auto nctx = normalize_context(ctx); req.param.set( ss::sstring("subject"), - ss::sstring(fmt::format("/:{0}:{1}", nctx, sub))); + ss::sstring(fmt::format("/:{0}:{1}", ctx, sub))); } } /// \brief Inject or prepend context into the "subject" query parameter. -inline void -scope_subject_query(ss::http::request& req, const ss::sstring& ctx) { - auto nctx = normalize_context(ctx); +/// +/// ctx must already be normalized. +inline void scope_subject_query(ss::http::request& req, std::string_view ctx) { auto existing = req.get_query_param("subject"); if (existing.empty()) { - req.set_query_param("subject", fmt::format(":{0}:", nctx)); + req.set_query_param("subject", fmt::format(":{0}:", ctx)); } else if (!starts_with_context(existing)) { - req.set_query_param("subject", fmt::format(":{0}:{1}", nctx, existing)); + req.set_query_param("subject", fmt::format(":{0}:{1}", ctx, existing)); } } /// \brief Inject or prepend context into the "subjectPrefix" query parameter. +/// +/// ctx must already be normalized. inline void -scope_subject_prefix_query(ss::http::request& req, const ss::sstring& ctx) { - auto nctx = normalize_context(ctx); +scope_subject_prefix_query(ss::http::request& req, std::string_view ctx) { auto existing = req.get_query_param("subjectPrefix"); if (existing.empty()) { - req.set_query_param("subjectPrefix", fmt::format(":{0}:", nctx)); + req.set_query_param("subjectPrefix", fmt::format(":{0}:", ctx)); } else if (!starts_with_context(existing)) { req.set_query_param( - "subjectPrefix", fmt::format(":{0}:{1}", nctx, existing)); + "subjectPrefix", fmt::format(":{0}:{1}", ctx, existing)); } } /// \brief Inject the context as a context-only qualified subject path /// parameter. +/// +/// ctx must already be normalized. inline void -inject_context_as_subject(ss::http::request& req, const ss::sstring& ctx) { - auto nctx = normalize_context(ctx); +inject_context_as_subject(ss::http::request& req, std::string_view ctx) { req.param.set( - ss::sstring("subject"), ss::sstring(fmt::format("/:{0}:", nctx))); + ss::sstring("subject"), ss::sstring(fmt::format("/:{0}:", ctx))); } } // namespace pandaproxy::schema_registry diff --git a/src/v/pandaproxy/schema_registry/handlers.cc b/src/v/pandaproxy/schema_registry/handlers.cc index cccfb800b22c1..b753480a24c7e 100644 --- a/src/v/pandaproxy/schema_registry/handlers.cc +++ b/src/v/pandaproxy/schema_registry/handlers.cc @@ -1514,8 +1514,7 @@ ss::future delete_context(server::request_t rq, server::reply_t rp) { parse_accept_header(rq, rp); - auto ctx_str = parse::request_param(*rq.req, "context"); - auto ctx = context{normalize_context(ctx_str)}; + auto ctx = context{parse_normalized_context(*rq.req)}; if (ctx == default_context) { throw as_exception( diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index e54a5f96fd046..793f9c023a67f 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -368,13 +368,14 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { security::default_cluster_name, delete_security_acls)); - // Context-prefixed route helpers: extract the {context} path parameter, - // apply the given URL-rewriting function, then delegate to the handler. + // Context-prefixed route helpers: extract and normalize the {context} + // path parameter, apply the given URL-rewriting function, then delegate + // to the handler. auto ctx_route = [](auto scope_fn, auto handler) { return [=]( server::request_t rq, server::reply_t rp) -> ss::future { - auto ctx = parse::request_param(*rq.req, "context"); + auto ctx = parse_normalized_context(*rq.req); scope_fn(*rq.req, ctx); return handler(std::move(rq), std::move(rp)); }; @@ -386,7 +387,7 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { server::reply_t rp, std::optional auth_result) -> ss::future { - auto ctx = parse::request_param(*rq.req, "context"); + auto ctx = parse_normalized_context(*rq.req); scope_fn(*rq.req, ctx); return handler( std::move(rq), std::move(rp), std::move(auth_result)); @@ -577,13 +578,11 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { auth::level::publik, acl_operation::read, auth::none{}, - // Schema types are global — the handler ignores the context. Validate - // it anyway for consistency with other /contexts/{context}/... routes. + // Schema types are global — the handler ignores the context. + // ctx_route validates the {context} param for consistency with the + // other /contexts/{context}/... routes. ctx_route( - [](ss::http::request&, const ss::sstring& ctx) { - normalize_context(ctx); - }, - get_schemas_types))); + [](ss::http::request&, std::string_view) {}, get_schemas_types))); routes.routes.emplace_back(wrap( ss::httpd::schema_registry_json::ctx_delete_config, diff --git a/src/v/pandaproxy/schema_registry/test/context_router.cc b/src/v/pandaproxy/schema_registry/test/context_router.cc index d64c36d922b2d..14890bcc8a0d6 100644 --- a/src/v/pandaproxy/schema_registry/test/context_router.cc +++ b/src/v/pandaproxy/schema_registry/test/context_router.cc @@ -86,13 +86,6 @@ TEST(ContextRouterTest, ScopeSubjectParamWildcard) { EXPECT_EQ(req.get_path_param("subject"), ":*:my-topic"); } -TEST(ContextRouterTest, ScopeSubjectParamNoDot) { - ss::http::request req; - req.param.set("subject", "/my-topic"); - scope_subject_param(req, "staging"); - EXPECT_EQ(req.get_path_param("subject"), ":.staging:my-topic"); -} - TEST(ContextRouterTest, ScopeSubjectQueryAbsent) { ss::http::request req; scope_subject_query(req, ".staging"); @@ -113,12 +106,6 @@ TEST(ContextRouterTest, ScopeSubjectQueryAlreadyQualified) { EXPECT_EQ(req.get_query_param("subject"), ":.prod:my-topic"); } -TEST(ContextRouterTest, ScopeSubjectQueryNoDot) { - ss::http::request req; - scope_subject_query(req, "staging"); - EXPECT_EQ(req.get_query_param("subject"), ":.staging:"); -} - TEST(ContextRouterTest, ScopeSubjectPrefixQueryAbsent) { ss::http::request req; scope_subject_prefix_query(req, ".staging"); @@ -139,22 +126,22 @@ TEST(ContextRouterTest, ScopeSubjectPrefixQueryAlreadyQualified) { EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.prod:"); } -TEST(ContextRouterTest, ScopeSubjectPrefixQueryNoDot) { - ss::http::request req; - scope_subject_prefix_query(req, "staging"); - EXPECT_EQ(req.get_query_param("subjectPrefix"), ":.staging:"); -} - TEST(ContextRouterTest, InjectContextAsSubject) { ss::http::request req; inject_context_as_subject(req, ".staging"); EXPECT_EQ(req.get_path_param("subject"), ":.staging:"); } -TEST(ContextRouterTest, InjectContextAsSubjectNoDot) { +TEST(ContextRouterTest, ParseNormalizedContext) { ss::http::request req; - inject_context_as_subject(req, "staging"); - EXPECT_EQ(req.get_path_param("subject"), ":.staging:"); + req.param.set("context", "/staging"); + EXPECT_EQ(parse_normalized_context(req), ".staging"); +} + +TEST(ContextRouterTest, ParseNormalizedContextRejectsInvalid) { + ss::http::request req; + req.param.set("context", "/a:b"); + EXPECT_THROW(parse_normalized_context(req), exception); } } // namespace pandaproxy::schema_registry From 151fe0f41fec5e713b6b6802ecd7ed2652b8ff51 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Tue, 28 Apr 2026 22:17:04 +0000 Subject: [PATCH 13/14] sr/context: thread operation_name through deferred handlers The deferred-authz helpers (handle_get_schemas_ids_id_authz, handle_get_subjects_authz, handle_get_contexts_authz) and several handle_config_mode_authz call sites inside the per-request handlers hardcoded the operation nickname against the non-prefixed route's path_description (e.g. "get_schemas_ids_id"). With the new /contexts/{context}/... routes registered against the same handlers, audit log records issued via these deferred paths attributed to the wrong nickname. Pass the route's operation_name from the wrap class through ctx_deferred_route, the handler entry points, and into the authz helpers so audit events record the actual route nickname (e.g. "ctx_get_subjects" for context-prefixed requests). (cherry picked from commit e314131f65a13c4b434c9362a72e164ee45f0e8a) --- src/v/pandaproxy/schema_registry/auth.h | 9 ++-- .../schema_registry/authorization.cc | 9 ++-- .../schema_registry/authorization.h | 3 ++ src/v/pandaproxy/schema_registry/handlers.cc | 51 +++++++++++-------- src/v/pandaproxy/schema_registry/handlers.h | 30 +++++++---- src/v/pandaproxy/schema_registry/service.cc | 29 +++++++---- 6 files changed, 80 insertions(+), 51 deletions(-) diff --git a/src/v/pandaproxy/schema_registry/auth.h b/src/v/pandaproxy/schema_registry/auth.h index 8dfda7aba9b9e..35f9dfdc000f5 100644 --- a/src/v/pandaproxy/schema_registry/auth.h +++ b/src/v/pandaproxy/schema_registry/auth.h @@ -59,9 +59,12 @@ class auth { using regular_function_handler = ss::noncopyable_function< ss::future(server::request_t, server::reply_t)>; - using deferred_function_handler = ss::noncopyable_function( - server::request_t, server::reply_t, std::optional)>; + using deferred_function_handler + = ss::noncopyable_function( + server::request_t, + server::reply_t, + std::optional, + std::string_view operation_name)>; using function_handler = std::variant; diff --git a/src/v/pandaproxy/schema_registry/authorization.cc b/src/v/pandaproxy/schema_registry/authorization.cc index 9c9e8d31b92b6..3cad5f3f54145 100644 --- a/src/v/pandaproxy/schema_registry/authorization.cc +++ b/src/v/pandaproxy/schema_registry/authorization.cc @@ -164,10 +164,9 @@ void handle_authz( void handle_get_schemas_ids_id_authz( const server::request_t& rq, + std::string_view operation_name, std::optional& auth_result, const chunked_vector& subjects) { - const auto& operation_name - = ss::httpd::schema_registry_json::get_schemas_ids_id.operations.nickname; constexpr auto op = security::acl_operation::read; if (!auth_result.has_value()) { // ACLs or authentication is disabled @@ -229,10 +228,9 @@ void handle_get_schemas_ids_id_authz( void handle_get_subjects_authz( const server::request_t& rq, + std::string_view operation_name, std::optional& auth_result, chunked_vector& subjects) { - const auto& operation_name - = ss::httpd::schema_registry_json::get_subjects.operations.nickname; constexpr auto op = security::acl_operation::describe; if (!auth_result.has_value()) { @@ -293,11 +291,10 @@ void handle_get_subjects_authz( ss::future<> handle_get_contexts_authz( const server::request_t& rq, + std::string_view operation_name, sharded_store& store, std::optional& auth_result, chunked_vector& contexts) { - const auto& operation_name - = ss::httpd::schema_registry_json::get_contexts.operations.nickname; constexpr auto op = security::acl_operation::describe; if (!auth_result.has_value()) { diff --git a/src/v/pandaproxy/schema_registry/authorization.h b/src/v/pandaproxy/schema_registry/authorization.h index f60554fa15280..0407a7ec77f2b 100644 --- a/src/v/pandaproxy/schema_registry/authorization.h +++ b/src/v/pandaproxy/schema_registry/authorization.h @@ -33,11 +33,13 @@ void handle_authz( void handle_get_schemas_ids_id_authz( const server::request_t& rq, + std::string_view operation_name, std::optional& auth_result, const chunked_vector& subjects); void handle_get_subjects_authz( const server::request_t& rq, + std::string_view operation_name, std::optional& auth_result, chunked_vector& subjects); @@ -47,6 +49,7 @@ void handle_get_subjects_authz( /// - Empty contexts: user needs sr_registry describe access ss::future<> handle_get_contexts_authz( const server::request_t& rq, + std::string_view operation_name, sharded_store& store, std::optional& auth_result, chunked_vector& contexts); diff --git a/src/v/pandaproxy/schema_registry/handlers.cc b/src/v/pandaproxy/schema_registry/handlers.cc index b753480a24c7e..76c01dce96863 100644 --- a/src/v/pandaproxy/schema_registry/handlers.cc +++ b/src/v/pandaproxy/schema_registry/handlers.cc @@ -350,7 +350,8 @@ put_config(server::request_t rq, server::reply_t rp) { ss::future get_config_subject( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto ctx_sub = context_subject::from_string( parse::request_param(*rq.req, "subject")); @@ -360,7 +361,7 @@ ss::future get_config_subject( enterprise::handle_config_mode_authz( rq, - ss::httpd::schema_registry_json::get_config_subject.operations.nickname, + operation_name, auth_result, ctx_sub, security::acl_operation::describe_configs); @@ -417,7 +418,8 @@ std::invoke_result_t get_or_load(server::request_t& rq, F f) { ss::future put_config_subject( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_content_type_header(rq); parse_accept_header(rq, rp); auto ctx_sub = context_subject::from_string( @@ -426,7 +428,7 @@ ss::future put_config_subject( enterprise::handle_config_mode_authz( rq, - ss::httpd::schema_registry_json::put_config_subject.operations.nickname, + operation_name, auth_result, ctx_sub, security::acl_operation::alter_configs); @@ -446,15 +448,15 @@ ss::future put_config_subject( ss::future delete_config_subject( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto ctx_sub = context_subject::from_string( parse::request_param(*rq.req, "subject")); enterprise::handle_config_mode_authz( rq, - ss::httpd::schema_registry_json::delete_config_subject.operations - .nickname, + operation_name, auth_result, ctx_sub, security::acl_operation::alter_configs); @@ -532,7 +534,8 @@ ss::future put_mode(server::request_t rq, server::reply_t rp) { ss::future get_mode_subject( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto ctx_sub = context_subject::from_string( parse::request_param(*rq.req, "subject")); @@ -542,7 +545,7 @@ ss::future get_mode_subject( enterprise::handle_config_mode_authz( rq, - ss::httpd::schema_registry_json::get_mode_subject.operations.nickname, + operation_name, auth_result, ctx_sub, security::acl_operation::describe_configs); @@ -567,7 +570,8 @@ ss::future get_mode_subject( ss::future put_mode_subject( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_content_type_header(rq); parse_accept_header(rq, rp); auto frc = parse::query_param>(*rq.req, "force") @@ -578,7 +582,7 @@ ss::future put_mode_subject( enterprise::handle_config_mode_authz( rq, - ss::httpd::schema_registry_json::put_mode_subject.operations.nickname, + operation_name, auth_result, ctx_sub, security::acl_operation::alter_configs); @@ -598,14 +602,15 @@ ss::future put_mode_subject( ss::future delete_mode_subject( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto ctx_sub = context_subject::from_string( parse::request_param(*rq.req, "subject")); enterprise::handle_config_mode_authz( rq, - ss::httpd::schema_registry_json::delete_mode_subject.operations.nickname, + operation_name, auth_result, ctx_sub, security::acl_operation::alter_configs); @@ -655,7 +660,8 @@ get_schemas_types(server::request_t rq, server::reply_t rp) { ss::future get_schemas_ids_id( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto id = parse::request_param(*rq.req, "id"); const auto format = parse_output_format(*rq.req); @@ -674,7 +680,7 @@ ss::future get_schemas_ids_id( // Subject-based deferred authz (handles 403 vs 404) enterprise::handle_get_schemas_ids_id_authz( - rq, auth_result, result.matched_subjects); + rq, operation_name, auth_result, result.matched_subjects); if (!result.found()) { throw as_exception(not_found(id)); @@ -692,7 +698,8 @@ ss::future get_schemas_ids_id( ss::future get_schemas_ids_id_schema( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto id = parse::request_param(*rq.req, "id"); const auto format = parse_output_format(*rq.req); @@ -711,7 +718,7 @@ ss::future get_schemas_ids_id_schema( // Subject-based deferred authz (handles 403 vs 404) enterprise::handle_get_schemas_ids_id_authz( - rq, auth_result, result.matched_subjects); + rq, operation_name, auth_result, result.matched_subjects); if (!result.found()) { throw as_exception(not_found(id)); @@ -804,7 +811,8 @@ ss::future::reply_t> get_schemas_ids_id_subjects( ss::future get_subjects( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); auto inc_del{ parse::query_param>(*rq.req, "deleted") @@ -820,7 +828,7 @@ ss::future get_subjects( // Handle AuthZ - Filters res for the subjects the user is allowed to // see - enterprise::handle_get_subjects_authz(rq, auth_result, res); + enterprise::handle_get_subjects_authz(rq, operation_name, auth_result, res); // Convert context_subject to qualified string format for JSON response auto subjects_str = std::move(res) | std::views::as_rvalue @@ -1487,7 +1495,8 @@ delete_security_acls(server::request_t rq, server::reply_t rp) { ss::future get_contexts( server::request_t rq, server::reply_t rp, - std::optional auth_result) { + std::optional auth_result, + std::string_view operation_name) { parse_accept_header(rq, rp); co_await rq.service().writer().read_sync(); @@ -1496,7 +1505,7 @@ ss::future get_contexts( = co_await rq.service().schema_store().get_materialized_contexts(); co_await enterprise::handle_get_contexts_authz( - rq, rq.service().schema_store(), auth_result, contexts); + rq, operation_name, rq.service().schema_store(), auth_result, contexts); auto contexts_str = std::move(contexts) | std::views::as_rvalue | std::ranges::views::transform([](context&& ctx) { diff --git a/src/v/pandaproxy/schema_registry/handlers.h b/src/v/pandaproxy/schema_registry/handlers.h index 3abee3f133d4f..d36819c344958 100644 --- a/src/v/pandaproxy/schema_registry/handlers.h +++ b/src/v/pandaproxy/schema_registry/handlers.h @@ -29,17 +29,20 @@ put_config(ctx_server::request_t rq, ctx_server::reply_t rp); ss::future::reply_t> get_config_subject( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> put_config_subject( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> delete_config_subject( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> get_mode(ctx_server::request_t rq, ctx_server::reply_t rp); @@ -50,17 +53,20 @@ put_mode(ctx_server::request_t rq, ctx_server::reply_t rp); ss::future::reply_t> get_mode_subject( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> put_mode_subject( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> delete_mode_subject( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> get_schemas_types( ctx_server::request_t rq, ctx_server::reply_t rp); @@ -68,12 +74,14 @@ ss::future::reply_t> get_schemas_types( ss::future::reply_t> get_schemas_ids_id( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> get_schemas_ids_id_schema( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> get_schemas_ids_id_versions( ctx_server::request_t rq, ctx_server::reply_t rp); @@ -84,7 +92,8 @@ ss::future::reply_t> get_schemas_ids_id_subjects( ss::future::reply_t> get_subjects( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> get_subject_versions( ctx_server::request_t rq, ctx_server::reply_t rp); @@ -129,7 +138,8 @@ ss::future::reply_t> delete_security_acls( ss::future::reply_t> get_contexts( ctx_server::request_t rq, ctx_server::reply_t rp, - std::optional auth_result); + std::optional auth_result, + std::string_view operation_name); ss::future::reply_t> delete_context( ctx_server::request_t rq, ctx_server::reply_t rp); diff --git a/src/v/pandaproxy/schema_registry/service.cc b/src/v/pandaproxy/schema_registry/service.cc index 793f9c023a67f..bcacf2e1ccac0 100644 --- a/src/v/pandaproxy/schema_registry/service.cc +++ b/src/v/pandaproxy/schema_registry/service.cc @@ -98,7 +98,10 @@ class wrap { }, [&](const auth::deferred_function_handler& h) { return h( - std::move(rq), std::move(rp), std::move(auth_result)); + std::move(rq), + std::move(rp), + std::move(auth_result), + _operation_name); }); } catch (const kafka::client::partition_error& ex) { if ( @@ -382,16 +385,20 @@ server::routes_t get_schema_registry_routes(ss::gate& gate, one_shot& es) { }; auto ctx_deferred_route = [](auto scope_fn, auto handler) { - return [=]( - server::request_t rq, - server::reply_t rp, - std::optional auth_result) - -> ss::future { - auto ctx = parse_normalized_context(*rq.req); - scope_fn(*rq.req, ctx); - return handler( - std::move(rq), std::move(rp), std::move(auth_result)); - }; + return + [=]( + server::request_t rq, + server::reply_t rp, + std::optional auth_result, + std::string_view operation_name) -> ss::future { + auto ctx = parse_normalized_context(*rq.req); + scope_fn(*rq.req, ctx); + return handler( + std::move(rq), + std::move(rp), + std::move(auth_result), + operation_name); + }; }; routes.routes.emplace_back(wrap( From 3316af86784b7faa954a346b3395c96f86e0980c Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Tue, 28 Apr 2026 22:19:22 +0000 Subject: [PATCH 14/14] tests/audit_log: verify ctx_ in context-prefixed audit logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an integration test that exercises four representative /contexts/{context}/... routes — one regular handler (POST /subjects/{subject}/versions) and three deferred handlers (GET /subjects, GET /schemas/ids/{id}, GET /config/{subject}) — and verifies that each audit record uses the context-prefixed nickname (ctx_post_subject_versions, ctx_get_subjects, ctx_get_schemas_ids_id, ctx_get_config_subject) and an authz resource scoped to the context-qualified subject. (cherry picked from commit bc778c4455bc81a0de41f92672956e7b883e39fd) --- tests/rptest/tests/audit_log_test.py | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/rptest/tests/audit_log_test.py b/tests/rptest/tests/audit_log_test.py index 4517140677c7a..cee31ab5703f8 100644 --- a/tests/rptest/tests/audit_log_test.py +++ b/tests/rptest/tests/audit_log_test.py @@ -3749,6 +3749,97 @@ def test_sr_audit_public(self, endpoint_name, audit_transport_mode): self.check_matching_api_record(endpoint, StatusID.SUCCESS) + @skip_fips_mode + @cluster(num_nodes=5) + @matrix(audit_transport_mode=get_audit_modes()) + def test_sr_audit_context_prefix_authz(self, audit_transport_mode): + """ + Verify ACLs and audit logging for context-prefixed routes + (/contexts/{context}/...). Each route should: + - Authorize against the context-qualified subject derived from the + URL prefix and {subject} path/query parameter. + - Record the context-prefixed nickname (ctx_*) in audit logs, even + for deferred-authz handlers where the nickname used to be hardcoded + to the non-prefixed variant. + """ + self.setup_cluster() + + ctx = ".staging" + subject = "topic-a" + qualified_subject = f":{ctx}:{subject}" + self.sr_client.base_path = f"contexts/{ctx}" + schema_data = json.dumps({"schema": schema1_def}) + + # POST /contexts/{ctx}/subjects/{subject}/versions, regular handler. + # Without an ACL on the qualified subject, this is denied. + result = self.sr_client.post_subjects_subject_versions( + subject=subject, data=schema_data, auth=self.user_auth + ) + self.assert_equal(result.status_code, 403) + self.check_matching_api_record_parts( + path=f"contexts/{ctx}/subjects/{subject}/versions", + resources={"name": qualified_subject, "type": "subject"}, + operation="ctx_post_subject_versions", + status_id=StatusID.FAILURE, + ) + + # Grant prefix-WRITE on the .staging context, retry. Should succeed + # and audit the qualified subject under ctx_post_subject_versions. + self._post_acl(self._create_acl(f":{ctx}:", "SUBJECT", "PREFIXED", "WRITE")) + result = self.sr_client.post_subjects_subject_versions( + subject=subject, data=schema_data, auth=self.user_auth + ) + self.assert_equal(result.status_code, 200) + schema_id = result.json()["id"] + self.check_matching_api_record_parts( + path=f"contexts/{ctx}/subjects/{subject}/versions", + resources={"name": qualified_subject, "type": "subject"}, + operation="ctx_post_subject_versions", + status_id=StatusID.SUCCESS, + ) + + # GET /contexts/{ctx}/subjects, deferred handler. Audit log must + # record ctx_get_subjects (not get_subjects). + self._post_acl(self._create_acl(f":{ctx}:", "SUBJECT", "PREFIXED", "DESCRIBE")) + result = self.sr_client.get_subjects(auth=self.user_auth) + self.assert_equal(result.status_code, 200) + self.check_matching_api_record_parts( + path=f"contexts/{ctx}/subjects", + resources={"name": qualified_subject, "type": "subject"}, + operation="ctx_get_subjects", + status_id=StatusID.SUCCESS, + ) + + # GET /contexts/{ctx}/schemas/ids/{id}, deferred handler that authz's + # against the matched subjects. Audit log must record + # ctx_get_schemas_ids_id. + self._post_acl(self._create_acl(f":{ctx}:", "SUBJECT", "PREFIXED", "READ")) + result = self.sr_client.get_schemas_ids_id(id=schema_id, auth=self.user_auth) + self.assert_equal(result.status_code, 200) + self.check_matching_api_record_parts( + path=f"contexts/{ctx}/schemas/ids/{schema_id}", + resources={"name": qualified_subject, "type": "subject"}, + operation="ctx_get_schemas_ids_id", + status_id=StatusID.SUCCESS, + ) + + # GET /contexts/{ctx}/config/{subject}, deferred handler routed + # through handle_config_mode_authz. Audit log must record + # ctx_get_config_subject. + self._post_acl( + self._create_acl(f":{ctx}:", "SUBJECT", "PREFIXED", "DESCRIBE_CONFIGS") + ) + result = self.sr_client.get_config_subject( + subject=subject, fallback=True, auth=self.user_auth + ) + self.assert_equal(result.status_code, 200) + self.check_matching_api_record_parts( + path=f"contexts/{ctx}/config/{subject}?defaultToGlobal=true", + resources={"name": qualified_subject, "type": "subject"}, + operation="ctx_get_config_subject", + status_id=StatusID.SUCCESS, + ) + class AuditLogTestSanctionMode(AuditLogTestBase): """Validates the behaviour of audit logging under sanctioning mode"""