diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile
index 1781ead94f..51df76c162 100644
--- a/.github/workflows/build/Dockerfile
+++ b/.github/workflows/build/Dockerfile
@@ -43,6 +43,9 @@ ADD .github/workflows/build/conf/topologies/health.xml /knox-runtime/conf/topolo
ADD .github/workflows/build/conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml
ADD .github/workflows/build/conf/topologies/remoteauth.xml /knox-runtime/conf/topologies/remoteauth.xml
ADD .github/workflows/build/conf/topologies/k8sauth.xml /knox-runtime/conf/topologies/k8sauth.xml
+ADD .github/workflows/build/conf/topologies/sparkconnect.xml /knox-runtime/conf/topologies/sparkconnect.xml
+ADD .github/workflows/build/conf/topologies/sparkconnect-restricted.xml /knox-runtime/conf/topologies/sparkconnect-restricted.xml
+ADD .github/workflows/build/conf/topologies/sparkconnect-fgac.xml /knox-runtime/conf/topologies/sparkconnect-fgac.xml
RUN chown -R gateway /knox-runtime/
diff --git a/.github/workflows/build/conf/topologies/sparkconnect-fgac.xml b/.github/workflows/build/conf/topologies/sparkconnect-fgac.xml
new file mode 100644
index 0000000000..b81d284065
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/sparkconnect-fgac.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+ federation
+ JWTProvider
+ true
+
+ knox.token.use.cookie
+ false
+
+
+
+ authorization
+ AclsAuthz
+ true
+
+ SPARKCONNECT.acl
+ *;*;*
+
+
+ SPARKCONNECT.methods.deny
+ AddArtifacts
+
+
+
+
+ SPARKCONNECT
+ grpc://sparkconnect-mock:15002
+
+
diff --git a/.github/workflows/build/conf/topologies/sparkconnect-restricted.xml b/.github/workflows/build/conf/topologies/sparkconnect-restricted.xml
new file mode 100644
index 0000000000..779527d22a
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/sparkconnect-restricted.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+ federation
+ JWTProvider
+ true
+
+ knox.token.use.cookie
+ false
+
+
+
+ authorization
+ AclsAuthz
+ true
+
+ SPARKCONNECT.acl
+ nobody;*;*
+
+
+
+
+ SPARKCONNECT
+ grpc://sparkconnect-mock:15002
+
+
diff --git a/.github/workflows/build/conf/topologies/sparkconnect.xml b/.github/workflows/build/conf/topologies/sparkconnect.xml
new file mode 100644
index 0000000000..c8d5710930
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/sparkconnect.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ federation
+ JWTProvider
+ true
+
+ knox.token.use.cookie
+ false
+
+
+
+
+ SPARKCONNECT
+ grpc://sparkconnect-mock:15002
+
+
diff --git a/.github/workflows/build/gateway-site.xml b/.github/workflows/build/gateway-site.xml
index 36e884a3ef..301920e434 100644
--- a/.github/workflows/build/gateway-site.xml
+++ b/.github/workflows/build/gateway-site.xml
@@ -212,4 +212,32 @@ limitations under the License.
member
+
+
+ gateway.grpc.enabled
+ true
+
+
+ gateway.grpc.service.role
+ SPARKCONNECT
+
+
+ gateway.grpc.proto.services
+ spark.connect.SparkConnectService
+
+
+
+ gateway.grpc.identity.rules
+ 2.1=principal,2.2=principal
+
+
+
diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml
index 727bca2633..067fef20ef 100644
--- a/.github/workflows/compose/docker-compose.yml
+++ b/.github/workflows/compose/docker-compose.yml
@@ -85,6 +85,47 @@ services:
depends_on:
- k3s
+ # One-shot: generates Python protobuf/gRPC stubs from the same vendored
+ # spark/connect/*.proto files the gateway compiles against, into a volume
+ # shared by the mock backend and the tests. Generating rather than depending on
+ # pyspark keeps the images small and means a proto refresh that broke the wire
+ # contract would break these tests too.
+ sparkconnect-protos:
+ image: python:3.10-slim
+ entrypoint:
+ - /bin/sh
+ - -c
+ command:
+ - |
+ set -e
+ pip install --no-cache-dir --quiet grpcio-tools==1.60.0
+ python -m grpc_tools.protoc -I/protos \
+ --python_out=/out --grpc_python_out=/out \
+ /protos/spark/connect/*.proto
+ # Generated modules import each other as spark.connect.*, so the output
+ # has to be an importable package.
+ touch /out/spark/__init__.py /out/spark/connect/__init__.py
+ echo 'spark connect stubs generated'
+ volumes:
+ - ../../../gateway-service-grpc/src/test/proto:/protos:ro
+ - sparkconnect-protos:/out
+
+ # Stands in for a Spark Connect server on a private network. Plaintext, which
+ # is what Knox's grpc:// backend scheme describes.
+ sparkconnect-mock:
+ image: python:3.10-slim
+ environment:
+ - PYTHONPATH=/stubs
+ volumes:
+ - ./sparkconnect:/mock:ro
+ - sparkconnect-protos:/stubs:ro
+ command: >
+ sh -c "pip install --no-cache-dir --quiet grpcio==1.60.0 protobuf==4.25.8
+ && python /mock/mock_server.py"
+ depends_on:
+ sparkconnect-protos:
+ condition: service_completed_successfully
+
knox:
image: apache/knox-dev:${IMAGE_TAG:-master}
command: /gateway.sh
@@ -101,14 +142,21 @@ services:
condition: service_started
k8s-bootstrap:
condition: service_completed_successfully
+ sparkconnect-mock:
+ condition: service_started
tests:
image: python:3.10-slim
working_dir: /tests
volumes:
- ../tests:/tests
+ - sparkconnect-protos:/stubs:ro
environment:
- KNOX_GATEWAY_URL=https://knox:8443/
+ - KNOX_SPARKCONNECT_HOST=knox
+ - KNOX_SPARKCONNECT_PORT=15002
+ # Generated Spark Connect stubs, shared with the mock backend.
+ - PYTHONPATH=/stubs
command: >
bash -c "pip install -r requirements.txt
&& pylint *.py
@@ -120,3 +168,4 @@ services:
volumes:
k3s-output:
+ sparkconnect-protos:
diff --git a/.github/workflows/compose/sparkconnect/mock_server.py b/.github/workflows/compose/sparkconnect/mock_server.py
new file mode 100644
index 0000000000..20b0a01e86
--- /dev/null
+++ b/.github/workflows/compose/sparkconnect/mock_server.py
@@ -0,0 +1,113 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A stand-in Spark Connect server for the Knox integration tests.
+
+Running real Spark would add a gigabyte of image and a minute of startup to
+test a gateway, and the gateway does not care what is behind it -- only that it
+speaks `spark.connect.SparkConnectService`. So this implements just enough of
+that service to make the gateway's behavior observable.
+
+The important trick is that the RPCs echo back what the *backend* received,
+rather than returning canned data. Knox overwrites `user_context.user_id` with
+the authenticated principal on its way through, and that rewrite is invisible
+from the client side -- the client only knows what it sent. By reflecting the
+observed identity into the response, an assertion about what Spark would have
+seen becomes an ordinary assertion in the test.
+
+The stubs are generated at container start from the same vendored
+`spark/connect/*.proto` files the gateway compiles against, so a proto refresh
+that broke the wire contract would break this too.
+"""
+
+import logging
+import os
+from concurrent import futures
+
+import grpc
+
+from spark.connect import base_pb2
+from spark.connect import base_pb2_grpc
+
+LOG = logging.getLogger("mock-spark-connect")
+
+# Enough responses to prove a server stream is relayed message by message rather
+# than collapsed or truncated.
+EXECUTE_PLAN_RESPONSE_COUNT = 5
+
+
+def _observed_user(request):
+ """The user_id the backend actually received, i.e. after Knox's rewrite."""
+ return request.user_context.user_id
+
+
+class MockSparkConnectService(base_pb2_grpc.SparkConnectServiceServicer):
+ """Implements the handful of RPCs the integration tests exercise."""
+
+ def AnalyzePlan(self, request, context): # noqa: N802 - gRPC naming
+ observed = _observed_user(request)
+ LOG.info("AnalyzePlan session=%s user_id=%s", request.session_id, observed)
+ # explain_string is a free-form string field, so it can carry the observed
+ # identity back to the test without inventing a side channel.
+ return base_pb2.AnalyzePlanResponse(
+ session_id=request.session_id,
+ explain=base_pb2.AnalyzePlanResponse.Explain(explain_string=observed),
+ )
+
+ def ExecutePlan(self, request, context): # noqa: N802 - gRPC naming
+ observed = _observed_user(request)
+ LOG.info("ExecutePlan session=%s user_id=%s", request.session_id, observed)
+ for index in range(EXECUTE_PLAN_RESPONSE_COUNT):
+ yield base_pb2.ExecutePlanResponse(
+ session_id=request.session_id,
+ operation_id=observed,
+ response_id=f"response-{index}",
+ )
+
+ def Config(self, request, context): # noqa: N802 - gRPC naming
+ observed = _observed_user(request)
+ LOG.info("Config session=%s user_id=%s", request.session_id, observed)
+ return base_pb2.ConfigResponse(
+ session_id=request.session_id,
+ # Echo the observed identity as a config value so the Config path can
+ # be asserted the same way as AnalyzePlan.
+ pairs=[base_pb2.KeyValue(key="knox.observed.user", value=observed)],
+ )
+
+ def AddArtifacts(self, request_iterator, context): # noqa: N802 - gRPC naming
+ observed = ""
+ count = 0
+ for request in request_iterator:
+ observed = _observed_user(request)
+ count += 1
+ LOG.info("AddArtifacts messages=%d user_id=%s", count, observed)
+ return base_pb2.AddArtifactsResponse()
+
+
+def serve():
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+ port = os.environ.get("MOCK_PORT", "15002")
+ server = grpc.server(futures.ThreadPoolExecutor(max_workers=8))
+ base_pb2_grpc.add_SparkConnectServiceServicer_to_server(MockSparkConnectService(), server)
+ # Plaintext: this stands in for a Spark Connect server on a private network,
+ # which is exactly the posture Knox's grpc:// backend scheme describes.
+ server.add_insecure_port(f"[::]:{port}")
+ server.start()
+ LOG.info("Mock Spark Connect server listening on %s", port)
+ server.wait_for_termination()
+
+
+if __name__ == "__main__":
+ serve()
diff --git a/.github/workflows/tests/requirements.txt b/.github/workflows/tests/requirements.txt
index 736823a6a2..feee7beece 100644
--- a/.github/workflows/tests/requirements.txt
+++ b/.github/workflows/tests/requirements.txt
@@ -1,4 +1,6 @@
requests==2.33.0
pytest==9.0.3
pylint==4.0.5
-ldap3==2.9.1
\ No newline at end of file
+ldap3==2.9.1
+grpcio==1.60.0
+protobuf==4.25.8
diff --git a/.github/workflows/tests/test_spark_connect.py b/.github/workflows/tests/test_spark_connect.py
new file mode 100644
index 0000000000..5f4b9ef578
--- /dev/null
+++ b/.github/workflows/tests/test_spark_connect.py
@@ -0,0 +1,276 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Integration tests for the Spark Connect (gRPC) listener.
+
+These drive a real gRPC client against a running gateway, which is the only way
+to cover the parts unit tests have to mock: the listener being discovered and
+started, TLS from the gateway identity, real token validation, topology
+deployment, and the interceptor chain in its real order.
+
+The backend is a stand-in Spark Connect server that echoes back the
+`user_context.user_id` it received. That is what makes identity assertion
+observable -- the client cannot otherwise see what Knox rewrote on the way
+through.
+"""
+
+# Protobuf message classes are created dynamically from the descriptor pool when
+# the generated modules are imported, so static analysis cannot see them.
+# pylint: disable=no-member
+
+import os
+import ssl
+import unittest
+
+import grpc
+import requests
+import urllib3
+
+from spark.connect import base_pb2
+from spark.connect import base_pb2_grpc
+
+# The dev environment uses self-signed certificates throughout.
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+TOPOLOGY_METADATA_KEY = "knox-topology"
+OPEN_TOPOLOGY = "sparkconnect"
+RESTRICTED_TOPOLOGY = "sparkconnect-restricted"
+# Any user may reach it, but AddArtifacts is denied by method name.
+FGAC_TOPOLOGY = "sparkconnect-fgac"
+# Present in the demo LDAP the compose environment starts.
+KNOX_USER = "guest"
+KNOX_PASSWORD = "guest-password"
+
+
+def _gateway_url():
+ return os.environ.get("KNOX_GATEWAY_URL", "https://localhost:8443/")
+
+
+def _sparkconnect_endpoint():
+ host = os.environ.get("KNOX_SPARKCONNECT_HOST", "localhost")
+ port = os.environ.get("KNOX_SPARKCONNECT_PORT", "15002")
+ return host, int(port)
+
+
+def _acquire_token():
+ """Gets a Knox JWT the way a user would, over HTTPS before any gRPC call.
+
+ The knoxldap topology fronts KNOXTOKEN with a basic-auth Shiro realm over the
+ demo LDAP, which is the closest thing this environment has to the
+ authenticate-once-then-carry-a-token flow the gRPC listener expects.
+ """
+ url = f"{_gateway_url()}gateway/knoxldap/knoxtoken/api/v1/token"
+ response = requests.get(url, auth=(KNOX_USER, KNOX_PASSWORD), verify=False, timeout=30)
+ response.raise_for_status()
+ return response.json()["access_token"]
+
+
+class SparkConnectTestBase(unittest.TestCase):
+ """Shared channel plumbing for the Spark Connect listener tests."""
+
+ token = None
+ server_certificate = None
+
+ @classmethod
+ def setUpClass(cls):
+ host, port = _sparkconnect_endpoint()
+ # The listener presents the gateway identity, which is self-signed here.
+ # Trust exactly that certificate rather than disabling verification, so
+ # the test still proves TLS is actually working.
+ cls.server_certificate = ssl.get_server_certificate((host, port)).encode("utf-8")
+ cls.token = _acquire_token()
+
+ def _channel(self, token=None, topology=None):
+ host, port = _sparkconnect_endpoint()
+ credentials = grpc.ssl_channel_credentials(root_certificates=self.server_certificate)
+ # The gateway certificate is issued for its own hostname, which need not
+ # match the compose service name.
+ options = (("grpc.ssl_target_name_override", "localhost"),)
+ channel = grpc.secure_channel(f"{host}:{port}", credentials, options)
+ metadata = []
+ if token is not None:
+ metadata.append(("authorization", f"Bearer {token}"))
+ if topology is not None:
+ metadata.append((TOPOLOGY_METADATA_KEY, topology))
+ return channel, tuple(metadata)
+
+ def _analyze(self, token=None, topology=None, session_id="itest-session", claimed_user="root"):
+ """Sends AnalyzePlan and returns the response, or raises RpcError."""
+ channel, metadata = self._channel(token=token, topology=topology)
+ with channel:
+ stub = base_pb2_grpc.SparkConnectServiceStub(channel)
+ request = base_pb2.AnalyzePlanRequest(session_id=session_id)
+ # Claim to be someone else; Knox must overwrite this.
+ request.user_context.user_id = claimed_user
+ return stub.AnalyzePlan(request, metadata=metadata, timeout=30)
+
+ def assert_rpc_code(self, expected, callable_obj):
+ """Asserts a call fails with a specific gRPC status code."""
+ with self.assertRaises(grpc.RpcError) as raised:
+ callable_obj()
+ self.assertEqual(expected, raised.exception.code(),
+ f"expected {expected}, got {raised.exception.code()}: "
+ f"{raised.exception.details()}")
+
+
+class TestSparkConnectAuthentication(SparkConnectTestBase):
+ """Nothing reaches the backend without a valid Knox token."""
+
+ def test_call_without_a_token_is_rejected(self):
+ """An unauthenticated call must never reach the backend."""
+ self.assert_rpc_code(grpc.StatusCode.UNAUTHENTICATED,
+ lambda: self._analyze(token=None, topology=OPEN_TOPOLOGY))
+
+ def test_call_with_a_malformed_token_is_rejected(self):
+ """Something that is not a JWT at all is refused cleanly."""
+ self.assert_rpc_code(grpc.StatusCode.UNAUTHENTICATED,
+ lambda: self._analyze(token="not-a-jwt", topology=OPEN_TOPOLOGY))
+
+ def test_call_with_a_well_formed_but_unsigned_token_is_rejected(self):
+ """A forged JWT is refused as UNAUTHENTICATED, not UNKNOWN."""
+ # Structurally a JWT, but not one this gateway issued.
+ forged = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ ".eyJzdWIiOiJndWVzdCIsImlzcyI6IktOT1hTU08ifQ"
+ ".c2lnbmF0dXJl")
+ self.assert_rpc_code(grpc.StatusCode.UNAUTHENTICATED,
+ lambda: self._analyze(token=forged, topology=OPEN_TOPOLOGY))
+
+
+class TestSparkConnectRouting(SparkConnectTestBase):
+ """Topology selection comes from client metadata and must resolve."""
+
+ def test_call_without_a_topology_is_rejected(self):
+ """With no default topology configured there is nowhere to route."""
+ # No default topology is configured, so there is nowhere to route.
+ self.assert_rpc_code(grpc.StatusCode.UNIMPLEMENTED,
+ lambda: self._analyze(token=self.token, topology=None))
+
+ def test_call_to_an_unknown_topology_is_rejected(self):
+ """A topology that declares no SPARKCONNECT service is unroutable."""
+ self.assert_rpc_code(grpc.StatusCode.UNAVAILABLE,
+ lambda: self._analyze(token=self.token, topology="no-such-topology"))
+
+
+class TestSparkConnectAuthorization(SparkConnectTestBase):
+ """Naming a topology is not the same as being allowed to use it."""
+
+ def test_topology_acl_denies_an_authenticated_user(self):
+ """A valid token does not by itself grant access to a topology."""
+ # Same valid token, same backend -- refused by that topology's ACL.
+ self.assert_rpc_code(grpc.StatusCode.PERMISSION_DENIED,
+ lambda: self._analyze(token=self.token, topology=RESTRICTED_TOPOLOGY))
+
+ def test_permitted_topology_is_reachable_with_the_same_token(self):
+ """The same token reaches a topology whose ACLs permit the user."""
+ response = self._analyze(token=self.token, topology=OPEN_TOPOLOGY)
+ self.assertEqual("itest-session", response.session_id)
+
+
+class TestSparkConnectIdentityAssertion(SparkConnectTestBase):
+ """The client's claimed identity is replaced with the authenticated one."""
+
+ def test_backend_sees_the_authenticated_principal_not_the_claim(self):
+ """Knox overwrites the client-supplied user_id before the backend sees it."""
+ response = self._analyze(token=self.token, topology=OPEN_TOPOLOGY, claimed_user="root")
+ # The mock echoes back the user_id it received.
+ self.assertEqual(KNOX_USER, response.explain.explain_string)
+
+ def test_claim_is_overwritten_even_when_left_empty(self):
+ """An absent claim is filled in rather than passed through empty."""
+ response = self._analyze(token=self.token, topology=OPEN_TOPOLOGY, claimed_user="")
+ self.assertEqual(KNOX_USER, response.explain.explain_string)
+
+ def test_identity_is_asserted_on_the_config_rpc_too(self):
+ """Identity assertion applies to every RPC, not just AnalyzePlan."""
+ channel, metadata = self._channel(token=self.token, topology=OPEN_TOPOLOGY)
+ with channel:
+ stub = base_pb2_grpc.SparkConnectServiceStub(channel)
+ request = base_pb2.ConfigRequest(session_id="itest-config")
+ request.user_context.user_id = "root"
+ request.operation.get_all.SetInParent()
+ response = stub.Config(request, metadata=metadata, timeout=30)
+ self.assertEqual(KNOX_USER, response.pairs[0].value)
+
+
+class TestSparkConnectStreaming(SparkConnectTestBase):
+ """Server streaming is relayed message by message."""
+
+ def test_execute_plan_relays_every_response(self):
+ """A server stream arrives complete and in order."""
+ channel, metadata = self._channel(token=self.token, topology=OPEN_TOPOLOGY)
+ with channel:
+ stub = base_pb2_grpc.SparkConnectServiceStub(channel)
+ request = base_pb2.ExecutePlanRequest(session_id="itest-stream")
+ request.user_context.user_id = "root"
+ responses = list(stub.ExecutePlan(request, metadata=metadata, timeout=30))
+ self.assertEqual(5, len(responses))
+ self.assertEqual("response-0", responses[0].response_id)
+ self.assertEqual("response-4", responses[-1].response_id)
+ # The backend reflects the asserted identity into operation_id.
+ self.assertEqual(KNOX_USER, responses[0].operation_id)
+
+
+class TestSparkConnectMethodGating(SparkConnectTestBase):
+ """Whole RPCs can be refused by name, which needs no message parsing."""
+
+ def _upload(self, topology):
+ """Attempts an AddArtifacts call against the given topology."""
+ channel, metadata = self._channel(token=self.token, topology=topology)
+ with channel:
+ stub = base_pb2_grpc.SparkConnectServiceStub(channel)
+ request = base_pb2.AddArtifactsRequest(session_id="itest-artifacts")
+ request.user_context.user_id = "root"
+ return stub.AddArtifacts(iter([request]), metadata=metadata, timeout=30)
+
+ def test_add_artifacts_is_denied_in_a_topology_that_denies_it(self):
+ """A topology relying on plan-level policy can refuse code upload."""
+ # SPARKCONNECT.methods.deny in sparkconnect-fgac.xml; the gateway reads
+ # only the method name from the request path to decide this.
+ self.assert_rpc_code(grpc.StatusCode.PERMISSION_DENIED,
+ lambda: self._upload(FGAC_TOPOLOGY))
+
+ def test_add_artifacts_is_permitted_where_it_is_not_denied(self):
+ """The same user and RPC succeed in a topology with no such rule."""
+ self._upload(OPEN_TOPOLOGY)
+
+ def test_other_rpcs_still_work_in_the_denying_topology(self):
+ """Denying one method must not disturb the rest of the service."""
+ response = self._analyze(token=self.token, topology=FGAC_TOPOLOGY)
+ self.assertEqual(KNOX_USER, response.explain.explain_string)
+
+ def test_config_rpc_still_carries_the_asserted_identity(self):
+ """Config is relayed like any other RPC, with the identity replaced.
+
+ Knox no longer screens session configuration keys: the gateway reads only
+ the identity fields, by field number, and makes no assumption about the
+ Config RPC's internal shape. Protecting a reserved key is the job of a
+ component inside the backend, which can recompute the identity per
+ request rather than trusting a key a client could also write.
+ """
+ channel, metadata = self._channel(token=self.token, topology=OPEN_TOPOLOGY)
+ with channel:
+ stub = base_pb2_grpc.SparkConnectServiceStub(channel)
+ request = base_pb2.ConfigRequest(session_id="itest-config-identity")
+ request.user_context.user_id = "root"
+ pair = request.operation.set.pairs.add()
+ pair.key = "spark.sql.shuffle.partitions"
+ pair.value = "8"
+ response = stub.Config(request, metadata=metadata, timeout=30)
+ # The mock echoes back the user_id it received.
+ self.assertEqual(KNOX_USER, response.pairs[0].value)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/LICENSE b/LICENSE
index 1a7827712d..829e828115 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1379,3 +1379,40 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+------------------------------------------------------------------------------
+Protocol Buffers License (BSD 3-clause)
+------------------------------------------------------------------------------
+
+Copyright 2008 Google Inc. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Code generated by the Protocol Buffer compiler is owned by the owner
+of the input file used when generating it. This code is not
+standalone and requires a support library to be linked with it. This
+support library is itself covered by the above license.
diff --git a/build-tools/src/main/resources/build-tools/checkstyle/suppressions.xml b/build-tools/src/main/resources/build-tools/checkstyle/suppressions.xml
index 43f7677259..f01a5e1fa1 100644
--- a/build-tools/src/main/resources/build-tools/checkstyle/suppressions.xml
+++ b/build-tools/src/main/resources/build-tools/checkstyle/suppressions.xml
@@ -24,4 +24,7 @@ limitations under the License.
+
+
+
\ No newline at end of file
diff --git a/build-tools/src/main/resources/build-tools/spotbugs-filter.xml b/build-tools/src/main/resources/build-tools/spotbugs-filter.xml
index fb4d7857d4..bb137395cd 100644
--- a/build-tools/src/main/resources/build-tools/spotbugs-filter.xml
+++ b/build-tools/src/main/resources/build-tools/spotbugs-filter.xml
@@ -85,4 +85,10 @@ limitations under the License.
+
+
+
+
+
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidator.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTValidator.java
similarity index 98%
rename from gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidator.java
rename to gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTValidator.java
index 34fd050664..6990cb5172 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidator.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTValidator.java
@@ -15,11 +15,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.knox.gateway.websockets;
+package org.apache.knox.gateway.provider.federation.jwt;
import com.nimbusds.jose.JWSHeader;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
-import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
import org.apache.knox.gateway.provider.federation.jwt.filter.SignatureVerificationCache;
import org.apache.knox.gateway.services.security.token.JWTokenAuthority;
import org.apache.knox.gateway.services.security.token.TokenMetadata;
diff --git a/gateway-release/pom.xml b/gateway-release/pom.xml
index 7df1c6c224..c978146d7a 100644
--- a/gateway-release/pom.xml
+++ b/gateway-release/pom.xml
@@ -524,5 +524,9 @@
org.apache.knoxgateway-service-restcatalog
+
+ org.apache.knox
+ gateway-service-grpc
+
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
index d4639b0114..d9ef95b40c 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
@@ -57,6 +57,26 @@ public interface GatewayMessages {
@Message( level = MessageLevel.INFO, text = "Failed to stopped gateway." )
void failedToStopGateway(@StackTrace( level = MessageLevel.INFO ) Exception e);
+ @Message( level = MessageLevel.INFO, text = "Started the {0} protocol listener on port {1}." )
+ void startedProtocolListener( String name, String port );
+
+ @Message( level = MessageLevel.FATAL, text = "Failed to start the {0} protocol listener: {1}" )
+ void failedToStartProtocolListener( String name, @StackTrace( level = MessageLevel.FATAL ) Exception e );
+
+ @Message( level = MessageLevel.WARN, text = "Failed to stop the {0} protocol listener: {1}" )
+ void failedToStopProtocolListener( String name, @StackTrace( level = MessageLevel.WARN ) Exception e );
+
+ @Message( level = MessageLevel.WARN, text = "Failed to reload the {0} protocol listener after a topology change: {1}" )
+ void failedToReloadProtocolListener( String name, @StackTrace( level = MessageLevel.WARN ) Exception e );
+
+ @Message( level = MessageLevel.WARN,
+ text = "The {0} protocol listener is enabled in the refreshed configuration but cannot be started without a gateway restart." )
+ void protocolListenerCannotBeStarted( String name );
+
+ @Message( level = MessageLevel.WARN,
+ text = "The {0} protocol listener is disabled in the refreshed configuration but cannot be stopped without a gateway restart." )
+ void protocolListenerCannotBeStopped( String name );
+
@Message( level = MessageLevel.INFO, text = "Loading configuration resource {0}" )
void loadingConfigurationResource( String res );
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayServer.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayServer.java
index c5db37cc9c..ac8be7aac8 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayServer.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayServer.java
@@ -38,6 +38,7 @@
import org.apache.knox.gateway.filter.PortMappingHelperHandler;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.i18n.resources.ResourcesFactory;
+import org.apache.knox.gateway.protocol.ProtocolListener;
import org.apache.knox.gateway.services.GatewayServices;
import org.apache.knox.gateway.services.ServiceType;
import org.apache.knox.gateway.services.registry.ServiceDefinitionRegistry;
@@ -134,6 +135,7 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.RELOADABLE_CONFIG_FILENAME;
@@ -166,6 +168,19 @@ public class GatewayServer {
private AtomicBoolean stopped = new AtomicBoolean(false);
private GatewayStatusService gatewayStatusService;
+ /**
+ * Listeners for protocols the servlet pipeline cannot carry, each on its own
+ * port. Discovered with ServiceLoader so the server keeps no compile-time
+ * dependency on their transport libraries.
+ */
+ private final List protocolListeners = new ArrayList<>();
+
+ /**
+ * Listeners present on the classpath but switched off at startup. Held only so
+ * that switching one on later can be reported as needing a restart.
+ */
+ private final List inactiveProtocolListeners = new ArrayList<>();
+
private final Set inactiveTopologies = new HashSet<>();
public static void main( String[] args ) {
@@ -278,7 +293,15 @@ private static synchronized void refreshGatewayConfig(GatewayConfigImpl config,
config.reloadConfiguration();
log.refreshedGatewayConfig();
for (GatewayConfigChangeListener listener : configChangeListeners) {
- listener.onGatewayConfigChanged(config);
+ try {
+ listener.onGatewayConfigChanged(config);
+ } catch (Exception e) {
+ // This runs on a scheduleAtFixedRate task, where an escaping
+ // exception cancels every future execution. One listener choking on
+ // a bad value must not silently stop configuration refresh for the
+ // whole gateway.
+ log.unableToReloadGatewayConfig(e);
+ }
}
}
}
@@ -765,6 +788,10 @@ private synchronized void start() throws Exception {
cleanupTopologyDeployments();
+ // Started after Jetty and after topologies are deployed, so a listener can
+ // resolve backends from the service registry as it comes up.
+ startProtocolListeners();
+
// Start the topology monitor.
monitor.startMonitor();
@@ -799,6 +826,115 @@ void createJetty() throws IOException, CertificateException, NoSuchAlgorithmExce
}
}
+ /**
+ * Starts every enabled protocol listener on the classpath.
+ *
+ * A listener that fails to start fails the gateway, the same as a Jetty
+ * connector would: a deployment that asked for a listener and silently did not
+ * get one is worse than one that refuses to come up.
+ */
+ private void startProtocolListeners() throws Exception {
+ for (ProtocolListener listener : ServiceLoader.load(ProtocolListener.class)) {
+ if (!listener.isEnabled(config)) {
+ // Kept so that switching it on later can be reported rather than ignored.
+ inactiveProtocolListeners.add(listener);
+ continue;
+ }
+ try {
+ listener.start(config, services);
+ protocolListeners.add(listener);
+ // A listener that tracks gateway configuration opts in by implementing
+ // GatewayConfigChangeListener; registering after a successful start keeps
+ // a failed listener from receiving refreshes.
+ if (listener instanceof GatewayConfigChangeListener) {
+ registerConfigChangeListener((GatewayConfigChangeListener) listener);
+ }
+ // A listener may bind several ports; report all of them, since a
+ // deployment that configured N endpoints wants to see N came up.
+ log.startedProtocolListener(listener.getName(), listener.getPorts().stream()
+ .map(GatewayServer::convertPortToString)
+ .collect(Collectors.joining(", ")));
+ } catch (Exception e) {
+ log.failedToStartProtocolListener(listener.getName(), e);
+ throw e;
+ }
+ }
+ if (!protocolListeners.isEmpty() || !inactiveProtocolListeners.isEmpty()) {
+ registerConfigChangeListener(enablementWatcher);
+ }
+ }
+
+ /**
+ * Reports an attempt to switch a protocol listener on or off in a running
+ * gateway.
+ *
+ * Whether a listener runs is decided once, at startup: an enabled one binds its
+ * socket, a disabled one is never started. Neither can change without a
+ * restart. Enablement is the gateway's decision rather than the listener's, so
+ * it is watched here instead of in each listener — and reported rather than
+ * silently ignored, because an operator who edits the property and sees nothing
+ * in the log has no way to tell the setting was not applied.
+ */
+ private final GatewayConfigChangeListener enablementWatcher =
+ refreshed -> warnAboutEnablementChanges(refreshed, protocolListeners, inactiveProtocolListeners);
+
+ /**
+ * Logs a warning for each listener whose enablement changed, in either
+ * direction.
+ *
+ * @param refreshed the reloaded configuration
+ * @param running listeners started at gateway startup
+ * @param inactive listeners on the classpath that were switched off at startup
+ * @return the names of the listeners reported, for testing
+ */
+ static List warnAboutEnablementChanges(GatewayConfig refreshed,
+ List running,
+ List inactive) {
+ final List reported = new ArrayList<>();
+ for (ProtocolListener listener : running) {
+ if (!listener.isEnabled(refreshed)) {
+ log.protocolListenerCannotBeStopped(listener.getName());
+ reported.add(listener.getName());
+ }
+ }
+ for (ProtocolListener listener : inactive) {
+ if (listener.isEnabled(refreshed)) {
+ log.protocolListenerCannotBeStarted(listener.getName());
+ reported.add(listener.getName());
+ }
+ }
+ return reported;
+ }
+
+ private void notifyProtocolListeners() {
+ for (ProtocolListener listener : protocolListeners) {
+ try {
+ listener.reload();
+ } catch (Exception e) {
+ // A listener that cannot refresh must not block the redeployment of the
+ // topologies the rest of the gateway serves.
+ log.failedToReloadProtocolListener(listener.getName(), e);
+ }
+ }
+ }
+
+ private void stopProtocolListeners() {
+ unregisterConfigChangeListener(enablementWatcher);
+ for (ProtocolListener listener : protocolListeners) {
+ try {
+ if (listener instanceof GatewayConfigChangeListener) {
+ unregisterConfigChangeListener((GatewayConfigChangeListener) listener);
+ }
+ listener.stop();
+ } catch (Exception e) {
+ // One listener refusing to stop must not keep the rest of the gateway up.
+ log.failedToStopProtocolListener(listener.getName(), e);
+ }
+ }
+ protocolListeners.clear();
+ inactiveProtocolListeners.clear();
+ }
+
private void handleHadoopXmlResources() {
final HadoopXmlResourceParser hadoopXmlResourceParser = new HadoopXmlResourceParser(config);
final HadoopXmlResourceMonitor hadoopXmlResourceMonitor = new HadoopXmlResourceMonitor(config, hadoopXmlResourceParser);
@@ -811,6 +947,9 @@ public synchronized void stop() throws Exception {
log.stoppingGateway();
services.stop();
monitor.stopMonitor();
+ // Drain before Jetty stops: long-lived streams get a bounded window to
+ // finish rather than being cut the moment shutdown begins.
+ stopProtocolListeners();
jetty.stop();
jetty.join();
log.stoppedGateway();
@@ -1121,6 +1260,10 @@ public void handleTopologyEvent( List events ) {
handleCreateDeployment(topology, deployDir);
}
}
+ // Protocol listeners bypass the webapp redeployment that refreshes the
+ // servlet filter chains, so anything they cached from a topology has to
+ // be invalidated explicitly or an edited topology never takes effect.
+ notifyProtocolListeners();
}
}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
index 061518537d..5e98a5818d 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
@@ -164,6 +164,24 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig {
public static final String WEBSOCKET_IDLE_TIMEOUT = GATEWAY_CONFIG_FILE_PREFIX + ".websocket.idle.timeout";
public static final String WEBSOCKET_MAX_WAIT_BUFFER_COUNT = GATEWAY_CONFIG_FILE_PREFIX + ".websocket.max.wait.buffer.count";
+ /* @since 3.0.0 gRPC listener config variables */
+ public static final String GRPC_FEATURE_ENABLED = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.enabled";
+ public static final String GRPC_PORT = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.port";
+ public static final String GRPC_SERVICE_ROLE = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.service.role";
+ public static final String GRPC_IDENTITY_RULES = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.identity.rules";
+ public static final String GRPC_IDENTITY_SCAN_LIMIT = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.identity.scan.limit";
+ public static final String GRPC_DEFAULT_TOPOLOGY = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.default.topology";
+ public static final String GRPC_TOPOLOGY_METADATA_KEY = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.topology.metadata.key";
+ public static final String GRPC_METHODS_DENY = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.methods.deny";
+ public static final String GRPC_METHODS_ALLOW = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.methods.allow";
+ public static final String GRPC_MAX_MESSAGE_SIZE = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.max.message.size";
+ public static final String GRPC_PERMIT_KEEPALIVE_TIME = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.permit.keepalive.time";
+ public static final String GRPC_PERMIT_KEEPALIVE_WITHOUT_CALLS = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.permit.keepalive.without.calls";
+ public static final String GRPC_MAX_CONCURRENT_CALLS_PER_CONNECTION = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.max.concurrent.calls.per.connection";
+ public static final String GRPC_CHANNEL_IDLE_TIMEOUT = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.channel.idle.timeout";
+ public static final String GRPC_DRAIN_TIMEOUT = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.drain.timeout";
+ public static final String GRPC_BACKEND_TOKEN_ALIAS = GATEWAY_CONFIG_FILE_PREFIX + ".grpc.backend.token.alias";
+
/* @since 2.0.0 WebShell config variables */
public static final String WEBSHELL_FEATURE_ENABLED = GATEWAY_CONFIG_FILE_PREFIX + ".webshell.feature.enabled";
@@ -227,6 +245,21 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig {
public static final int DEFAULT_WEBSOCKET_IDLE_TIMEOUT = 300000;
public static final int DEFAULT_WEBSOCKET_MAX_WAIT_BUFFER_COUNT = 100;
+ /* gRPC listener defaults. The port and identity layout come from Spark
+ Connect, the protocol this was first built for. */
+ public static final boolean DEFAULT_GRPC_FEATURE_ENABLED = false;
+ public static final int DEFAULT_GRPC_PORT = 15002;
+ public static final String DEFAULT_GRPC_SERVICE_ROLE = "GRPC";
+ /** 128 KiB; see IdentityRewritePolicy for why the rewrite is bounded at all. */
+ public static final int DEFAULT_GRPC_IDENTITY_SCAN_LIMIT = 131072;
+ public static final int DEFAULT_GRPC_MAX_MESSAGE_SIZE = 134217728;
+ public static final long DEFAULT_GRPC_PERMIT_KEEPALIVE_TIME = 10000L;
+ public static final boolean DEFAULT_GRPC_PERMIT_KEEPALIVE_WITHOUT_CALLS = true;
+ public static final int DEFAULT_GRPC_MAX_CONCURRENT_CALLS_PER_CONNECTION = 1000;
+ public static final long DEFAULT_GRPC_CHANNEL_IDLE_TIMEOUT = 1800000L;
+ public static final long DEFAULT_GRPC_DRAIN_TIMEOUT = 30000L;
+ public static final String DEFAULT_GRPC_TOPOLOGY_METADATA_KEY = "knox-topology";
+
public static final boolean DEFAULT_WEBSHELL_FEATURE_ENABLED = false;
public static final boolean DEFAULT_WEBSHELL_AUDIT_LOGGING_ENABLED = false;
public static final int DEFAULT_WEBSHELL_MAX_CONCURRENT_SESSIONS = 3;
@@ -1114,6 +1147,121 @@ public int getWebsocketMaxWaitBufferCount() {
return getInt( WEBSOCKET_MAX_WAIT_BUFFER_COUNT, DEFAULT_WEBSOCKET_MAX_WAIT_BUFFER_COUNT);
}
+ @Override
+ public boolean isGrpcEnabled() {
+ return getBoolean(GRPC_FEATURE_ENABLED, DEFAULT_GRPC_FEATURE_ENABLED);
+ }
+
+ @Override
+ public int getGrpcPort() {
+ return getInt(GRPC_PORT, DEFAULT_GRPC_PORT);
+ }
+
+ @Override
+ public String getGrpcServiceRole() {
+ return get(GRPC_SERVICE_ROLE, DEFAULT_GRPC_SERVICE_ROLE);
+ }
+
+ @Override
+ public List getGrpcListenerNames() {
+ final String configured = get(GRPC_LISTENER_NAMES);
+ if (configured == null || configured.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+ final List names = new ArrayList<>();
+ for (String name : configured.trim().split("\\s*,\\s*")) {
+ if (!name.isEmpty()) {
+ names.add(name);
+ }
+ }
+ return names;
+ }
+
+ @Override
+ public Map getGrpcListenerConfig(String listenerName) {
+ final Map listenerConfig = new HashMap<>();
+ final String prefix = GATEWAY_CONFIG_FILE_PREFIX + ".grpc." + listenerName + ".";
+ for (String key : getPropertyNames()) {
+ if (key != null && key.startsWith(prefix)) {
+ final String value = get(key);
+ if (value != null) {
+ listenerConfig.put(key.substring(prefix.length()), value);
+ }
+ }
+ }
+ return listenerConfig;
+ }
+
+ @Override
+ public String getGrpcProtoServices() {
+ return get(GRPC_PROTO_SERVICES);
+ }
+
+ @Override
+ public String getGrpcIdentityRules() {
+ return get(GRPC_IDENTITY_RULES);
+ }
+
+ @Override
+ public int getGrpcIdentityScanLimit() {
+ return getInt(GRPC_IDENTITY_SCAN_LIMIT, DEFAULT_GRPC_IDENTITY_SCAN_LIMIT);
+ }
+
+ @Override
+ public String getGrpcDefaultTopology() {
+ return get(GRPC_DEFAULT_TOPOLOGY);
+ }
+
+ @Override
+ public String getGrpcTopologyMetadataKey() {
+ return get(GRPC_TOPOLOGY_METADATA_KEY, DEFAULT_GRPC_TOPOLOGY_METADATA_KEY);
+ }
+
+ @Override
+ public String getGrpcMethodsDeny() {
+ return get(GRPC_METHODS_DENY);
+ }
+
+ @Override
+ public String getGrpcMethodsAllow() {
+ return get(GRPC_METHODS_ALLOW);
+ }
+
+ @Override
+ public int getGrpcMaxMessageSize() {
+ return getInt(GRPC_MAX_MESSAGE_SIZE, DEFAULT_GRPC_MAX_MESSAGE_SIZE);
+ }
+
+ @Override
+ public long getGrpcPermitKeepAliveTime() {
+ return getLong(GRPC_PERMIT_KEEPALIVE_TIME, DEFAULT_GRPC_PERMIT_KEEPALIVE_TIME);
+ }
+
+ @Override
+ public boolean isGrpcPermitKeepAliveWithoutCalls() {
+ return getBoolean(GRPC_PERMIT_KEEPALIVE_WITHOUT_CALLS, DEFAULT_GRPC_PERMIT_KEEPALIVE_WITHOUT_CALLS);
+ }
+
+ @Override
+ public int getGrpcMaxConcurrentCallsPerConnection() {
+ return getInt(GRPC_MAX_CONCURRENT_CALLS_PER_CONNECTION, DEFAULT_GRPC_MAX_CONCURRENT_CALLS_PER_CONNECTION);
+ }
+
+ @Override
+ public long getGrpcChannelIdleTimeout() {
+ return getLong(GRPC_CHANNEL_IDLE_TIMEOUT, DEFAULT_GRPC_CHANNEL_IDLE_TIMEOUT);
+ }
+
+ @Override
+ public long getGrpcDrainTimeout() {
+ return getLong(GRPC_DRAIN_TIMEOUT, DEFAULT_GRPC_DRAIN_TIMEOUT);
+ }
+
+ @Override
+ public String getGrpcBackendTokenAlias() {
+ return get(GRPC_BACKEND_TOKEN_ALIAS);
+ }
+
@Override
public Map getGatewayPortMappings() {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ApplicationDeploymentContributor.java b/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ApplicationDeploymentContributor.java
index 1e4d1f6a5a..034c563728 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ApplicationDeploymentContributor.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ApplicationDeploymentContributor.java
@@ -162,6 +162,10 @@ private void contributeRewriteRules(DeploymentContext context) {
private void contributeResources(DeploymentContext context, Service service) {
Map filterParams = new HashMap<>();
List bindings = serviceDefinition.getRoutes();
+ if ( bindings == null ) {
+ // JAXB leaves the list null when a definition declares no .
+ return;
+ }
for ( Route binding : bindings ) {
List filters = binding.getRewrites();
if ( filters != null && !filters.isEmpty() ) {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributor.java b/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributor.java
index 1a9a7eb36e..b2c366a60a 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributor.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributor.java
@@ -113,6 +113,15 @@ private void contributeRewriteRules(DeploymentContext context) {
private void contributeResources(DeploymentContext context, Service service) {
Map filterParams = new HashMap<>();
List bindings = serviceDefinition.getRoutes();
+ if ( bindings == null ) {
+ // A service definition need not declare routes. Services carried by a
+ // non-servlet listener — Spark Connect over gRPC, for instance — have no
+ // path for the servlet pipeline to match, and exist as definitions only so
+ // the role is known to the registry and to tooling. JAXB leaves the list
+ // null when is absent, and iterating it would fail the whole
+ // topology deployment, not merely this service.
+ return;
+ }
for ( Route binding : bindings ) {
List filters = binding.getRewrites();
if ( filters != null && !filters.isEmpty() ) {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/registry/impl/DefaultServiceDefinitionRegistry.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/registry/impl/DefaultServiceDefinitionRegistry.java
index 44bfd4b209..62b573d743 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/registry/impl/DefaultServiceDefinitionRegistry.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/registry/impl/DefaultServiceDefinitionRegistry.java
@@ -98,6 +98,13 @@ private void populateServiceDefinitions() {
for (ServiceDefinition serviceDefinition : getServices()) {
List routes = serviceDefinition.getRoutes();
+ if (routes == null) {
+ // A service carried by a non-servlet listener has no path for the
+ // servlet pipeline to match and so declares no routes, contributing no
+ // URL templates here. This registry walks every definition on the
+ // classpath at startup, so failing on one would stop the whole gateway.
+ continue;
+ }
for (Route route : routes) {
try {
Template template = Parser.parseTemplate(route.getPath());
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/webshell/WebshellWebSocketAdapter.java b/gateway-server/src/main/java/org/apache/knox/gateway/webshell/WebshellWebSocketAdapter.java
index 44fb8d1a6b..6e6404b025 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/webshell/WebshellWebSocketAdapter.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/webshell/WebshellWebSocketAdapter.java
@@ -33,7 +33,7 @@
import org.apache.knox.gateway.audit.log4j.audit.AuditConstants;
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.services.security.token.UnknownTokenException;
-import org.apache.knox.gateway.websockets.JWTValidator;
+import org.apache.knox.gateway.provider.federation.jwt.JWTValidator;
import org.apache.knox.gateway.websockets.ProxyWebSocketAdapter;
import org.eclipse.jetty.websocket.api.Session;
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java
index f275ee9eeb..0295178557 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java
@@ -20,6 +20,7 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.provider.federation.jwt.JWTValidator;
import org.apache.knox.gateway.services.GatewayServices;
import org.apache.knox.gateway.services.ServiceType;
import org.apache.knox.gateway.services.registry.ServiceDefEntry;
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidatorFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidatorFactory.java
index 9cb93bd9a2..65cf9752ee 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidatorFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/JWTValidatorFactory.java
@@ -20,6 +20,7 @@
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
+import org.apache.knox.gateway.provider.federation.jwt.JWTValidator;
import org.apache.knox.gateway.provider.federation.jwt.filter.SignatureVerificationCache;
import org.apache.knox.gateway.services.GatewayServices;
import org.apache.knox.gateway.services.ServiceType;
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/ProtocolListenerEnablementTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/ProtocolListenerEnablementTest.java
new file mode 100644
index 0000000000..9e08a31a04
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/ProtocolListenerEnablementTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.protocol.ProtocolListener;
+import org.apache.knox.gateway.services.GatewayServices;
+
+import org.junit.Test;
+
+/**
+ * Whether a protocol listener runs is decided once, at startup, and cannot change
+ * without a restart. Toggling the property in a running gateway therefore does
+ * nothing — so it has to say so, or an operator has no way to tell the edit was
+ * not applied.
+ */
+public class ProtocolListenerEnablementTest {
+
+ @Test
+ public void reportsAListenerSwitchedOffWhileRunning() {
+ final StubListener running = new StubListener("SparkConnect", false);
+
+ final List reported = GatewayServer.warnAboutEnablementChanges(
+ config(), Collections.singletonList(running), Collections.emptyList());
+
+ assertEquals(Collections.singletonList("SparkConnect"), reported);
+ }
+
+ @Test
+ public void reportsAListenerSwitchedOnWhileStopped() {
+ // The likelier mistake: an operator sets enabled=true, expects a listener,
+ // and gets silence. Nothing else in the gateway would mention it.
+ final StubListener inactive = new StubListener("SparkConnect", true);
+
+ final List reported = GatewayServer.warnAboutEnablementChanges(
+ config(), Collections.emptyList(), Collections.singletonList(inactive));
+
+ assertEquals(Collections.singletonList("SparkConnect"), reported);
+ }
+
+ @Test
+ public void staysQuietWhenEnablementIsUnchanged() {
+ final StubListener running = new StubListener("SparkConnect", true);
+ final StubListener inactive = new StubListener("Other", false);
+
+ final List reported = GatewayServer.warnAboutEnablementChanges(
+ config(), Collections.singletonList(running), Collections.singletonList(inactive));
+
+ assertTrue("no warning is due when nothing changed", reported.isEmpty());
+ }
+
+ @Test
+ public void reportsEachChangedListenerSeparately() {
+ final List running =
+ Arrays.asList(new StubListener("A", false), new StubListener("B", true));
+ final List inactive =
+ Arrays.asList(new StubListener("C", true), new StubListener("D", false));
+
+ final List reported =
+ GatewayServer.warnAboutEnablementChanges(config(), running, inactive);
+
+ // A was switched off, C was switched on; B and D are unchanged.
+ assertEquals(Arrays.asList("A", "C"), reported);
+ }
+
+ private static GatewayConfig config() {
+ return new GatewayTestConfig();
+ }
+
+ /** A listener that reports a fixed enablement, standing in for the config read. */
+ private static final class StubListener implements ProtocolListener {
+
+ private final String name;
+ private final boolean enabled;
+
+ StubListener(String name, boolean enabled) {
+ this.name = name;
+ this.enabled = enabled;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public boolean isEnabled(GatewayConfig config) {
+ return enabled;
+ }
+
+ @Override
+ public void start(GatewayConfig config, GatewayServices services) {
+ }
+
+ @Override
+ public void stop() {
+ }
+
+ @Override
+ public int getPort() {
+ return -1;
+ }
+ }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributorTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributorTest.java
index c8e7c60d24..355fdf895e 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributorTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/deploy/impl/ServiceDefinitionDeploymentContributorTest.java
@@ -366,6 +366,48 @@ public void testServiceAttributeParameters() throws Exception {
assertEquals("test2def", fparamKeyVal.get("test2"));
}
+ /*
+ * A service definition need not declare routes. Services carried by a
+ * non-servlet listener — Spark Connect over gRPC — have no path for the servlet
+ * pipeline to match and exist as definitions only so the role is known to the
+ * registry. JAXB leaves the route list null when is absent, and
+ * iterating it threw, which failed the whole topology deployment rather than
+ * just that service.
+ */
+ @Test
+ public void testServiceDefinitionWithoutRoutesContributesNothing() throws Exception {
+ UrlRewriteRulesDescriptor clusterRules = EasyMock.createNiceMock(UrlRewriteRulesDescriptor.class);
+ EasyMock.replay(clusterRules);
+
+ ServiceDefinition svcDef = EasyMock.createNiceMock(ServiceDefinition.class);
+ EasyMock.expect(svcDef.getRole()).andReturn("SPARKCONNECT").anyTimes();
+ // Exactly what JAXB produces for a definition with no element.
+ EasyMock.expect(svcDef.getRoutes()).andReturn(null).anyTimes();
+ EasyMock.expect(svcDef.getDispatch()).andReturn(null).anyTimes();
+ EasyMock.replay(svcDef);
+
+ ServiceDefinitionDeploymentContributor sddc =
+ new ServiceDefinitionDeploymentContributor(svcDef, null);
+
+ DeploymentContext context = EasyMock.createNiceMock(DeploymentContext.class);
+ EasyMock.expect(context.getDescriptor("rewrite")).andReturn(clusterRules).anyTimes();
+ TestGatewayDescriptor gd = new TestGatewayDescriptor();
+ EasyMock.expect(context.getGatewayDescriptor()).andReturn(gd).anyTimes();
+ EasyMock.replay(context);
+
+ Service service = EasyMock.createNiceMock(Service.class);
+ EasyMock.expect(service.getRole()).andReturn("SPARKCONNECT").anyTimes();
+ EasyMock.replay(service);
+
+ // Must not throw; a throw here becomes a DeploymentException and takes the
+ // entire topology down, including its other services.
+ sddc.contributeService(context, service);
+
+ assertNotNull(gd.resources());
+ assertEquals("a routeless definition should contribute no resources",
+ 0, gd.resources().size());
+ }
+
private static class TestGatewayDescriptor extends GatewayDescriptorImpl {
}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/webshell/WebshellWebsocketAdapterTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/webshell/WebshellWebsocketAdapterTest.java
index 61e4446ff8..7dec1f35ba 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/webshell/WebshellWebsocketAdapterTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/webshell/WebshellWebsocketAdapterTest.java
@@ -25,7 +25,7 @@
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
-import org.apache.knox.gateway.websockets.JWTValidator;
+import org.apache.knox.gateway.provider.federation.jwt.JWTValidator;
import org.apache.knox.gateway.websockets.WebsocketLogMessages;
import org.easymock.EasyMock;
import org.easymock.EasyMockSupport;
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java
index 47331b8071..9ff38b1e5d 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java
@@ -26,6 +26,7 @@
import org.apache.knox.gateway.i18n.GatewaySpiMessages;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
+import org.apache.knox.gateway.provider.federation.jwt.JWTValidator;
import org.apache.knox.gateway.services.GatewayServices;
import org.apache.knox.gateway.webshell.WebshellWebSocketAdapter;
import org.easymock.EasyMock;
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/JWTValidatorTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/JWTValidatorTest.java
index e79bea6d7c..f539c4e7ea 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/JWTValidatorTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/JWTValidatorTest.java
@@ -26,6 +26,7 @@
import com.nimbusds.jwt.SignedJWT;
import org.apache.commons.codec.binary.Base64;
import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.provider.federation.jwt.JWTValidator;
import org.apache.knox.gateway.provider.federation.jwt.filter.SignatureVerificationCache;
import org.apache.knox.gateway.services.GatewayServices;
import org.apache.knox.gateway.services.ServiceType;
diff --git a/gateway-service-definitions/src/main/resources/services/sparkconnect/1.0.0/service.xml b/gateway-service-definitions/src/main/resources/services/sparkconnect/1.0.0/service.xml
new file mode 100644
index 0000000000..5a101dbf02
--- /dev/null
+++ b/gateway-service-definitions/src/main/resources/services/sparkconnect/1.0.0/service.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+ API
+ /sparkconnect
+ Spark Connect
+ Apache Spark Connect gRPC endpoint, proxied by the Knox Spark Connect listener on its own port.
+
+
diff --git a/gateway-service-grpc/pom.xml b/gateway-service-grpc/pom.xml
new file mode 100644
index 0000000000..778c7fdf18
--- /dev/null
+++ b/gateway-service-grpc/pom.xml
@@ -0,0 +1,197 @@
+
+
+
+ 4.0.0
+
+ org.apache.knox
+ gateway
+ 3.0.0-SNAPSHOT
+
+
+ gateway-service-grpc
+ gateway-service-grpc
+ gRPC listener for Apache Knox, proxying protobuf services without compiling against their schemas
+
+
+
+ org.apache.knox
+ gateway-spi
+ compile
+
+
+ org.apache.knox
+ gateway-i18n
+
+
+
+ org.apache.knox
+ gateway-util-common
+
+
+
+ org.apache.knox
+ gateway-provider-security-jwt
+
+
+
+ javax.servlet
+ javax.servlet-api
+ provided
+
+
+
+ org.apache.knox
+ gateway-provider-security-authz-acls
+
+
+
+ io.grpc
+ grpc-api
+
+
+ io.grpc
+ grpc-stub
+ test
+
+
+ io.grpc
+ grpc-protobuf
+ test
+
+
+
+ io.grpc
+ grpc-netty-shaded
+
+
+
+ com.google.protobuf
+ protobuf-java
+ test
+
+
+ com.google.guava
+ guava
+ test
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+ io.grpc
+ grpc-inprocess
+ test
+
+
+ io.grpc
+ grpc-testing
+ test
+
+
+ junit
+ junit
+ test
+
+
+ org.easymock
+ easymock
+ test
+
+
+ org.hamcrest
+ hamcrest
+ test
+
+
+ org.apache.knox
+ gateway-test-utils
+ test
+
+
+
+ org.apache.knox
+ gateway-spi-common
+ test
+
+
+
+
+
+
+
+ kr.motd.maven
+ os-maven-plugin
+ ${os-maven-plugin.version}
+
+
+
+
+ org.xolstice.maven.plugins
+ protobuf-maven-plugin
+ ${protobuf-maven-plugin.version}
+
+ com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}
+ grpc-java
+ io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
+
+
+
+
+
+ test-compile
+ test-compile-custom
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-pmd-plugin
+
+
+
+ ${project.build.directory}/generated-test-sources/protobuf/java
+ ${project.build.directory}/generated-test-sources/protobuf/grpc-java
+
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+
+
+
+ ${project.build.sourceDirectory}
+
+
+
+
+
+
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AclAuthorizer.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AclAuthorizer.java
new file mode 100644
index 0000000000..5a7bad6295
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AclAuthorizer.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+import org.apache.knox.gateway.filter.AclParser;
+import org.apache.knox.gateway.filter.InvalidACLException;
+
+/**
+ * Evaluates a topology's {@code AclsAuthz} ACLs for a gRPC call.
+ *
+ * Knox's authorization responsibility on this path is deliberately one question:
+ * may this user use this service in this topology at all? Fine-grained
+ * authorization — databases, tables, columns, row filters, masking — belongs to
+ * policy evaluated inside the backend against the identity Knox asserts, and is
+ * not something a gateway can usefully duplicate.
+ *
+ * The syntax and semantics are the servlet provider's, down to sharing its
+ * {@link AclParser}: {@code users;groups;ipaddresses}, an {@code AND}/{@code OR}
+ * processing mode, {@code *} wildcards, and the {@code KNOX_ADMIN_USERS} /
+ * {@code KNOX_ADMIN_GROUPS} placeholders. Operators should not have to learn a
+ * second ACL dialect because the transport changed.
+ */
+public class AclAuthorizer {
+
+ private static final String ACL_SUFFIX = ".acl";
+ private static final String ACL_MODE_SUFFIX = ".acl.mode";
+ private static final String DEFAULT_ACL_MODE = "AND";
+ private static final String KNOX_ADMIN_USERS_PLACEHOLDER = "KNOX_ADMIN_USERS";
+ private static final String KNOX_ADMIN_GROUPS_PLACEHOLDER = "KNOX_ADMIN_GROUPS";
+
+ private final AclParser parser = new AclParser();
+ private final String aclProcessingMode;
+ private final Set adminUsers;
+ private final Set adminGroups;
+ private final boolean unrestricted;
+
+ /**
+ * Builds an authorizer for one resource role from a topology's provider
+ * parameters.
+ *
+ * @param resourceRole the service role the ACLs apply to, e.g. {@code SPARKCONNECT}
+ * @param providerParams the {@code AclsAuthz} provider parameters, or null if the
+ * topology declares no such provider
+ * @param knoxAdminUsers comma-separated admin users from gateway configuration
+ * @param knoxAdminGroups comma-separated admin groups from gateway configuration
+ * @throws InvalidACLException if a configured ACL is malformed
+ */
+ public AclAuthorizer(String resourceRole,
+ Map providerParams,
+ String knoxAdminUsers,
+ String knoxAdminGroups) throws InvalidACLException {
+ // Provider params become filter params lowercased on the servlet path, and
+ // the filter looks them up that way; match it so the same topology XML works.
+ final Map params = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ if (providerParams != null) {
+ params.putAll(providerParams);
+ }
+
+ String mode = params.get(resourceRole + ACL_MODE_SUFFIX);
+ if (mode == null) {
+ mode = params.get("acl.mode");
+ }
+ this.aclProcessingMode = mode == null ? DEFAULT_ACL_MODE : mode.toUpperCase(Locale.ROOT);
+
+ final String acls = params.get(resourceRole + ACL_SUFFIX);
+ parser.parseAcls(resourceRole, acls);
+
+ this.adminUsers = split(knoxAdminUsers);
+ this.adminGroups = split(knoxAdminGroups);
+
+ // No ACLs configured at all means no restrictions, matching the servlet
+ // provider: a topology that never mentions this role does not silently deny.
+ this.unrestricted = parser.users.isEmpty() && parser.groups.isEmpty()
+ && parser.ipv.getIPAddresses().isEmpty();
+ }
+
+ private static Set split(String csv) {
+ if (csv == null || csv.trim().isEmpty()) {
+ return Collections.emptySet();
+ }
+ return new HashSet<>(Arrays.asList(csv.trim().split("\\s*,\\s*")));
+ }
+
+ /**
+ * Decides whether a call is permitted.
+ *
+ * @param user the authenticated principal
+ * @param groups the principal's groups, possibly empty
+ * @param remoteAddress the client's IP address, or null if unavailable
+ * @return true if the call may proceed
+ */
+ public boolean isPermitted(String user, Set groups, String remoteAddress) {
+ if (unrestricted) {
+ return true;
+ }
+
+ boolean userAccess = checkUser(user);
+ boolean groupAccess = checkGroups(groups);
+ boolean ipAccess = remoteAddress != null && parser.ipv.validateIpAddress(remoteAddress);
+
+ if ("OR".equals(aclProcessingMode)) {
+ // Under OR, a wildcard has to read as "not a reason to grant" — otherwise a
+ // single '*' in any position would admit everyone.
+ if (parser.anyUser) {
+ userAccess = false;
+ }
+ if (parser.anyGroup) {
+ groupAccess = false;
+ }
+ if (parser.ipv.allowsAnyIP()) {
+ ipAccess = false;
+ }
+ return userAccess || groupAccess || ipAccess;
+ }
+ if ("AND".equals(aclProcessingMode)) {
+ return userAccess && groupAccess && ipAccess;
+ }
+ return false;
+ }
+
+ private boolean checkUser(String user) {
+ if (user == null) {
+ return false;
+ }
+ if (parser.anyUser) {
+ return true;
+ }
+ if (parser.users.contains(user)) {
+ return true;
+ }
+ return parser.users.contains(KNOX_ADMIN_USERS_PLACEHOLDER) && adminUsers.contains(user);
+ }
+
+ private boolean checkGroups(Set groups) {
+ if (groups == null || groups.isEmpty()) {
+ // A subject with no groups can still satisfy an AND policy whose group
+ // position is a wildcard, e.g. '*;*;127.0.0.*'.
+ return parser.anyGroup && "AND".equals(aclProcessingMode);
+ }
+ if (parser.anyGroup) {
+ return true;
+ }
+ for (String group : groups) {
+ if (parser.groups.contains(group)) {
+ return true;
+ }
+ if (parser.groups.contains(KNOX_ADMIN_GROUPS_PLACEHOLDER) && adminGroups.contains(group)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuditInterceptor.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuditInterceptor.java
new file mode 100644
index 0000000000..5358ca7942
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuditInterceptor.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.AuditService;
+import org.apache.knox.gateway.audit.api.AuditServiceFactory;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.audit.api.ResourceType;
+import org.apache.knox.gateway.audit.log4j.audit.AuditConstants;
+
+import io.grpc.Context;
+import io.grpc.Contexts;
+import io.grpc.ForwardingServerCall;
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+
+/**
+ * Creates the per-call context and writes one audit record per RPC.
+ *
+ * This is the outermost interceptor. It runs first so that every call — including
+ * ones rejected for a bad token or a failed ACL check — gets a record, and it
+ * observes the outcome last, once the inner interceptors have filled in whatever
+ * they resolved. The record therefore reports the principal and topology even on
+ * paths where the call never reached a backend.
+ *
+ * A shared mutable {@link GrpcCallContext} is what makes that possible: gRPC
+ * context values set by an inner interceptor are not visible to an outer one, so
+ * the state the inner stages establish has to live in an object this interceptor
+ * created and attached before delegating.
+ */
+public class AuditInterceptor implements ServerInterceptor {
+
+ private static final AuditService AUDIT_SERVICE = AuditServiceFactory.getAuditService();
+ private static final Auditor AUDITOR = AuditServiceFactory.getAuditService()
+ .getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME,
+ AuditConstants.KNOX_SERVICE_NAME,
+ AuditConstants.KNOX_COMPONENT_NAME);
+
+ @Override
+ public ServerCall.Listener interceptCall(ServerCall call,
+ Metadata headers,
+ ServerCallHandler next) {
+ final String method = call.getMethodDescriptor().getFullMethodName();
+ final GrpcCallContext callContext = new GrpcCallContext(
+ method,
+ call.getAuthority(),
+ AuthorizationInterceptor.remoteAddressOf(call),
+ System.nanoTime());
+
+ final ServerCall auditedCall =
+ new ForwardingServerCall.SimpleForwardingServerCall(call) {
+ @Override
+ public void close(Status status, Metadata trailers) {
+ try {
+ audit(callContext, status);
+ } finally {
+ super.close(status, trailers);
+ }
+ }
+ };
+
+ final Context grpcContext = Context.current().withValue(GrpcCallContext.KEY, callContext);
+ return Contexts.interceptCall(grpcContext, auditedCall, headers, next);
+ }
+
+ private void audit(GrpcCallContext callContext, Status status) {
+ final long millis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - callContext.getStartNanos());
+ final String outcome = status.isOk() ? ActionOutcome.SUCCESS : ActionOutcome.FAILURE;
+
+ final StringBuilder message = new StringBuilder(160);
+ message.append("status=").append(status.getCode())
+ .append(", topology=").append(nullSafe(callContext.getTopology()))
+ .append(", backend=").append(nullSafe(callContext.getBackendUrl()))
+ .append(", remoteAddress=").append(nullSafe(callContext.getRemoteAddress()))
+ .append(", authority=").append(nullSafe(callContext.getAuthority()))
+ .append(", durationMs=").append(millis);
+ // Populated only on the proto-aware path; a byte-level proxy cannot know them.
+ if (callContext.getSessionId() != null) {
+ message.append(", sessionId=").append(callContext.getSessionId());
+ }
+ if (callContext.getOperationId() != null) {
+ message.append(", operationId=").append(callContext.getOperationId());
+ }
+
+ AUDIT_SERVICE.createContext();
+ try {
+ if (callContext.getPrincipal() != null) {
+ AUDIT_SERVICE.getContext().setUsername(callContext.getPrincipal());
+ }
+ AUDITOR.audit(Action.ACCESS, callContext.getMethodName(), ResourceType.URI, outcome,
+ message.toString());
+ } finally {
+ AUDIT_SERVICE.detachContext();
+ }
+ }
+
+ private static String nullSafe(String value) {
+ return value == null ? "-" : value;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthenticatedUser.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthenticatedUser.java
new file mode 100644
index 0000000000..81ee2081fe
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthenticatedUser.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * The identity a validated bearer token established.
+ *
+ * Groups come from the token's {@code knox.groups} claim when the deployment
+ * configures {@code knoxtoken} to embed them. That keeps authorization decisions
+ * free of a per-RPC group lookup, which suits a credential that is already a
+ * point-in-time delegation of the user's identity.
+ */
+public class AuthenticatedUser {
+
+ private final String principal;
+ private final Set groups;
+
+ public AuthenticatedUser(String principal, Set groups) {
+ this.principal = principal;
+ this.groups = groups == null ? Collections.emptySet() : Collections.unmodifiableSet(groups);
+ }
+
+ public String getPrincipal() {
+ return principal;
+ }
+
+ public Set getGroups() {
+ return groups;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthenticationInterceptor.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthenticationInterceptor.java
new file mode 100644
index 0000000000..8f65cb32a1
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthenticationInterceptor.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+
+/**
+ * Rejects any call that does not present a valid Knox bearer token.
+ *
+ * Authentication happens before a backend channel is opened, so an
+ * unauthenticated request never reaches the backend — which matters because the
+ * services this fronts commonly have little or no authentication of their own
+ * and assume a proxy provides it.
+ *
+ * Tokens are checked when an RPC starts and not again while it runs. A
+ * multi-hour {@code ExecutePlan} is therefore not severed the moment its token
+ * expires; the next RPC fails instead. Cutting off long queries at expiry would
+ * punish precisely the workloads these protocols exist to serve, and the
+ * backend's own session timeout still bounds how long a session survives.
+ */
+public class AuthenticationInterceptor implements ServerInterceptor {
+
+ private static final GrpcGatewayMessages LOG = MessagesFactory.get(GrpcGatewayMessages.class);
+
+ private final TokenAuthenticator authenticator;
+
+ public AuthenticationInterceptor(TokenAuthenticator authenticator) {
+ this.authenticator = authenticator;
+ }
+
+ @Override
+ public ServerCall.Listener interceptCall(ServerCall call,
+ Metadata headers,
+ ServerCallHandler next) {
+ final String method = call.getMethodDescriptor().getFullMethodName();
+ final String header = headers.get(GrpcMetadataKeys.AUTHORIZATION);
+
+ if (header == null || !header.regionMatches(true, 0, GrpcMetadataKeys.BEARER_PREFIX, 0,
+ GrpcMetadataKeys.BEARER_PREFIX.length())) {
+ return reject(call, method, "no bearer token presented");
+ }
+
+ final String serializedToken = header.substring(GrpcMetadataKeys.BEARER_PREFIX.length()).trim();
+ final AuthenticatedUser user;
+ try {
+ user = authenticator.authenticate(serializedToken);
+ } catch (TokenAuthenticator.AuthenticationException e) {
+ return reject(call, method, e.getMessage());
+ } catch (RuntimeException e) {
+ // Belt and braces: whatever goes wrong while examining a credential, the
+ // answer is that the call is not authenticated. Letting an exception escape
+ // would hand the caller UNKNOWN instead of UNAUTHENTICATED, which both
+ // leaks that the input was unusual and lets a malformed token cost the
+ // gateway a stack trace on every request.
+ return reject(call, method, "token validation failed: " + e.getClass().getSimpleName());
+ }
+
+ final GrpcCallContext callContext = GrpcCallContext.current();
+ if (callContext != null) {
+ callContext.setPrincipal(user.getPrincipal());
+ callContext.setGroups(user.getGroups());
+ }
+ return next.startCall(call, headers);
+ }
+
+ private ServerCall.Listener reject(ServerCall call,
+ String method,
+ String reason) {
+ LOG.authenticationFailed(method, reason);
+ // The description is deliberately generic: distinguishing "expired" from
+ // "bad signature" tells an attacker which tokens are real.
+ call.close(Status.UNAUTHENTICATED.withDescription("Invalid or missing bearer token"), new Metadata());
+ return new ServerCall.Listener() { };
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthorizationInterceptor.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthorizationInterceptor.java
new file mode 100644
index 0000000000..1549ff556e
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/AuthorizationInterceptor.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.filter.InvalidACLException;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Provider;
+import org.apache.knox.gateway.topology.Topology;
+
+import io.grpc.Grpc;
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+
+/**
+ * Applies the coarse "may this user use this service in this topology" check,
+ * after authentication and before any backend connection is opened.
+ *
+ * The servlet {@code AclsAuthz} filter cannot run here — there is no filter
+ * chain on a gRPC call — so this reads the same provider configuration directly
+ * and evaluates it with the same parser. A topology that declares no ACLs for
+ * the role is unrestricted, as on the servlet path.
+ */
+public class AuthorizationInterceptor implements ServerInterceptor {
+
+ private static final GrpcGatewayMessages LOG = MessagesFactory.get(GrpcGatewayMessages.class);
+
+ private static final String AUTHZ_PROVIDER_ROLE = "authorization";
+ private static final String ACLS_AUTHZ_PROVIDER_NAME = "AclsAuthz";
+
+ private final GatewayConfig config;
+ private final GatewayServices services;
+ private final String resourceRole;
+ /**
+ * Authorizers are derived from topology configuration, which changes only on
+ * redeploy, so they are cached per topology rather than rebuilt per RPC. The
+ * cache is cleared when topologies are reloaded.
+ */
+ private final Map authorizers = new ConcurrentHashMap<>();
+
+ public AuthorizationInterceptor(GatewayConfig config, GatewayServices services, String resourceRole) {
+ this.config = config;
+ this.services = services;
+ this.resourceRole = resourceRole;
+ }
+
+ /** Drops cached ACLs so a redeployed topology takes effect. */
+ public void invalidate() {
+ authorizers.clear();
+ }
+
+ @Override
+ public ServerCall.Listener interceptCall(ServerCall call,
+ Metadata headers,
+ ServerCallHandler next) {
+ final GrpcCallContext callContext = GrpcCallContext.current();
+ final String method = call.getMethodDescriptor().getFullMethodName();
+ final String topology = callContext == null ? null : callContext.getTopology();
+ final String user = callContext == null ? null : callContext.getPrincipal();
+
+ if (topology == null || user == null) {
+ // Routing and authentication run first; reaching here without either means
+ // the chain was assembled wrongly. Deny rather than guess.
+ return reject(call, method, user, topology, "call reached authorization without an identity or topology");
+ }
+
+ final AclAuthorizer authorizer;
+ try {
+ authorizer = authorizers.computeIfAbsent(topology, this::buildAuthorizer);
+ } catch (InvalidAclConfigurationException e) {
+ return reject(call, method, user, topology, e.getMessage());
+ }
+
+ if (!authorizer.isPermitted(user, callContext.getGroups(), callContext.getRemoteAddress())) {
+ return reject(call, method, user, topology, "denied by the topology ACLs for " + resourceRole);
+ }
+ return next.startCall(call, headers);
+ }
+
+ private AclAuthorizer buildAuthorizer(String topologyName) {
+ final TopologyService topologyService = services.getService(ServiceType.TOPOLOGY_SERVICE);
+ Map providerParams = null;
+ if (topologyService != null) {
+ for (Topology topology : topologyService.getTopologies()) {
+ if (topologyName.equals(topology.getName())) {
+ final Provider provider = topology.getProvider(AUTHZ_PROVIDER_ROLE, ACLS_AUTHZ_PROVIDER_NAME);
+ if (provider != null && provider.isEnabled()) {
+ providerParams = provider.getParams();
+ }
+ break;
+ }
+ }
+ }
+ try {
+ return new AclAuthorizer(resourceRole, providerParams,
+ config.getKnoxAdminUsers(), config.getKnoxAdminGroups());
+ } catch (InvalidACLException e) {
+ throw new InvalidAclConfigurationException(
+ "Topology " + topologyName + " has malformed ACLs for " + resourceRole, e);
+ }
+ }
+
+ static String remoteAddressOf(ServerCall, ?> call) {
+ final SocketAddress address = call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
+ if (address instanceof InetSocketAddress) {
+ final InetSocketAddress inet = (InetSocketAddress) address;
+ return inet.getAddress() == null ? inet.getHostString() : inet.getAddress().getHostAddress();
+ }
+ return address == null ? null : address.toString();
+ }
+
+ private ServerCall.Listener reject(ServerCall call,
+ String method,
+ String user,
+ String topology,
+ String reason) {
+ LOG.authorizationFailed(method, user, topology, reason);
+ call.close(Status.PERMISSION_DENIED.withDescription("Not permitted to use " + resourceRole), new Metadata());
+ return new ServerCall.Listener() { };
+ }
+
+ /** Wraps {@link InvalidACLException} so it can escape a {@code computeIfAbsent} mapping function. */
+ static class InvalidAclConfigurationException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ InvalidAclConfigurationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendChannelCache.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendChannelCache.java
new file mode 100644
index 0000000000..3511e1b4ce
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendChannelCache.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.KeyStore;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.TrustManagerFactory;
+
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.security.KeystoreService;
+
+import io.grpc.ManagedChannel;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NegotiationType;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+
+/**
+ * Keeps one {@link ManagedChannel} per backend URL, shared by every call routed
+ * to that backend.
+ *
+ * gRPC channels multiplex concurrent calls over a pooled HTTP/2 connection and
+ * are designed to be long-lived, so creating one per RPC would be both slower
+ * and wasteful of connections. Channels go idle on their own after
+ * {@code gateway.grpc.channel.idle.timeout} and reconnect transparently
+ * when used again, so a cached entry for an unused backend costs nothing.
+ */
+public class BackendChannelCache {
+
+ private static final GrpcGatewayMessages LOG = MessagesFactory.get(GrpcGatewayMessages.class);
+
+ private static final String PLAINTEXT_SCHEME = "grpc";
+ private static final String TLS_SCHEME = "grpcs";
+
+ private final GrpcListenerSettings settings;
+ private final GatewayServices services;
+ private final Map channels = new ConcurrentHashMap<>();
+
+ public BackendChannelCache(GrpcListenerSettings settings, GatewayServices services) {
+ this.settings = settings;
+ this.services = services;
+ }
+
+ /**
+ * Returns the shared channel for the given backend URL, creating it if this is
+ * the first call to that backend.
+ *
+ * @param backendUrl a {@code grpc://host:port} or {@code grpcs://host:port} URL
+ * @return the channel for that backend
+ * @throws io.grpc.StatusRuntimeException if the URL is unusable or TLS cannot be set up
+ */
+ public ManagedChannel getChannel(String backendUrl) {
+ return channels.computeIfAbsent(backendUrl, this::createChannel);
+ }
+
+ private ManagedChannel createChannel(String backendUrl) {
+ final URI uri = parse(backendUrl);
+ final String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
+ final String host = uri.getHost();
+ final int port = uri.getPort();
+
+ if (host == null || port < 0) {
+ throw Status.FAILED_PRECONDITION
+ .withDescription("The backend URL must include a host and port: " + backendUrl)
+ .asRuntimeException();
+ }
+
+ final NettyChannelBuilder builder = NettyChannelBuilder.forAddress(host, port)
+ .maxInboundMessageSize(settings.getMaxMessageSize())
+ .idleTimeout(settings.getChannelIdleTimeoutMillis(), TimeUnit.MILLISECONDS);
+
+ if (TLS_SCHEME.equals(scheme)) {
+ builder.negotiationType(NegotiationType.TLS);
+ try {
+ builder.sslContext(GrpcSslContexts.forClient().trustManager(backendTrustManagers()).build());
+ } catch (Exception e) {
+ LOG.failedToBuildBackendTls(backendUrl, e);
+ throw Status.UNAVAILABLE
+ .withDescription("Cannot establish TLS to the backend")
+ .withCause(e)
+ .asRuntimeException();
+ }
+ } else if (PLAINTEXT_SCHEME.equals(scheme)) {
+ builder.negotiationType(NegotiationType.PLAINTEXT);
+ } else {
+ throw Status.FAILED_PRECONDITION
+ .withDescription("The backend URL scheme must be grpc:// or grpcs://, got: " + backendUrl)
+ .asRuntimeException();
+ }
+
+ LOG.openedBackendChannel(backendUrl);
+ return builder.build();
+ }
+
+ /**
+ * Trust material for the backend leg: the HTTP client truststore if the
+ * deployment configured one, otherwise the gateway keystore. This is the same
+ * fallback the WebSocket handler applies, so a deployment that already trusts
+ * its backends over {@code wss://} needs no extra configuration here.
+ */
+ private TrustManagerFactory backendTrustManagers() throws Exception {
+ final KeystoreService keystoreService = services.getService(ServiceType.KEYSTORE_SERVICE);
+ KeyStore truststore = keystoreService.getTruststoreForHttpClient();
+ if (truststore == null) {
+ truststore = keystoreService.getKeystoreForGateway();
+ }
+ final TrustManagerFactory factory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ factory.init(truststore);
+ return factory;
+ }
+
+ private static URI parse(String backendUrl) {
+ try {
+ return new URI(backendUrl);
+ } catch (URISyntaxException e) {
+ throw Status.FAILED_PRECONDITION
+ .withDescription("Malformed backend URL: " + backendUrl)
+ .withCause(e)
+ .asRuntimeException();
+ }
+ }
+
+ /**
+ * Shuts every cached channel down, waiting up to the given deadline in total
+ * for in-flight calls to finish.
+ *
+ * @param timeoutMillis total time to wait for all channels to terminate
+ */
+ public void shutdown(long timeoutMillis) {
+ final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
+ for (Map.Entry entry : channels.entrySet()) {
+ entry.getValue().shutdown();
+ }
+ for (Map.Entry entry : channels.entrySet()) {
+ final long remaining = deadline - System.nanoTime();
+ try {
+ if (remaining <= 0 || !entry.getValue().awaitTermination(remaining, TimeUnit.NANOSECONDS)) {
+ entry.getValue().shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ entry.getValue().shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ LOG.closedBackendChannel(entry.getKey());
+ }
+ channels.clear();
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendChannelProvider.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendChannelProvider.java
new file mode 100644
index 0000000000..0c8f11cb6b
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendChannelProvider.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import io.grpc.Channel;
+
+/**
+ * Supplies the backend channel for the call in flight.
+ *
+ * Implementations read the backend the routing interceptor resolved into the
+ * current {@link GrpcCallContext}, so the proxy handler itself never needs to
+ * know how topologies map to backends.
+ */
+@FunctionalInterface
+public interface BackendChannelProvider {
+
+ /**
+ * Returns the channel for the current call's backend.
+ *
+ * @return a channel to the backend
+ * @throws io.grpc.StatusRuntimeException if no backend can be resolved
+ */
+ Channel getChannel();
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendHeaderRewriter.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendHeaderRewriter.java
new file mode 100644
index 0000000000..f890c7ed2b
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/BackendHeaderRewriter.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.AliasServiceException;
+
+import io.grpc.Metadata;
+
+/**
+ * Replaces the client's credentials with Knox's own on the backend leg.
+ *
+ * The client's bearer token proves the user's identity to Knox and has no
+ * meaning beyond it, so it is removed rather than forwarded. In its place, if a
+ * pre-shared backend token is configured, Knox presents that. Besides
+ * authenticating the gateway to the backend, it closes the hole where a client
+ * with network reachability to the backend port could simply bypass the gateway
+ * altogether — network restrictions should prevent that too, but a credential
+ * the client does not hold makes it structural rather than topological.
+ *
+ * Knox-internal routing metadata is dropped for the same reason: it was
+ * addressed to the gateway, and the backend has no use for it.
+ */
+public class BackendHeaderRewriter implements HeaderRewriter {
+
+ private static final GrpcGatewayMessages LOG = MessagesFactory.get(GrpcGatewayMessages.class);
+
+ private final String backendAuthorization;
+ private final Metadata.Key topologyKey;
+
+ /**
+ * @param aliasService used to resolve the backend token; may be null when no
+ * alias is configured
+ * @param backendTokenAlias the alias holding the backend's pre-shared token, or
+ * null if the backend requires no token
+ */
+ public BackendHeaderRewriter(AliasService aliasService, String backendTokenAlias,
+ Metadata.Key topologyKey) {
+ this.backendAuthorization = resolveBackendToken(aliasService, backendTokenAlias);
+ this.topologyKey = topologyKey;
+ }
+
+ private static String resolveBackendToken(AliasService aliasService, String alias) {
+ if (aliasService == null || alias == null || alias.trim().isEmpty()) {
+ return null;
+ }
+ try {
+ final char[] token = aliasService.getPasswordFromAliasForGateway(alias);
+ if (token == null || token.length == 0) {
+ LOG.missingBackendTokenAlias(alias);
+ return null;
+ }
+ return GrpcMetadataKeys.BEARER_PREFIX + new String(token);
+ } catch (AliasServiceException e) {
+ LOG.missingBackendTokenAlias(alias);
+ return null;
+ }
+ }
+
+ @Override
+ public void rewrite(Metadata headers) {
+ headers.removeAll(GrpcMetadataKeys.AUTHORIZATION);
+ headers.removeAll(topologyKey);
+ if (backendAuthorization != null) {
+ headers.put(GrpcMetadataKeys.AUTHORIZATION, backendAuthorization);
+ }
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/ByteArrayMarshaller.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/ByteArrayMarshaller.java
new file mode 100644
index 0000000000..e836591dfc
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/ByteArrayMarshaller.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import io.grpc.MethodDescriptor;
+import io.grpc.Status;
+
+/**
+ * Passes message bodies through as opaque bytes.
+ *
+ * With this marshaller the gateway can relay a call whose message types it has
+ * no generated classes for, which is what makes the fallback path work for
+ * methods outside the vendored protos — a newer client calling an RPC added
+ * after this build still gets proxied rather than rejected.
+ */
+public final class ByteArrayMarshaller implements MethodDescriptor.Marshaller {
+
+ public static final ByteArrayMarshaller INSTANCE = new ByteArrayMarshaller();
+
+ private ByteArrayMarshaller() {
+ }
+
+ @Override
+ public InputStream stream(byte[] value) {
+ return new ByteArrayInputStream(value);
+ }
+
+ @Override
+ public byte[] parse(InputStream stream) {
+ try {
+ // grpc hands over the complete message, so a single drain is enough and the
+ // inbound size limit has already been applied by the transport.
+ return readAll(stream);
+ } catch (IOException e) {
+ throw Status.INTERNAL.withDescription("Failed to read gRPC message").withCause(e).asRuntimeException();
+ }
+ }
+
+ private static byte[] readAll(InputStream stream) throws IOException {
+ final java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
+ final byte[] chunk = new byte[8192];
+ int read;
+ while ((read = stream.read(chunk)) != -1) {
+ buffer.write(chunk, 0, read);
+ }
+ return buffer.toByteArray();
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcCallContext.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcCallContext.java
new file mode 100644
index 0000000000..b330cee1c4
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcCallContext.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Collections;
+import java.util.Set;
+
+import io.grpc.Context;
+
+/**
+ * Per-call state shared across the interceptor chain and the proxy handler.
+ *
+ * The instance is created by the outermost interceptor and attached to the gRPC
+ * {@link Context}, then filled in as the call descends the chain: authentication
+ * sets the principal and groups, routing sets the topology and backend. Because
+ * every interceptor mutates one object rather than layering new context values,
+ * the audit interceptor — which wraps the call from outside — can still report
+ * what the inner interceptors resolved, including on the paths where they
+ * rejected the call.
+ *
+ * Instances are confined to a single call. gRPC may invoke listener callbacks on
+ * different threads, so the fields are volatile; they are written once during
+ * interceptor descent and only read afterwards.
+ */
+// volatile, not synchronized: gRPC dispatches a call's listener callbacks across
+// threads, and these fields are written once during interceptor descent and read
+// afterwards. A lock would serialise readers for no benefit.
+@SuppressWarnings("PMD.AvoidUsingVolatile")
+public class GrpcCallContext {
+
+ public static final Context.Key KEY = Context.key("KnoxGrpcCallContext");
+
+ private final String methodName;
+ private final String authority;
+ private final String remoteAddress;
+ private final long startNanos;
+
+ private volatile String principal;
+ private volatile Set groups = Collections.emptySet();
+ private volatile String topology;
+ private volatile String backendUrl;
+ private volatile String sessionId;
+ private volatile String operationId;
+
+ public GrpcCallContext(String methodName, String authority, String remoteAddress, long startNanos) {
+ this.methodName = methodName;
+ this.authority = authority;
+ this.remoteAddress = remoteAddress;
+ this.startNanos = startNanos;
+ }
+
+ /**
+ * Returns the call context attached to the current gRPC context, or null when
+ * called outside a proxied call.
+ *
+ * @return the current call context, or null
+ */
+ public static GrpcCallContext current() {
+ return KEY.get();
+ }
+
+ public String getMethodName() {
+ return methodName;
+ }
+
+ public String getAuthority() {
+ return authority;
+ }
+
+ public String getRemoteAddress() {
+ return remoteAddress;
+ }
+
+ public long getStartNanos() {
+ return startNanos;
+ }
+
+ public String getPrincipal() {
+ return principal;
+ }
+
+ public void setPrincipal(String principal) {
+ this.principal = principal;
+ }
+
+ public Set getGroups() {
+ return groups;
+ }
+
+ public void setGroups(Set groups) {
+ this.groups = groups == null ? Collections.emptySet() : Collections.unmodifiableSet(groups);
+ }
+
+ public String getTopology() {
+ return topology;
+ }
+
+ public void setTopology(String topology) {
+ this.topology = topology;
+ }
+
+ public String getBackendUrl() {
+ return backendUrl;
+ }
+
+ public void setBackendUrl(String backendUrl) {
+ this.backendUrl = backendUrl;
+ }
+
+ /**
+ * The session this call belongs to, where the protocol has such a notion and a
+ * request message has been parsed. Null when requests are relayed without
+ * inspection.
+ *
+ * @return the session id, or null
+ */
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public void setSessionId(String sessionId) {
+ this.sessionId = sessionId;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public void setOperationId(String operationId) {
+ this.operationId = operationId;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcEndpoint.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcEndpoint.java
new file mode 100644
index 0000000000..fdf16989de
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcEndpoint.java
@@ -0,0 +1,515 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.KeyManagerFactory;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.KeystoreService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Service;
+import org.apache.knox.gateway.topology.Topology;
+
+import io.grpc.Metadata;
+import io.grpc.Server;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
+
+/**
+ * One gRPC listener: a Netty server on one port, with its own TLS identity, its
+ * own view of what to proxy, and its own backend channels.
+ *
+ * A gateway runs one of these per configured listener. They share the gateway's
+ * services — tokens, topologies, audit — and route to the same topologies; what
+ * distinguishes them is the socket and the certificate presented on it. See
+ * {@link GrpcListenerSettingsFactory} for why that separation is worth having.
+ */
+// volatile: lifecycle and policy fields are written by the thread calling
+// start/stop or delivering a configuration change, and read by request threads.
+@SuppressWarnings("PMD.AvoidUsingVolatile")
+public class GrpcEndpoint {
+
+ private static final GrpcGatewayMessages LOG = MessagesFactory.get(GrpcGatewayMessages.class);
+
+ private final GrpcListenerSettings settings;
+
+ private volatile Server server;
+ private volatile BackendChannelCache channelCache;
+ private volatile AuthorizationInterceptor authorizationInterceptor;
+ private volatile MethodAccessInterceptor methodAccessInterceptor;
+ /**
+ * Rebuilt when configuration changes. The relay reads this per message rather
+ * than capturing it, so a change reaches handlers that already exist.
+ */
+ private volatile MessageInterceptor messageInterceptor = MessageInterceptor.passthrough();
+ /** Read per call, so a changed default topology applies without a restart. */
+ private volatile String defaultTopology;
+ /** The configuration the running interceptor was built from, for change detection. */
+ private volatile String identityRules;
+ private volatile int identityScanLimit;
+
+ public GrpcEndpoint(GrpcListenerSettings settings) {
+ this.settings = settings;
+ this.defaultTopology = settings.getDefaultTopology();
+ this.identityRules = settings.getIdentityRules();
+ this.identityScanLimit = settings.getIdentityScanLimit();
+ }
+
+ public String getName() {
+ return settings.getName();
+ }
+
+ public GrpcListenerSettings getSettings() {
+ return settings;
+ }
+
+ public int getPort() {
+ final Server current = server;
+ return current == null ? -1 : current.getPort();
+ }
+
+ /**
+ * Binds the port and begins serving.
+ *
+ * @param config the gateway configuration, for the services shared across
+ * listeners: token validation, admin users, keystores
+ * @param services the started gateway services
+ * @throws Exception if the listener cannot start
+ */
+ public void start(GatewayConfig config, GatewayServices services) throws Exception {
+ if (settings.getProtoServices().isEmpty()) {
+ // Refusing to start beats binding a port that answers UNIMPLEMENTED to
+ // everything, which would look like a working listener.
+ throw new IllegalStateException("The gRPC listener '" + getName() + "' is enabled but "
+ + GrpcListenerSettingsFactory.propertyName(null, "proto.services")
+ + " names no proto service to proxy");
+ }
+
+ // Parsed here rather than on the first call: a malformed rule must stop the
+ // gateway starting, not silently leave identity assertion switched off.
+ final IdentityRewritePolicy identityPolicy = createPolicy(settings);
+ this.messageInterceptor = createMessageInterceptor(identityPolicy);
+
+ final BackendChannelCache channels = new BackendChannelCache(settings, services);
+ this.channelCache = channels;
+
+ final BackendChannelProvider channelProvider = () -> {
+ final GrpcCallContext callContext = GrpcCallContext.current();
+ if (callContext == null || callContext.getBackendUrl() == null) {
+ throw Status.UNAVAILABLE.withDescription("No backend resolved for this call").asRuntimeException();
+ }
+ return channels.getChannel(callContext.getBackendUrl());
+ };
+
+ // Built once, and validated here rather than on the first call: a bad key
+ // name should stop the gateway starting, not surprise the first user.
+ final Metadata.Key topologyKey =
+ GrpcMetadataKeys.topologyKey(settings.getTopologyMetadataKey());
+
+ final AliasService aliasService = services.getService(ServiceType.ALIAS_SERVICE);
+ final HeaderRewriter headerRewriter =
+ new BackendHeaderRewriter(aliasService, settings.getBackendTokenAlias(), topologyKey);
+
+ final String serviceRole = settings.getServiceRole();
+ this.authorizationInterceptor = new AuthorizationInterceptor(config, services, serviceRole);
+ this.methodAccessInterceptor = new MethodAccessInterceptor(services, serviceRole,
+ MethodAccessPolicy.of(settings.getMethodsDeny(), settings.getMethodsAllow()));
+
+ // Order is load-bearing: audit wraps everything so even rejected calls are
+ // recorded, then identity, then topology selection, then the checks that
+ // depend on both having succeeded.
+ final List interceptors = Arrays.asList(
+ new AuditInterceptor(),
+ new AuthenticationInterceptor(new TokenAuthenticator(config, services)),
+ new RoutingInterceptor(() -> defaultTopology, services, serviceRole, topologyKey),
+ authorizationInterceptor,
+ // Coarse method gating needs no schema: gRPC carries the method name in
+ // the request path. It runs last so a denial is attributable to a known
+ // user in a known topology.
+ methodAccessInterceptor);
+
+ final NettyServerBuilder builder = NettyServerBuilder.forPort(settings.getPort())
+ .maxInboundMessageSize(settings.getMaxMessageSize())
+ .maxConcurrentCallsPerConnection(settings.getMaxConcurrentCallsPerConnection())
+ .permitKeepAliveTime(settings.getPermitKeepAliveTimeMillis(), TimeUnit.MILLISECONDS)
+ .permitKeepAliveWithoutCalls(settings.isPermitKeepAliveWithoutCalls());
+
+ if (settings.isSslEnabled()) {
+ builder.sslContext(buildServerSslContext(config, services));
+ } else {
+ // Clients that carry a bearer token generally require TLS anyway, so this
+ // is really a test and development posture; say so rather than let it pass.
+ LOG.listenerTlsDisabled(getName());
+ }
+
+ // No generated service is registered. Every call for a proxied proto service
+ // reaches the same byte-level relay, and the relay consults the current
+ // interceptor per message.
+ final MessageInterceptor currentInterceptor = message -> messageInterceptor.intercept(message);
+ builder.fallbackHandlerRegistry(new ProxyHandlerRegistry(
+ settings.getProtoServices(),
+ methodName -> currentInterceptor,
+ relay -> InterceptorChain.intercept(
+ new ProxyCallHandler<>(channelProvider, relay, headerRewriter), interceptors)));
+
+ try {
+ this.server = builder.build().start();
+ } catch (Exception e) {
+ LOG.failedToStartListener(getName(), e);
+ channels.shutdown(0L);
+ this.channelCache = null;
+ throw e;
+ }
+ LOG.startedListener(getName(), getPort());
+ LOG.proxyingServices(getName(), String.join(", ", settings.getProtoServices()),
+ identityPolicy.toString());
+ noteIfNoTopologyDeclaresTheRole(services, serviceRole);
+ }
+
+ private static IdentityRewritePolicy createPolicy(GrpcListenerSettings settings) {
+ return IdentityRewritePolicy.parse(settings.getIdentityRules(), settings.getIdentityScanLimit());
+ }
+
+ /**
+ * Identity assertion is optional: a protocol whose requests carry no identity
+ * field gets a pure relay. Where rules are given, every request has each named
+ * field replaced with the authenticated principal.
+ */
+ private static MessageInterceptor createMessageInterceptor(IdentityRewritePolicy policy) {
+ return policy.isEmpty()
+ ? MessageInterceptor.passthrough()
+ : new IdentityAssertingInterceptor(policy);
+ }
+
+ /**
+ * Notes, at debug level, that this listener is running with nothing to route to.
+ *
+ * Enabling a listener and declaring a backend are separate steps in separate
+ * files, so it is possible to do the first and forget the second — but it is
+ * equally possible to do the first deliberately and wait. A deployment that
+ * enables a listener as a matter of course, and adds a topology only when
+ * someone provisions a backend, is in this state normally and perhaps
+ * permanently. That is why this is debug rather than a warning: it helps when
+ * someone is asking why calls are refused, without nagging every deployment
+ * that is simply waiting.
+ */
+ private void noteIfNoTopologyDeclaresTheRole(GatewayServices services, String serviceRole) {
+ final TopologyService topologyService = services.getService(ServiceType.TOPOLOGY_SERVICE);
+ if (topologyService == null) {
+ return;
+ }
+ for (Topology topology : topologyService.getTopologies()) {
+ for (Service service : topology.getServices()) {
+ if (serviceRole.equals(service.getRole())) {
+ return;
+ }
+ }
+ }
+ LOG.noTopologyDeclaresService(getName(), serviceRole);
+ }
+
+ /**
+ * Builds this listener's TLS context.
+ *
+ * By default that is the gateway identity — the same key material Jetty
+ * presents — so a deployment has one certificate to manage, not several. A
+ * listener that configures its own keystore presents that instead, which is
+ * what allows several listeners on one gateway to answer for several hostnames
+ * with plain single-name certificates.
+ *
+ * Either way the chosen entry is copied into a single-entry keystore before the
+ * key manager is built, so the configured alias is the one presented even when
+ * the source keystore holds others.
+ */
+ private SslContext buildServerSslContext(GatewayConfig config, GatewayServices services)
+ throws Exception {
+ try {
+ final AliasService aliasService = services.getService(ServiceType.ALIAS_SERVICE);
+ final KeyStore source;
+ final String alias;
+ final char[] passphrase;
+
+ if (settings.getSslKeystorePath() == null) {
+ final KeystoreService keystoreService = services.getService(ServiceType.KEYSTORE_SERVICE);
+ source = keystoreService.getKeystoreForGateway();
+ if (source == null) {
+ throw new IllegalStateException("The gateway identity keystore is not available");
+ }
+ alias = config.getIdentityKeyAlias();
+ passphrase = aliasService.getGatewayIdentityPassphrase();
+ } else {
+ passphrase = keystorePassphrase(aliasService);
+ source = loadKeystore(settings.getSslKeystorePath(), settings.getSslKeystoreType(), passphrase);
+ alias = keyEntryAlias(source);
+ }
+
+ final Key key = source.getKey(alias, passphrase);
+ final Certificate[] chain = source.getCertificateChain(alias);
+ if (!(key instanceof PrivateKey) || chain == null || chain.length == 0) {
+ throw new IllegalStateException("The keystore for gRPC listener '" + getName()
+ + "' has no usable key entry for alias " + alias);
+ }
+
+ final KeyStore identity = KeyStore.getInstance("PKCS12");
+ identity.load(null, null);
+ identity.setKeyEntry(alias, key, passphrase, chain);
+
+ final KeyManagerFactory keyManagers =
+ KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagers.init(identity, passphrase);
+
+ LOG.listenerTlsIdentity(getName(),
+ settings.getSslKeystorePath() == null ? "the gateway identity" : settings.getSslKeystorePath(),
+ alias);
+
+ // GrpcSslContexts applies the ALPN and cipher requirements of the HTTP/2
+ // profile gRPC mandates.
+ return GrpcSslContexts.configure(SslContextBuilder.forServer(keyManagers)).build();
+ } catch (Exception e) {
+ LOG.failedToBuildServerTls(getName(), e);
+ throw e;
+ }
+ }
+
+ /**
+ * The keystore password, from the configured alias. Falling back to the gateway
+ * identity passphrase matches what the embedded LDAP server does, and covers
+ * the common case of a keystore provisioned alongside the gateway's own.
+ */
+ private char[] keystorePassphrase(AliasService aliasService) throws Exception {
+ final String alias = settings.getSslKeystorePasswordAlias();
+ if (alias == null) {
+ return aliasService.getGatewayIdentityPassphrase();
+ }
+ final char[] password = aliasService.getPasswordFromAliasForGateway(alias);
+ if (password == null || password.length == 0) {
+ throw new IllegalStateException("The keystore password alias '" + alias
+ + "' configured for gRPC listener '" + getName() + "' resolves to nothing");
+ }
+ return password;
+ }
+
+ private KeyStore loadKeystore(String path, String type, char[] passphrase) throws Exception {
+ if (!Files.isReadable(Paths.get(path))) {
+ throw new IllegalStateException("The keystore configured for gRPC listener '" + getName()
+ + "' cannot be read: " + path);
+ }
+ final KeyStore keystore = KeyStore.getInstance(type);
+ try (InputStream in = Files.newInputStream(Paths.get(path))) {
+ keystore.load(in, passphrase);
+ } catch (IOException e) {
+ throw new IllegalStateException("The keystore configured for gRPC listener '" + getName()
+ + "' could not be loaded; check its type and password: " + path, e);
+ }
+ return keystore;
+ }
+
+ /**
+ * The entry to present. A keystore holding exactly one key entry needs no alias
+ * configured, which is the usual shape of a per-listener keystore; anything
+ * else has to say which, because picking arbitrarily would present a
+ * certificate nobody chose.
+ */
+ private String keyEntryAlias(KeyStore keystore) throws Exception {
+ final String configured = settings.getSslKeystoreAlias();
+ if (configured != null) {
+ if (!keystore.containsAlias(configured)) {
+ throw new IllegalStateException("The keystore for gRPC listener '" + getName()
+ + "' holds no entry named " + configured);
+ }
+ return configured;
+ }
+ final List keyEntries = new ArrayList<>();
+ final Enumeration aliases = keystore.aliases();
+ while (aliases.hasMoreElements()) {
+ final String candidate = aliases.nextElement();
+ if (keystore.isKeyEntry(candidate)) {
+ keyEntries.add(candidate);
+ }
+ }
+ if (keyEntries.size() == 1) {
+ return keyEntries.get(0);
+ }
+ throw new IllegalStateException("The keystore for gRPC listener '" + getName() + "' holds "
+ + keyEntries.size() + " key entries, so "
+ + GrpcListenerSettingsFactory.propertyName(getName(), "ssl.keystore.alias")
+ + " must name the one to present");
+ }
+
+ /**
+ * Stops accepting new calls and lets in-flight ones finish, up to the
+ * configured drain timeout.
+ *
+ * Long-lived streams are severed if they outlast the drain. Clients of
+ * streaming protocols generally recover, since such protocols usually carry
+ * their own reattach or retry mechanism for exactly this case.
+ */
+ public void stop() {
+ final Server current = server;
+ if (current == null) {
+ return;
+ }
+ final long drainTimeoutMillis = settings.getDrainTimeoutMillis();
+ LOG.stoppingListener(getName(), drainTimeoutMillis);
+ current.shutdown();
+ try {
+ if (!current.awaitTermination(drainTimeoutMillis, TimeUnit.MILLISECONDS)) {
+ LOG.drainTimedOut(getName(), drainTimeoutMillis);
+ current.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ current.shutdownNow();
+ Thread.currentThread().interrupt();
+ } finally {
+ server = null;
+ final BackendChannelCache channels = channelCache;
+ if (channels != null) {
+ channels.shutdown(drainTimeoutMillis);
+ channelCache = null;
+ }
+ LOG.stoppedListener(getName());
+ }
+ }
+
+ /** Drops cached per-topology policy so a redeployed topology takes effect. */
+ public void reload() {
+ final AuthorizationInterceptor authz = authorizationInterceptor;
+ if (authz != null) {
+ authz.invalidate();
+ }
+ final MethodAccessInterceptor methods = methodAccessInterceptor;
+ if (methods != null) {
+ methods.invalidate();
+ }
+ }
+
+ /**
+ * Applies a changed {@code gateway-reloadable.xml} to the controls that can
+ * move on a running listener.
+ *
+ * Only the identity rewrite and the default topology are refreshed. The
+ * transport settings are built into the bound server and cannot change without
+ * a restart, so rather than accept them silently and do nothing — which looks
+ * like it worked — any attempt to change one is named in the log.
+ *
+ * @param updated the settings the refreshed configuration implies for this
+ * listener
+ */
+ public void onSettingsChanged(GrpcListenerSettings updated) {
+ if (!Objects.equals(updated.getDefaultTopology(), defaultTopology)) {
+ this.defaultTopology = updated.getDefaultTopology();
+ LOG.reloadedPolicy(getName(), "default topology: "
+ + (defaultTopology == null ? "none" : defaultTopology));
+ }
+ if (!Objects.equals(updated.getIdentityRules(), identityRules)
+ || updated.getIdentityScanLimit() != identityScanLimit) {
+ // Record the new configuration either way, so a rule that cannot be parsed
+ // is reported once rather than on every refresh; correcting it changes the
+ // value again and is picked up normally.
+ this.identityRules = updated.getIdentityRules();
+ this.identityScanLimit = updated.getIdentityScanLimit();
+ try {
+ final IdentityRewritePolicy policy = createPolicy(updated);
+ this.messageInterceptor = createMessageInterceptor(policy);
+ LOG.reloadedPolicy(getName(), "identity rules: " + policy);
+ } catch (RuntimeException e) {
+ // Keep the running policy. Switching identity assertion off because
+ // someone mistyped a rule is the one outcome worse than ignoring the
+ // edit, and throwing here would escape into the configuration refresh
+ // task and stop it running again.
+ LOG.invalidIdentityRules(getName(), String.valueOf(updated.getIdentityRules()), e);
+ }
+ }
+ warnAboutRestartOnlyChanges(updated);
+ }
+
+ private void warnAboutRestartOnlyChanges(GrpcListenerSettings updated) {
+ final List changed = new ArrayList<>();
+ if (updated.getPort() != settings.getPort()) {
+ changed.add("port");
+ }
+ if (!Objects.equals(updated.getServiceRole(), settings.getServiceRole())) {
+ changed.add("service.role");
+ }
+ if (!Objects.equals(updated.getProtoServices(), settings.getProtoServices())) {
+ changed.add("proto.services");
+ }
+ if (updated.getMaxMessageSize() != settings.getMaxMessageSize()) {
+ changed.add("max.message.size");
+ }
+ if (updated.getMaxConcurrentCallsPerConnection() != settings.getMaxConcurrentCallsPerConnection()) {
+ changed.add("max.concurrent.calls.per.connection");
+ }
+ if (updated.getPermitKeepAliveTimeMillis() != settings.getPermitKeepAliveTimeMillis()) {
+ changed.add("permit.keepalive.time");
+ }
+ if (updated.isPermitKeepAliveWithoutCalls() != settings.isPermitKeepAliveWithoutCalls()) {
+ changed.add("permit.keepalive.without.calls");
+ }
+ if (updated.getChannelIdleTimeoutMillis() != settings.getChannelIdleTimeoutMillis()) {
+ changed.add("channel.idle.timeout");
+ }
+ if (updated.getDrainTimeoutMillis() != settings.getDrainTimeoutMillis()) {
+ changed.add("drain.timeout");
+ }
+ if (!Objects.equals(updated.getBackendTokenAlias(), settings.getBackendTokenAlias())) {
+ changed.add("backend.token.alias");
+ }
+ if (!Objects.equals(updated.getTopologyMetadataKey(), settings.getTopologyMetadataKey())) {
+ changed.add("topology.metadata.key");
+ }
+ if (updated.isSslEnabled() != settings.isSslEnabled()
+ || !Objects.equals(updated.getSslKeystorePath(), settings.getSslKeystorePath())
+ || !Objects.equals(updated.getSslKeystoreAlias(), settings.getSslKeystoreAlias())
+ || !Objects.equals(updated.getSslKeystorePasswordAlias(), settings.getSslKeystorePasswordAlias())
+ || !Objects.equals(updated.getSslKeystoreType(), settings.getSslKeystoreType())) {
+ changed.add("ssl.*");
+ }
+ if (!changed.isEmpty()) {
+ LOG.restartOnlyConfigChanged(getName(), String.join(", ", changed));
+ }
+ }
+
+ /** Exposed so tests can drive a configuration change without binding a port. */
+ MessageInterceptor currentMessageInterceptor() {
+ return messageInterceptor;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcGatewayMessages.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcGatewayMessages.java
new file mode 100644
index 0000000000..7266b4b12c
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcGatewayMessages.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import org.apache.knox.gateway.i18n.messages.Message;
+import org.apache.knox.gateway.i18n.messages.MessageLevel;
+import org.apache.knox.gateway.i18n.messages.Messages;
+import org.apache.knox.gateway.i18n.messages.StackTrace;
+
+/**
+ * Logging for the gRPC gateway listener.
+ *
+ * @since 3.0.0
+ */
+@Messages(logger = "org.apache.knox.gateway.grpc")
+public interface GrpcGatewayMessages {
+
+ @Message(level = MessageLevel.INFO, text = "Started {0} gRPC listener on port {1}")
+ void startedListener(String name, int port);
+
+ @Message(level = MessageLevel.INFO,
+ text = "The {0} listener is proxying [{1}]; identity rules: {2}")
+ void proxyingServices(String name, String protoServices, String identityRules);
+
+ @Message(level = MessageLevel.INFO, text = "Stopping {0} gRPC listener, draining for up to {1} ms")
+ void stoppingListener(String name, long drainTimeoutMillis);
+
+ @Message(level = MessageLevel.WARN,
+ text = "The {0} gRPC listener did not drain within {1} ms; terminating in-flight calls")
+ void drainTimedOut(String name, long drainTimeoutMillis);
+
+ @Message(level = MessageLevel.INFO, text = "Stopped {0} gRPC listener")
+ void stoppedListener(String name);
+
+ @Message(level = MessageLevel.ERROR, text = "Failed to start the {0} gRPC listener")
+ void failedToStartListener(String name, @StackTrace(level = MessageLevel.ERROR) Exception e);
+
+ @Message(level = MessageLevel.WARN, text = "Rejected unauthenticated gRPC call to {0}: {1}")
+ void authenticationFailed(String method, String reason);
+
+ @Message(level = MessageLevel.WARN,
+ text = "Denied gRPC call to {0} for user {1} in topology {2}: {3}")
+ void authorizationFailed(String method, String user, String topology, String reason);
+
+ @Message(level = MessageLevel.WARN, text = "Cannot route gRPC call to {0}: {1}")
+ void routingFailed(String method, String reason);
+
+ @Message(level = MessageLevel.DEBUG,
+ text = "Routing gRPC call to {0} for user {1} to topology {2} backend {3}")
+ void routingCall(String method, String user, String topology, String backend);
+
+ @Message(level = MessageLevel.DEBUG, text = "Opened backend gRPC channel to {0}")
+ void openedBackendChannel(String backend);
+
+ @Message(level = MessageLevel.DEBUG, text = "Closed backend gRPC channel to {0}")
+ void closedBackendChannel(String backend);
+
+ @Message(level = MessageLevel.ERROR, text = "Failed to build TLS context for the {0} gRPC listener")
+ void failedToBuildServerTls(String name, @StackTrace(level = MessageLevel.ERROR) Exception e);
+
+ @Message(level = MessageLevel.ERROR, text = "Failed to build TLS context for backend {0}")
+ void failedToBuildBackendTls(String backend, @StackTrace(level = MessageLevel.ERROR) Exception e);
+
+ @Message(level = MessageLevel.WARN,
+ text = "The {0} gRPC listener is running without TLS; bearer tokens will cross the network in clear text")
+ void listenerTlsDisabled(String name);
+
+ @Message(level = MessageLevel.WARN, text = "Could not resolve the backend token alias {0}")
+ void missingBackendTokenAlias(String alias);
+
+ // DEBUG, not WARN: a listener enabled ahead of any backend is a legitimate
+ // steady state. Deployments that switch the listener on by default and create a
+ // topology only when someone provisions a cluster would otherwise carry a
+ // warning forever, which is how warnings stop being read. The actionable signal
+ // for a genuine misconfiguration is the per-call rejection, which names the
+ // missing configuration directly.
+ @Message(level = MessageLevel.DEBUG,
+ text = "The {0} listener is running but no deployed topology declares a {1} service, "
+ + "so calls will be rejected until one does. Add a {1}"
+ + "... to a topology; topologies are picked up without a restart.")
+ void noTopologyDeclaresService(String name, String role);
+
+ @Message(level = MessageLevel.WARN,
+ text = "Denied gRPC call to {0} for user {1} in topology {2}: the method is not permitted there")
+ void methodDenied(String method, String user, String topology);
+
+ @Message(level = MessageLevel.INFO,
+ text = "Reloaded the {0} listener message policy: {1}")
+ void reloadedPolicy(String name, String policy);
+
+ @Message(level = MessageLevel.WARN,
+ text = "The {0} listener cannot apply the identity rewrite rules [{1}]; "
+ + "the previously configured rules remain in effect")
+ void invalidIdentityRules(String name, String rules,
+ @StackTrace(level = MessageLevel.WARN) Exception e);
+
+ @Message(level = MessageLevel.WARN,
+ text = "The {0} listener cannot apply changes to [{1}] without a gateway restart; "
+ + "the running values remain in effect")
+ void restartOnlyConfigChanged(String name, String properties);
+
+ @Message(level = MessageLevel.INFO,
+ text = "The {0} listener presents the TLS identity from {1}, alias {2}")
+ void listenerTlsIdentity(String name, String keystore, String alias);
+
+ @Message(level = MessageLevel.WARN,
+ text = "The {0} listeners cannot be reconfigured: [{1}] are not running listeners. "
+ + "Adding or removing a listener needs a gateway restart")
+ void listenerSetChanged(String name, String names);
+
+ @Message(level = MessageLevel.WARN,
+ text = "The refreshed {0} listener configuration could not be read; "
+ + "the running configuration remains in effect")
+ void invalidListenerConfiguration(String name,
+ @StackTrace(level = MessageLevel.WARN) Exception e);
+
+ @Message(level = MessageLevel.DEBUG, text = "{0}")
+ void debugLog(String message);
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListener.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListener.java
new file mode 100644
index 0000000000..71c499dcf9
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListener.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.GatewayConfigChangeListener;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.protocol.ProtocolListener;
+import org.apache.knox.gateway.services.GatewayServices;
+
+/**
+ * The gateway's gRPC listeners: one or more Netty servers on their own ports,
+ * wired to Knox's identity, token, topology and audit services.
+ *
+ * They are separate sockets rather than routes on the gateway's existing
+ * connectors because gRPC requires HTTP/2 negotiated over ALPN, and Knox's Jetty
+ * connectors are HTTP/1.1 only. Beyond the transport, the servlet pipeline could
+ * not carry these calls anyway: Servlet 3.1 has no trailer API, and gRPC puts
+ * {@code grpc-status} — and often structured error details — in trailers.
+ *
+ *
No schema, anywhere
+ * This compiles against no {@code .proto} file and no generated class. Calls are
+ * relayed as opaque bytes for whatever proto services a deployment names, and the
+ * one thing that needs to look inside a message — replacing the caller's claimed
+ * identity with the authenticated one — is done by field number on the wire.
+ * Field numbers are the part of a protobuf schema that cannot change without
+ * breaking every deployed client, so the gateway tracks no particular version of
+ * anything.
+ *
+ * What a deployment supplies is therefore configuration rather than code: which
+ * proto services to front, which Knox service role ties them to a topology,
+ * where the identity lives, and which RPCs to refuse. The protocol this was
+ * built for runs through the documentation, but only ever as values.
+ *
+ *
Why more than one
+ * Each listener routes to as many topologies as its clients select, so several
+ * listeners are not a way to separate policy — topology selection already does
+ * that. They exist because TLS identity is per-socket: a listener each lets a
+ * gateway answer for several hostnames with plain single-name certificates,
+ * which is the only option where the platform PKI cannot issue multi-name ones.
+ * A deployment that names no listeners runs exactly one, configured from the
+ * plain {@code gateway.grpc.*} properties.
+ */
+public class GrpcListener implements ProtocolListener, GatewayConfigChangeListener {
+
+ private static final GrpcGatewayMessages LOG = MessagesFactory.get(GrpcGatewayMessages.class);
+
+ private final List endpoints = new ArrayList<>();
+ /** Started listeners by name, so a configuration change reaches the right one. */
+ private final Map byName = new ConcurrentHashMap<>();
+
+ @Override
+ public String getName() {
+ return "gRPC";
+ }
+
+ @Override
+ public boolean isEnabled(GatewayConfig config) {
+ return config.isGrpcEnabled();
+ }
+
+ /**
+ * Starts every configured listener.
+ *
+ * One failing stops the gateway, and the listeners already started are stopped
+ * first: a gateway that came up serving half the endpoints an operator
+ * configured would be worse than one that refused to come up at all, because
+ * the missing half looks like a network fault from the outside.
+ */
+ @Override
+ public void start(GatewayConfig config, GatewayServices services) throws Exception {
+ for (GrpcListenerSettings settings : GrpcListenerSettingsFactory.create(config)) {
+ final GrpcEndpoint endpoint = new GrpcEndpoint(settings);
+ try {
+ endpoint.start(config, services);
+ } catch (Exception e) {
+ stop();
+ throw e;
+ }
+ endpoints.add(endpoint);
+ byName.put(settings.getName(), endpoint);
+ }
+ }
+
+ @Override
+ public void stop() {
+ for (GrpcEndpoint endpoint : endpoints) {
+ endpoint.stop();
+ }
+ endpoints.clear();
+ byName.clear();
+ }
+
+ @Override
+ public void reload() {
+ for (GrpcEndpoint endpoint : endpoints) {
+ endpoint.reload();
+ }
+ }
+
+ /**
+ * Hands each running listener the settings the refreshed configuration implies
+ * for it.
+ *
+ * Which listeners exist is fixed at startup, like whether the feature runs at
+ * all: a name added or removed in a running gateway is reported rather than
+ * acted on, since binding or releasing a port is exactly the kind of change an
+ * operator should schedule.
+ */
+ @Override
+ public void onGatewayConfigChanged(GatewayConfig config) {
+ final List updated;
+ try {
+ updated = GrpcListenerSettingsFactory.create(config);
+ } catch (RuntimeException e) {
+ LOG.invalidListenerConfiguration(getName(), e);
+ return;
+ }
+ final List unknown = new ArrayList<>();
+ for (GrpcListenerSettings settings : updated) {
+ final GrpcEndpoint endpoint = byName.get(settings.getName());
+ if (endpoint == null) {
+ unknown.add(settings.getName());
+ } else {
+ endpoint.onSettingsChanged(settings);
+ }
+ }
+ if (!unknown.isEmpty()) {
+ LOG.listenerSetChanged(getName(), String.join(", ", unknown));
+ }
+ }
+
+ /**
+ * @return the port of the first listener, for the gateway's startup log; see
+ * {@link #getPorts()} for all of them
+ */
+ @Override
+ public int getPort() {
+ return endpoints.isEmpty() ? -1 : endpoints.get(0).getPort();
+ }
+
+ @Override
+ public List getPorts() {
+ final List ports = new ArrayList<>(endpoints.size());
+ for (GrpcEndpoint endpoint : endpoints) {
+ ports.add(endpoint.getPort());
+ }
+ return Collections.unmodifiableList(ports);
+ }
+
+ /** Exposed so tests can inspect what a given configuration would start. */
+ List currentEndpoints() {
+ return Collections.unmodifiableList(endpoints);
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListenerSettings.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListenerSettings.java
new file mode 100644
index 0000000000..2a52895fb2
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListenerSettings.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Everything one gRPC listener needs in order to run: its transport limits, what
+ * it fronts, where the identity goes, and the TLS identity it presents.
+ *
+ * Plain values rather than reads against {@code GatewayConfig}, so that a
+ * listener can be built and tested without a gateway configuration to hand — and
+ * so that a gateway running several listeners has one of these per listener
+ * rather than each of them reaching back into shared configuration.
+ *
+ * The limits here are the listener's DoS surface. A new socket accepting 128 MB
+ * messages on long-lived streams needs message-size, stream-count and
+ * keepalive-abuse bounds configured from the start, not added after the first
+ * incident.
+ */
+public class GrpcListenerSettings {
+
+ private String name = "grpc";
+ private int port;
+ private int maxMessageSize = 134217728;
+ private int maxConcurrentCallsPerConnection = 1000;
+ private long permitKeepAliveTimeMillis = 10000L;
+ private boolean permitKeepAliveWithoutCalls = true;
+ private long channelIdleTimeoutMillis = 1800000L;
+ private long drainTimeoutMillis = 30000L;
+ private String backendTokenAlias;
+ private String topologyMetadataKey = GrpcMetadataKeys.DEFAULT_TOPOLOGY_KEY;
+ private String serviceRole = "GRPC";
+ private Set protoServices = Collections.emptySet();
+ private String defaultTopology;
+ private String identityRules;
+ private int identityScanLimit = 131072;
+ private String methodsDeny;
+ private String methodsAllow;
+ private boolean sslEnabled = true;
+ private String sslKeystorePath;
+ private String sslKeystoreType = "PKCS12";
+ private String sslKeystoreAlias;
+ private String sslKeystorePasswordAlias;
+
+ /**
+ * The Knox service role this listener resolves backends under, and the prefix
+ * for its ACL and method-list parameters in topology XML.
+ *
+ * @return the service role
+ */
+ public String getServiceRole() {
+ return serviceRole;
+ }
+
+ public GrpcListenerSettings serviceRole(String value) {
+ this.serviceRole = value;
+ return this;
+ }
+
+ /**
+ * The fully qualified proto service names this listener fronts. Anything else
+ * is answered {@code UNIMPLEMENTED}.
+ *
+ * @return the proxied service names
+ */
+ public Set getProtoServices() {
+ return protoServices;
+ }
+
+ public GrpcListenerSettings protoServices(Set value) {
+ this.protoServices = value == null ? Collections.emptySet() : value;
+ return this;
+ }
+
+ /** @return the topology to use when a client selects none, or null */
+ public String getDefaultTopology() {
+ return defaultTopology;
+ }
+
+ public GrpcListenerSettings defaultTopology(String value) {
+ this.defaultTopology = value;
+ return this;
+ }
+
+ /** @return the identity rewrite rules as configured, or null for none */
+ public String getIdentityRules() {
+ return identityRules;
+ }
+
+ public GrpcListenerSettings identityRules(String value) {
+ this.identityRules = value;
+ return this;
+ }
+
+ public int getIdentityScanLimit() {
+ return identityScanLimit;
+ }
+
+ public GrpcListenerSettings identityScanLimit(int value) {
+ this.identityScanLimit = value;
+ return this;
+ }
+
+ public String getMethodsDeny() {
+ return methodsDeny;
+ }
+
+ public GrpcListenerSettings methodsDeny(String value) {
+ this.methodsDeny = value;
+ return this;
+ }
+
+ public String getMethodsAllow() {
+ return methodsAllow;
+ }
+
+ public GrpcListenerSettings methodsAllow(String value) {
+ this.methodsAllow = value;
+ return this;
+ }
+
+ /** @return whether this listener presents TLS */
+ public boolean isSslEnabled() {
+ return sslEnabled;
+ }
+
+ public GrpcListenerSettings sslEnabled(boolean value) {
+ this.sslEnabled = value;
+ return this;
+ }
+
+ /**
+ * A keystore holding this listener's own server certificate, or null to present
+ * the gateway identity Jetty also presents.
+ *
+ * Distinct key material per listener is what lets several listeners answer for
+ * several hostnames on one gateway, each with a plain single-name certificate.
+ * That matters where the platform PKI cannot issue multi-name (SAN or wildcard)
+ * certificates, which would otherwise be the only way to serve more than one
+ * name from one endpoint.
+ *
+ * @return the keystore path, or null for the gateway identity
+ */
+ public String getSslKeystorePath() {
+ return sslKeystorePath;
+ }
+
+ public GrpcListenerSettings sslKeystorePath(String value) {
+ this.sslKeystorePath = value;
+ return this;
+ }
+
+ public String getSslKeystoreType() {
+ return sslKeystoreType;
+ }
+
+ public GrpcListenerSettings sslKeystoreType(String value) {
+ this.sslKeystoreType = value;
+ return this;
+ }
+
+ /** @return the entry within the keystore to present, or null for the sole entry */
+ public String getSslKeystoreAlias() {
+ return sslKeystoreAlias;
+ }
+
+ public GrpcListenerSettings sslKeystoreAlias(String value) {
+ this.sslKeystoreAlias = value;
+ return this;
+ }
+
+ /** @return the alias holding the keystore password, or null for the gateway's */
+ public String getSslKeystorePasswordAlias() {
+ return sslKeystorePasswordAlias;
+ }
+
+ public GrpcListenerSettings sslKeystorePasswordAlias(String value) {
+ this.sslKeystorePasswordAlias = value;
+ return this;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public GrpcListenerSettings name(String value) {
+ this.name = value;
+ return this;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public GrpcListenerSettings port(int value) {
+ this.port = value;
+ return this;
+ }
+
+ public int getMaxMessageSize() {
+ return maxMessageSize;
+ }
+
+ public GrpcListenerSettings maxMessageSize(int value) {
+ this.maxMessageSize = value;
+ return this;
+ }
+
+ public int getMaxConcurrentCallsPerConnection() {
+ return maxConcurrentCallsPerConnection;
+ }
+
+ public GrpcListenerSettings maxConcurrentCallsPerConnection(int value) {
+ this.maxConcurrentCallsPerConnection = value;
+ return this;
+ }
+
+ public long getPermitKeepAliveTimeMillis() {
+ return permitKeepAliveTimeMillis;
+ }
+
+ public GrpcListenerSettings permitKeepAliveTimeMillis(long value) {
+ this.permitKeepAliveTimeMillis = value;
+ return this;
+ }
+
+ public boolean isPermitKeepAliveWithoutCalls() {
+ return permitKeepAliveWithoutCalls;
+ }
+
+ public GrpcListenerSettings permitKeepAliveWithoutCalls(boolean value) {
+ this.permitKeepAliveWithoutCalls = value;
+ return this;
+ }
+
+ public long getChannelIdleTimeoutMillis() {
+ return channelIdleTimeoutMillis;
+ }
+
+ public GrpcListenerSettings channelIdleTimeoutMillis(long value) {
+ this.channelIdleTimeoutMillis = value;
+ return this;
+ }
+
+ public long getDrainTimeoutMillis() {
+ return drainTimeoutMillis;
+ }
+
+ public GrpcListenerSettings drainTimeoutMillis(long value) {
+ this.drainTimeoutMillis = value;
+ return this;
+ }
+
+ /**
+ * The metadata entry a client uses to select a topology. It is also the
+ * connection-string parameter users write, so a deployment may prefer a name
+ * that describes the choice rather than the gateway making it.
+ *
+ * @return the metadata key name
+ */
+ public String getTopologyMetadataKey() {
+ return topologyMetadataKey;
+ }
+
+ public GrpcListenerSettings topologyMetadataKey(String value) {
+ this.topologyMetadataKey = value;
+ return this;
+ }
+
+ public String getBackendTokenAlias() {
+ return backendTokenAlias;
+ }
+
+ public GrpcListenerSettings backendTokenAlias(String value) {
+ this.backendTokenAlias = value;
+ return this;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListenerSettingsFactory.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListenerSettingsFactory.java
new file mode 100644
index 0000000000..2ba34f50fa
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcListenerSettingsFactory.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+
+/**
+ * Turns gateway configuration into one {@link GrpcListenerSettings} per listener.
+ *
+ *
Why more than one listener
+ * A listener is a transport endpoint, not a policy boundary: each one still
+ * routes to as many topologies as its clients select, exactly as a single
+ * listener does. What separates them is the socket and the certificate on it.
+ *
+ * That is worth having because TLS identity is per-socket. Serving several
+ * hostnames from one endpoint needs one certificate naming all of them, and a
+ * platform PKI that cannot issue multi-name (SAN or wildcard) certificates
+ * cannot produce one. Several listeners, each presenting a plain single-name
+ * certificate for the hostname its clients dial, is the way to serve those
+ * clients without that certificate.
+ *
+ *
How a listener is configured
+ * {@code gateway.grpc.listener.names} lists them. Every other property is read
+ * from {@code gateway.grpc..} when that listener sets it, and
+ * from the plain {@code gateway.grpc.} otherwise — so shared settings
+ * are written once and only the differences are repeated.
+ *
+ * Naming no listeners yields exactly one, configured entirely from the plain
+ * properties. That is the ordinary deployment, and it means the multi-listener
+ * machinery costs nothing to a gateway that does not use it.
+ */
+public final class GrpcListenerSettingsFactory {
+
+ private static final Pattern VALID_NAME = Pattern.compile("[a-z0-9][a-z0-9_-]*");
+
+ /**
+ * First segments of the plain properties. A listener may not be named after
+ * one, because {@code gateway.grpc.identity.rules} and a listener called
+ * {@code identity} would occupy the same configuration namespace.
+ */
+ private static final Set RESERVED_NAMES = Collections.unmodifiableSet(
+ new HashSet<>(Arrays.asList("enabled", "port", "service", "proto", "identity", "default",
+ "topology", "methods", "max", "permit", "channel", "drain", "backend", "ssl",
+ "listener")));
+
+ private GrpcListenerSettingsFactory() {
+ }
+
+ /**
+ * Builds the settings for every configured listener.
+ *
+ * @param config the gateway configuration
+ * @return one settings object per listener, in configured order; never empty
+ * @throws IllegalArgumentException if a listener name is unusable, duplicated,
+ * or two listeners would bind the same port
+ */
+ public static List create(GatewayConfig config) {
+ final List names = config.getGrpcListenerNames();
+ final List settings = new ArrayList<>();
+
+ if (names == null || names.isEmpty()) {
+ settings.add(build(config, null, Collections.emptyMap()));
+ } else {
+ final Set seen = new LinkedHashSet<>();
+ for (String name : names) {
+ final String listener = validate(name, seen);
+ settings.add(build(config, listener, config.getGrpcListenerConfig(listener)));
+ }
+ }
+ requireDistinctPorts(settings);
+ return Collections.unmodifiableList(settings);
+ }
+
+ private static String validate(String name, Set seen) {
+ final String trimmed = name == null ? "" : name.trim();
+ if (!VALID_NAME.matcher(trimmed).matches()) {
+ throw new IllegalArgumentException("A gRPC listener name may contain only a-z, 0-9, '-' and"
+ + " '_', and must start with a letter or digit, got: " + name);
+ }
+ if (RESERVED_NAMES.contains(trimmed)) {
+ throw new IllegalArgumentException("'" + trimmed + "' cannot be a gRPC listener name because"
+ + " gateway.grpc." + trimmed + ".* is already a configuration property");
+ }
+ if (!seen.add(trimmed)) {
+ throw new IllegalArgumentException("Duplicate gRPC listener name: " + trimmed);
+ }
+ return trimmed;
+ }
+
+ /**
+ * Two listeners on one port is a startup failure rather than a race to bind:
+ * whichever lost would fail with an address-in-use error naming neither of the
+ * listeners involved.
+ */
+ private static void requireDistinctPorts(List settings) {
+ final Map byPort = new java.util.HashMap<>();
+ for (GrpcListenerSettings listener : settings) {
+ final String other = byPort.put(listener.getPort(), listener.getName());
+ if (other != null) {
+ throw new IllegalArgumentException("gRPC listeners '" + other + "' and '"
+ + listener.getName() + "' are both configured on port " + listener.getPort());
+ }
+ }
+ }
+
+ private static GrpcListenerSettings build(GatewayConfig config, String name,
+ Map overrides) {
+ final String serviceRole = string(overrides, "service.role", config.getGrpcServiceRole());
+ return new GrpcListenerSettings()
+ // An unnamed listener is named after its service role, so a single-listener
+ // deployment reads in the log for the thing being fronted rather than for
+ // the transport. A named one uses the name the operator chose.
+ .name(name == null ? serviceRole : name)
+ .serviceRole(serviceRole)
+ .port(integer(overrides, "port", config.getGrpcPort()))
+ .protoServices(protoServices(string(overrides, "proto.services", config.getGrpcProtoServices())))
+ .defaultTopology(string(overrides, "default.topology", config.getGrpcDefaultTopology()))
+ .topologyMetadataKey(string(overrides, "topology.metadata.key", config.getGrpcTopologyMetadataKey()))
+ .identityRules(string(overrides, "identity.rules", config.getGrpcIdentityRules()))
+ .identityScanLimit(integer(overrides, "identity.scan.limit", config.getGrpcIdentityScanLimit()))
+ .methodsDeny(string(overrides, "methods.deny", config.getGrpcMethodsDeny()))
+ .methodsAllow(string(overrides, "methods.allow", config.getGrpcMethodsAllow()))
+ .maxMessageSize(integer(overrides, "max.message.size", config.getGrpcMaxMessageSize()))
+ .maxConcurrentCallsPerConnection(integer(overrides, "max.concurrent.calls.per.connection",
+ config.getGrpcMaxConcurrentCallsPerConnection()))
+ .permitKeepAliveTimeMillis(longValue(overrides, "permit.keepalive.time",
+ config.getGrpcPermitKeepAliveTime()))
+ .permitKeepAliveWithoutCalls(bool(overrides, "permit.keepalive.without.calls",
+ config.isGrpcPermitKeepAliveWithoutCalls()))
+ .channelIdleTimeoutMillis(longValue(overrides, "channel.idle.timeout",
+ config.getGrpcChannelIdleTimeout()))
+ .drainTimeoutMillis(longValue(overrides, "drain.timeout", config.getGrpcDrainTimeout()))
+ .backendTokenAlias(string(overrides, "backend.token.alias", config.getGrpcBackendTokenAlias()))
+ .sslEnabled(bool(overrides, "ssl.enabled", config.isSSLEnabled()))
+ .sslKeystorePath(string(overrides, "ssl.keystore.path", null))
+ .sslKeystoreType(string(overrides, "ssl.keystore.type", "PKCS12"))
+ .sslKeystoreAlias(string(overrides, "ssl.keystore.alias", null))
+ .sslKeystorePasswordAlias(string(overrides, "ssl.keystore.password.alias", null));
+ }
+
+ static Set protoServices(String configured) {
+ if (configured == null || configured.trim().isEmpty()) {
+ return Collections.emptySet();
+ }
+ final Set names = new LinkedHashSet<>();
+ for (String name : configured.trim().split("\\s*,\\s*")) {
+ if (!name.isEmpty()) {
+ names.add(name);
+ }
+ }
+ return Collections.unmodifiableSet(names);
+ }
+
+ private static String string(Map overrides, String key, String fallback) {
+ final String value = overrides.get(key);
+ return value == null || value.trim().isEmpty() ? fallback : value.trim();
+ }
+
+ private static int integer(Map overrides, String key, int fallback) {
+ final String value = string(overrides, key, null);
+ return value == null ? fallback : parse(key, value).intValue();
+ }
+
+ private static long longValue(Map overrides, String key, long fallback) {
+ final String value = string(overrides, key, null);
+ return value == null ? fallback : parse(key, value);
+ }
+
+ private static boolean bool(Map overrides, String key, boolean fallback) {
+ final String value = string(overrides, key, null);
+ return value == null ? fallback : Boolean.parseBoolean(value);
+ }
+
+ private static Long parse(String key, String value) {
+ try {
+ return Long.valueOf(value);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ "gRPC listener property '" + key + "' must be a number, got: " + value, e);
+ }
+ }
+
+ /** @return the name of the property a listener would set to override this one */
+ static String propertyName(String listener, String property) {
+ return listener == null
+ ? "gateway.grpc." + property
+ : String.format(Locale.ROOT, "gateway.grpc.%s.%s", listener, property);
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcMetadataKeys.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcMetadataKeys.java
new file mode 100644
index 0000000000..255944b1d6
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/GrpcMetadataKeys.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import io.grpc.Metadata;
+
+/**
+ * Call metadata keys the gateway reads or writes.
+ *
+ * Everything here is expressible in a vanilla {@code sc://} connection string.
+ * The {@code token=} parameter becomes {@link #AUTHORIZATION}, and any parameter
+ * the client does not recognise — {@code knox-topology=analytics}, say — is sent
+ * verbatim as metadata on every request. That is what lets clients select a
+ * topology despite gRPC forbidding a path component in the connection URL.
+ */
+public final class GrpcMetadataKeys {
+
+ /** Carries the Knox-issued bearer token, set by the client's {@code token=} parameter. */
+ public static final Metadata.Key AUTHORIZATION =
+ Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
+
+ /**
+ * The default name of the metadata entry that selects a topology. Deployments
+ * can rename it, since it appears verbatim in the connection strings users
+ * write and need not advertise which gateway is reading it.
+ */
+ public static final String DEFAULT_TOPOLOGY_KEY = "knox-topology";
+
+ private static final Pattern VALID_KEY = Pattern.compile("[a-z0-9_.-]+");
+ private static final String BINARY_SUFFIX = "-bin";
+
+ public static final String BEARER_PREFIX = "Bearer ";
+
+ private GrpcMetadataKeys() {
+ }
+
+ /**
+ * Builds the metadata key used to select a topology.
+ *
+ * gRPC restricts header names to lowercase ASCII letters, digits and
+ * {@code -_.}, and reserves the {@code -bin} suffix for binary values. An
+ * invalid name would otherwise surface as an obscure failure from deep inside
+ * the transport, so it is rejected here with an explanation instead.
+ *
+ * @param name the configured metadata key name
+ * @return the metadata key to read topology selection from
+ * @throws IllegalArgumentException if the name is not usable as a gRPC metadata key
+ */
+ public static Metadata.Key topologyKey(String name) {
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "The topology metadata key name must not be empty");
+ }
+ final String trimmed = name.trim();
+ if (!trimmed.equals(trimmed.toLowerCase(Locale.ROOT))) {
+ throw new IllegalArgumentException(
+ "gRPC metadata key names are case-insensitive and must be given in lower case: " + name);
+ }
+ if (!VALID_KEY.matcher(trimmed).matches()) {
+ throw new IllegalArgumentException(
+ "The topology metadata key name may contain only a-z, 0-9, '-', '_' and '.': " + name);
+ }
+ if (trimmed.endsWith(BINARY_SUFFIX)) {
+ throw new IllegalArgumentException(
+ "gRPC reserves the '-bin' suffix for binary metadata; the topology key carries text: " + name);
+ }
+ if (AUTHORIZATION.name().equals(trimmed)) {
+ throw new IllegalArgumentException(
+ "The topology metadata key must not be 'authorization', which carries the bearer token");
+ }
+ return Metadata.Key.of(trimmed, Metadata.ASCII_STRING_MARSHALLER);
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/HeaderRewriter.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/HeaderRewriter.java
new file mode 100644
index 0000000000..90b1ce82a0
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/HeaderRewriter.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import io.grpc.Metadata;
+
+/**
+ * Adjusts call metadata in place before it is forwarded to the backend.
+ *
+ * The two legs have separate credentials: the client's bearer token
+ * authenticates the user to Knox and must not travel further, while the backend
+ * gets Knox's own pre-shared token if one is configured. Knox-internal routing
+ * metadata is dropped here too.
+ */
+@FunctionalInterface
+public interface HeaderRewriter {
+
+ /**
+ * Rewrites the metadata that will be sent to the backend.
+ *
+ * @param headers the client's call metadata, modified in place
+ */
+ void rewrite(Metadata headers);
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityAssertingInterceptor.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityAssertingInterceptor.java
new file mode 100644
index 0000000000..2280c6efc9
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityAssertingInterceptor.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import io.grpc.Status;
+
+/**
+ * Replaces the caller's claimed identity with the authenticated one, working
+ * directly on the wire format.
+ *
+ * This is the whole reason the gateway looks inside messages at all. Protocols
+ * in this family commonly trust a client-asserted identity field: the
+ * client states who it is and the server believes it. Such a field typically
+ * keys the server-side session cache, so leaving it alone would let one caller
+ * collide with — or attach to — another's session simply by claiming their name.
+ * Overwriting it is what makes sessions isolated and the audit trail meaningful.
+ *
+ * No schema is needed to do it. The identity lives at field numbers named by an
+ * {@link IdentityRewritePolicy}, and every other byte of the message is copied
+ * through verbatim — including fields from a newer protocol version this build
+ * has never heard of, which are not merely preserved but never even decoded.
+ *
+ * Three cases are refused rather than forwarded: a message that cannot be
+ * parsed, a message whose shape contradicts the configured rules, and a message
+ * whose identity fields lie beyond the policy's scan limit. All three share a
+ * reason — if the identity cannot be replaced everywhere the rules say it lives,
+ * then the caller's own claim would travel on somewhere, which is precisely what
+ * this exists to prevent.
+ */
+public class IdentityAssertingInterceptor implements MessageInterceptor {
+
+ private final IdentityRewritePolicy policy;
+ private final PrincipalSource principalSource;
+
+ /** Supplies the principal for the call in flight. */
+ @FunctionalInterface
+ public interface PrincipalSource {
+ String currentPrincipal();
+ }
+
+ public IdentityAssertingInterceptor(IdentityRewritePolicy policy, PrincipalSource principalSource) {
+ this.policy = policy;
+ this.principalSource = principalSource;
+ }
+
+ /** Uses the principal the authentication interceptor put in the call context. */
+ public IdentityAssertingInterceptor(IdentityRewritePolicy policy) {
+ this(policy, () -> {
+ final GrpcCallContext callContext = GrpcCallContext.current();
+ return callContext == null ? null : callContext.getPrincipal();
+ });
+ }
+
+ @Override
+ public byte[] intercept(byte[] message) {
+ final String principal = principalSource.currentPrincipal();
+ if (principal == null || principal.isEmpty()) {
+ // Authentication runs before any handler, so this cannot happen unless the
+ // interceptor chain was assembled wrongly. Forwarding would send the
+ // client's own claim through untouched.
+ throw Status.INTERNAL
+ .withDescription("No authenticated principal available for identity assertion")
+ .asRuntimeException();
+ }
+ try {
+ return assertIdentity(message, principal);
+ } catch (ProtoWire.MalformedMessageException e) {
+ throw Status.INVALID_ARGUMENT
+ .withDescription("Request message is not a well-formed protobuf message")
+ .withCause(e)
+ .asRuntimeException();
+ } catch (UnassertableMessageException e) {
+ throw Status.INVALID_ARGUMENT
+ .withDescription(e.getMessage())
+ .withCause(e)
+ .asRuntimeException();
+ }
+ }
+
+ /**
+ * Returns the message with every configured identity field replaced.
+ *
+ * @param message the request as received
+ * @param principal the authenticated principal
+ * @return the request to forward
+ * @throws ProtoWire.MalformedMessageException if the message cannot be parsed
+ * @throws UnassertableMessageException if the message's shape contradicts the
+ * rules, or an identity field lies beyond the scan limit
+ */
+ public byte[] assertIdentity(byte[] message, String principal) {
+ if (policy.isEmpty()) {
+ return message;
+ }
+ return rewrite(message, policy.root(), principal, 0);
+ }
+
+ /**
+ * Rewrites one message — the request itself, or a nested message a rule
+ * descends into.
+ *
+ * @param buffer the bytes of this message
+ * @param node the rules that apply at this depth
+ * @param principal the authenticated principal
+ * @param baseOffset where {@code buffer} begins within the request as a whole,
+ * so the scan limit is measured against the message the client sent
+ * rather than against each nested message separately
+ * @return the rewritten bytes
+ */
+ private byte[] rewrite(byte[] buffer, IdentityRewritePolicy.Node node, String principal,
+ int baseOffset) {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(buffer.length + 32);
+ final Set rewritten = new HashSet<>();
+
+ int pos = 0;
+ while (pos < buffer.length) {
+ final int recordStart = pos;
+ final ProtoWire.Varint tag = ProtoWire.readVarint(buffer, pos);
+ pos = tag.end();
+ final int field = ProtoWire.fieldNumber(tag.value());
+ final int wire = ProtoWire.wireType(tag.value());
+ final int[] bounds = ProtoWire.valueBounds(buffer, pos, wire);
+
+ final IdentityRewritePolicy.Node child = node.child(field);
+ if (child == null) {
+ out.write(buffer, recordStart, bounds[1] - recordStart);
+ } else {
+ requireWithinScanLimit(field, baseOffset + bounds[1]);
+ requireLengthDelimited(field, wire, child);
+ if (child.isLeaf()) {
+ write(out, field, child, principal);
+ } else {
+ final byte[] nested = ProtoWire.slice(buffer, bounds[0], bounds[1]);
+ ProtoWire.writeLengthDelimited(out, field,
+ rewrite(nested, child, principal, baseOffset + bounds[0]));
+ }
+ // Every occurrence is rewritten, not just the first: protobuf merges
+ // repeats, so one left alone could override the one we asserted.
+ rewritten.add(field);
+ }
+ pos = bounds[1];
+ }
+
+ // A client that sent no identity at all still gets one, at whatever depth the
+ // rules put it; the backend must never see a request whose identity Knox did
+ // not put there. Appending is safe however large the message is, because
+ // nothing was found to be overridden by — the walk above covered every byte.
+ for (Map.Entry entry : node.children().entrySet()) {
+ if (rewritten.contains(entry.getKey())) {
+ continue;
+ }
+ final IdentityRewritePolicy.Node child = entry.getValue();
+ if (child.isLeaf()) {
+ write(out, entry.getKey(), child, principal);
+ } else {
+ ProtoWire.writeLengthDelimited(out, entry.getKey(),
+ rewrite(new byte[0], child, principal, baseOffset));
+ }
+ }
+ return out.toByteArray();
+ }
+
+ private static void write(ByteArrayOutputStream out, int field,
+ IdentityRewritePolicy.Node leaf, String principal) {
+ ProtoWire.writeLengthDelimited(out, field,
+ leaf.subject().resolve(principal).getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Refuses a field that ends beyond the scan limit.
+ *
+ * Measured against the end rather than the start, so the bound covers what the
+ * rewrite has to copy: a container beginning in the first few bytes but running
+ * to a hundred megabytes costs as much as one that begins late.
+ */
+ private void requireWithinScanLimit(int field, int endOffset) {
+ if (endOffset > policy.getScanLimit()) {
+ throw new UnassertableMessageException("Identity field " + field
+ + " extends past the first " + policy.getScanLimit()
+ + " bytes of the request, so the authenticated identity cannot be asserted over it");
+ }
+ }
+
+ /**
+ * Refuses a field whose wire type contradicts the rules. A rule expects a
+ * string to overwrite or a message to descend into, and both are
+ * length-delimited; anything else means the configuration does not describe
+ * this protocol. Skipping it quietly would forward the caller's own claim.
+ */
+ private static void requireLengthDelimited(int field, int wire,
+ IdentityRewritePolicy.Node node) {
+ if (wire != ProtoWire.WIRETYPE_LENGTH_DELIMITED) {
+ throw new UnassertableMessageException("Identity field " + field + " is a "
+ + (node.isLeaf() ? "value to replace" : "message to descend into")
+ + " but arrived with wire type " + wire
+ + "; the configured identity rules do not describe this message");
+ }
+ }
+
+ /**
+ * Signals a message the configured rules cannot be applied to in full. Distinct
+ * from malformed input: the bytes parse, but their shape and the configuration
+ * disagree, or the identity sits further into the request than the policy
+ * allows.
+ */
+ public static class UnassertableMessageException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public UnassertableMessageException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityRewritePolicy.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityRewritePolicy.java
new file mode 100644
index 0000000000..4e488c4ec2
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityRewritePolicy.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The set of identity rewrite rules in force, compiled for one pass over a
+ * message.
+ *
+ * Zero rules is the ordinary case for a protocol that carries no identity: the
+ * relay is then a pure pipe. Where there are rules, they are compiled into a
+ * tree keyed by field number, so rules sharing a prefix — {@code 2.1} and
+ * {@code 2.2}, say — descend into that container once rather than once each.
+ *
+ *
The scan limit
+ * Every field a rule touches must lie wholly within the first
+ * {@link #getScanLimit()} bytes of the message. This bounds what identity
+ * assertion can be made to do: rewriting a nested field means slicing it out and
+ * rebuilding it, so without a limit a client could put a hundred megabytes
+ * inside the identity container and make the gateway copy it several times over.
+ *
+ * It is a rejection rather than a truncation, and that is the security-relevant
+ * part. Giving up on a rule that sits beyond the limit — and synthesising a
+ * fresh identity instead — would leave the caller's own claim in the message
+ * behind ours, where protobuf's last-wins merge semantics would let it take
+ * effect. A message we cannot fully assert over is one we must not forward.
+ *
+ * Messages are not otherwise constrained: a large request whose identity sits at
+ * the front, which is what generated serializers emit, passes regardless of its
+ * total size.
+ */
+public final class IdentityRewritePolicy {
+
+ /**
+ * 128 KiB. Comfortably past any identity container a real protocol declares,
+ * while keeping the worst-case rewrite cost of a 128 MB message the same as
+ * that of a small one.
+ */
+ public static final int DEFAULT_SCAN_LIMIT = 131072;
+
+ private static final IdentityRewritePolicy NONE =
+ new IdentityRewritePolicy(Collections.emptyList(), DEFAULT_SCAN_LIMIT, new Node());
+
+ private final List rules;
+ private final int scanLimit;
+ private final Node root;
+
+ private IdentityRewritePolicy(List rules, int scanLimit, Node root) {
+ this.rules = rules;
+ this.scanLimit = scanLimit;
+ this.root = root;
+ }
+
+ /** @return a policy that rewrites nothing */
+ public static IdentityRewritePolicy none() {
+ return NONE;
+ }
+
+ /**
+ * Parses a comma-separated list of rules.
+ *
+ * @param configuredRules for example {@code 2.1=principal, 2.2=principal};
+ * null or empty yields a policy that rewrites nothing
+ * @param scanLimit the maximum offset, in bytes, at which a rewritten field may
+ * end
+ * @return the compiled policy
+ * @throws IllegalArgumentException if a rule is malformed, if two rules
+ * collide, or if the scan limit is not positive
+ */
+ public static IdentityRewritePolicy parse(String configuredRules, int scanLimit) {
+ if (configuredRules == null || configuredRules.trim().isEmpty()) {
+ return NONE;
+ }
+ if (scanLimit < 1) {
+ throw new IllegalArgumentException("The identity scan limit must be positive, got: " + scanLimit);
+ }
+ final List parsed = new ArrayList<>();
+ final Node newRoot = new Node();
+ for (String entry : configuredRules.trim().split("\\s*,\\s*")) {
+ if (entry.isEmpty()) {
+ continue;
+ }
+ final IdentityRewriteRule rule = IdentityRewriteRule.parse(entry);
+ add(newRoot, rule);
+ parsed.add(rule);
+ }
+ if (parsed.isEmpty()) {
+ return NONE;
+ }
+ return new IdentityRewritePolicy(Collections.unmodifiableList(parsed), scanLimit, newRoot);
+ }
+
+ /**
+ * Inserts a rule into the tree, refusing the two ways rules can contradict each
+ * other: writing the same place twice, and writing a value at a field another
+ * rule descends through.
+ */
+ private static void add(Node root, IdentityRewriteRule rule) {
+ Node current = root;
+ for (final int field : rule.getPath()) {
+ if (current.subject != null) {
+ throw new IllegalArgumentException("Rule " + rule
+ + " descends through field " + field + ", which another rule writes a value to");
+ }
+ Node child = current.children.get(field);
+ if (child == null) {
+ child = new Node();
+ current.children.put(field, child);
+ }
+ current = child;
+ }
+ if (current.subject != null || !current.children.isEmpty()) {
+ throw new IllegalArgumentException("Rule " + rule + " collides with an earlier rule");
+ }
+ current.subject = rule.getSubject();
+ }
+
+ /** @return true if this policy rewrites nothing, so the relay is a pure pipe */
+ public boolean isEmpty() {
+ return rules.isEmpty();
+ }
+
+ /**
+ * @return the maximum offset, in bytes, at which a rewritten field may end
+ */
+ public int getScanLimit() {
+ return scanLimit;
+ }
+
+ public List getRules() {
+ return rules;
+ }
+
+ Node root() {
+ return root;
+ }
+
+ @Override
+ public String toString() {
+ if (rules.isEmpty()) {
+ return "none";
+ }
+ final StringBuilder text = new StringBuilder(48);
+ for (IdentityRewriteRule rule : rules) {
+ if (text.length() > 0) {
+ text.append(',');
+ }
+ text.append(rule);
+ }
+ return text.append(" (scan limit ").append(scanLimit).append(" bytes)").toString();
+ }
+
+ /**
+ * One field number in the compiled tree. A node either writes a value
+ * ({@code subject} set, a leaf) or is descended through ({@code children}
+ * populated) — {@link #add} refuses anything that would be both.
+ */
+ static final class Node {
+ /** Insertion-ordered so synthesised fields come out in the order configured. */
+ private final Map children = new LinkedHashMap<>();
+ private IdentitySubject subject;
+
+ Node child(int field) {
+ return children.get(field);
+ }
+
+ Map children() {
+ return children;
+ }
+
+ boolean isLeaf() {
+ return subject != null;
+ }
+
+ IdentitySubject subject() {
+ return subject;
+ }
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityRewriteRule.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityRewriteRule.java
new file mode 100644
index 0000000000..899cfea79b
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentityRewriteRule.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Locale;
+
+/**
+ * One place in a request message where the authenticated identity is written,
+ * expressed as field numbers rather than as a schema.
+ *
+ * Written as {@code path=subject}, where the path is one or more protobuf field
+ * numbers separated by dots and the subject names what to write there. Each
+ * leading number is a nested message to descend into; the last is the string
+ * field to replace. So {@code 1=principal} replaces a top-level field, and
+ * {@code 2.1=principal} replaces a field one level down.
+ *
+ * Naming numbers rather than compiling against generated classes is what keeps
+ * the gateway free of any particular protocol version: a schema may gain fields,
+ * rename them or deprecate them, but renumbering an existing field breaks every
+ * deployed client, so the numbers are the stable part.
+ */
+public final class IdentityRewriteRule {
+
+ /** Protobuf caps field numbers at 2^29-1. */
+ private static final int MAX_FIELD_NUMBER = 536870911;
+ private static final int RESERVED_FROM = 19000;
+ private static final int RESERVED_TO = 19999;
+
+ private final int[] path;
+ private final IdentitySubject subject;
+
+ private IdentityRewriteRule(int[] path, IdentitySubject subject) {
+ this.path = path;
+ this.subject = subject;
+ }
+
+ /**
+ * Parses one rule.
+ *
+ * @param rule {@code path=subject}, for example {@code 2.1=principal}
+ * @return the parsed rule
+ * @throws IllegalArgumentException if the rule is not a dotted list of legal
+ * field numbers followed by a known subject
+ */
+ public static IdentityRewriteRule parse(String rule) {
+ if (rule == null || rule.trim().isEmpty()) {
+ throw new IllegalArgumentException("A rewrite rule must not be empty");
+ }
+ final String trimmed = rule.trim();
+ final int separator = trimmed.indexOf('=');
+ if (separator < 0) {
+ throw new IllegalArgumentException(
+ "A rewrite rule must be written as 'path=subject', got: " + trimmed);
+ }
+ final String pathPart = trimmed.substring(0, separator).trim();
+ final IdentitySubject parsedSubject = IdentitySubject.parse(trimmed.substring(separator + 1));
+
+ if (pathPart.isEmpty()) {
+ throw new IllegalArgumentException(
+ "A rewrite rule must name at least one field number, got: " + trimmed);
+ }
+ final String[] parts = pathPart.split("\\.", -1);
+ final int[] parsedPath = new int[parts.length];
+ for (int i = 0; i < parts.length; i++) {
+ parsedPath[i] = parseFieldNumber(parts[i], trimmed);
+ }
+ return new IdentityRewriteRule(parsedPath, parsedSubject);
+ }
+
+ private static int parseFieldNumber(String value, String rule) {
+ final int number;
+ try {
+ number = Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ "A rewrite rule path must contain only field numbers, got: " + rule, e);
+ }
+ if (number < 1 || number > MAX_FIELD_NUMBER) {
+ throw new IllegalArgumentException(
+ "Field numbers must be between 1 and " + MAX_FIELD_NUMBER + ", got: " + rule);
+ }
+ if (number >= RESERVED_FROM && number <= RESERVED_TO) {
+ throw new IllegalArgumentException(
+ "Field numbers " + RESERVED_FROM + "-" + RESERVED_TO
+ + " are reserved by protobuf, got: " + rule);
+ }
+ return number;
+ }
+
+ /**
+ * @return the field numbers to follow, outermost first; never empty
+ */
+ public int[] getPath() {
+ return path.clone();
+ }
+
+ public IdentitySubject getSubject() {
+ return subject;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder text = new StringBuilder(16);
+ for (int i = 0; i < path.length; i++) {
+ if (i > 0) {
+ text.append('.');
+ }
+ text.append(path[i]);
+ }
+ return text.append('=').append(subject.name().toLowerCase(Locale.ROOT)).toString();
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentitySubject.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentitySubject.java
new file mode 100644
index 0000000000..08f946000a
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/IdentitySubject.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Arrays;
+import java.util.Locale;
+
+/**
+ * Which attribute of the authenticated caller a rewrite rule writes.
+ *
+ * A rule names a place in the message and a subject; this is the subject half.
+ * Keeping it explicit is what stops the gateway assuming that two fields in the
+ * same container mean "id" and "display name" — a convention of one protocol
+ * rather than a property of protobuf.
+ *
+ * The vocabulary is deliberately limited to what authentication actually
+ * establishes. Anything a deployment wishes were assertable but that Knox does
+ * not know is better refused at startup than written as an empty string.
+ */
+public enum IdentitySubject {
+
+ /** The authenticated principal: the subject of the validated bearer token. */
+ PRINCIPAL {
+ @Override
+ public String resolve(String principal) {
+ return principal;
+ }
+ };
+
+ /**
+ * Returns the value to write for this subject.
+ *
+ * @param principal the authenticated principal for the call in flight
+ * @return the value to write
+ */
+ public abstract String resolve(String principal);
+
+ /**
+ * Parses a subject name as written in configuration.
+ *
+ * @param value the configured name, case-insensitive
+ * @return the subject
+ * @throws IllegalArgumentException if the name is not one this build knows
+ */
+ public static IdentitySubject parse(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException("A rewrite rule must name a subject, e.g. '2.1=principal'");
+ }
+ final String normalized = value.trim().toLowerCase(Locale.ROOT);
+ for (IdentitySubject subject : values()) {
+ if (subject.name().toLowerCase(Locale.ROOT).equals(normalized)) {
+ return subject;
+ }
+ }
+ throw new IllegalArgumentException("Unknown identity subject '" + value.trim()
+ + "'; supported subjects are " + Arrays.toString(values()).toLowerCase(Locale.ROOT));
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/InterceptorChain.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/InterceptorChain.java
new file mode 100644
index 0000000000..584e365e56
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/InterceptorChain.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.List;
+
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+
+/**
+ * Wraps a call handler in an explicitly ordered interceptor chain.
+ *
+ * Order is a correctness property here, not a preference: routing must have
+ * chosen a topology before ACLs for that topology can be evaluated, and
+ * authentication must have established a principal before either. Rather than
+ * depend on the registration-order semantics of a builder, the chain is composed
+ * directly so the ordering is visible at the call site and cannot drift.
+ */
+public final class InterceptorChain {
+
+ private InterceptorChain() {
+ }
+
+ /**
+ * Returns a handler that applies the interceptors in list order, so the first
+ * element sees the call first and closes it last.
+ *
+ * @param handler the innermost handler
+ * @param interceptors the interceptors, outermost first
+ * @param the request message type
+ * @param the response message type
+ * @return the wrapped handler
+ */
+ public static ServerCallHandler intercept(
+ ServerCallHandler handler, List interceptors) {
+ ServerCallHandler result = handler;
+ for (int i = interceptors.size() - 1; i >= 0; i--) {
+ final ServerInterceptor interceptor = interceptors.get(i);
+ final ServerCallHandler next = result;
+ result = (call, headers) -> interceptor.interceptCall(call, headers, next);
+ }
+ return result;
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/MapFilterConfig.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/MapFilterConfig.java
new file mode 100644
index 0000000000..3b05656c3e
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/MapFilterConfig.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Map;
+
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+
+/**
+ * A map-backed {@link FilterConfig} for reusing servlet-configured collaborators
+ * off the servlet path.
+ *
+ * {@code SignatureVerificationCache} takes its settings from a
+ * {@code FilterConfig}, but the gRPC listener has no filter chain to get one
+ * from; its settings come from topology provider parameters instead. The
+ * WebSocket listener solves the same problem the same way.
+ */
+public class MapFilterConfig implements FilterConfig {
+
+ private final String name;
+ private final Map params;
+
+ public MapFilterConfig(String name, Map params) {
+ this.name = name;
+ this.params = params == null ? Collections.emptyMap() : params;
+ }
+
+ @Override
+ public String getFilterName() {
+ return name;
+ }
+
+ @Override
+ public ServletContext getServletContext() {
+ return null;
+ }
+
+ @Override
+ public String getInitParameter(String key) {
+ return params.get(key);
+ }
+
+ @Override
+ public Enumeration getInitParameterNames() {
+ return Collections.enumeration(params.keySet());
+ }
+}
diff --git a/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/MessageInterceptor.java b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/MessageInterceptor.java
new file mode 100644
index 0000000000..cc8670c372
--- /dev/null
+++ b/gateway-service-grpc/src/main/java/org/apache/knox/gateway/grpc/MessageInterceptor.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.grpc;
+
+import io.grpc.StatusRuntimeException;
+
+/**
+ * Inspects and optionally rewrites each request message on its way to the
+ * backend.
+ *
+ * This is the seam between the generic gRPC core and a protocol-aware plugin.
+ * The core never parses message bodies; everything that needs to — identity
+ * assertion, per-RPC gating, reserved-key protection — is expressed here. A
+ * generic byte-level proxy simply uses {@link #PASSTHROUGH}.
+ *
+ * @param the request message type
+ */
+@FunctionalInterface
+public interface MessageInterceptor {
+
+ /**
+ * A message interceptor that forwards every message unchanged. This is what
+ * makes the byte-level path a pure pipe.
+ */
+ MessageInterceptor