diff --git a/packages/grpc-js-xds/gulpfile.ts b/packages/grpc-js-xds/gulpfile.ts index 7fd4a367..5f5d9dfc 100644 --- a/packages/grpc-js-xds/gulpfile.ts +++ b/packages/grpc-js-xds/gulpfile.ts @@ -71,6 +71,7 @@ const copyTestFixtures = checkTask(() => const runTests = checkTask(() => { process.env.GRPC_EXPERIMENTAL_XDS_FEDERATION = 'true'; process.env.GRPC_EXPERIMENTAL_PICKFIRST_LB_CONFIG = 'true'; + process.env.GRPC_XDS_EXPERIMENTAL_RBAC = 'true'; if (Number(process.versions.node.split('.')[0]) <= 14) { process.env.GRPC_XDS_EXPERIMENTAL_ENABLE_RING_HASH = 'false'; } diff --git a/packages/grpc-js-xds/interop/test-server.Dockerfile b/packages/grpc-js-xds/interop/test-server.Dockerfile index 59954a84..7168c949 100644 --- a/packages/grpc-js-xds/interop/test-server.Dockerfile +++ b/packages/grpc-js-xds/interop/test-server.Dockerfile @@ -46,7 +46,7 @@ COPY --from=build /node/src/grpc-node/packages/grpc-js ./packages/grpc-js/ COPY --from=build /node/src/grpc-node/packages/grpc-js-xds ./packages/grpc-js-xds/ ENV GRPC_VERBOSITY="DEBUG" -ENV GRPC_TRACE=xds_client,server,xds_server,http_filter,certificate_provider +ENV GRPC_TRACE=xds_client,server,xds_server,http_filter,certificate_provider,rbac_filter # tini serves as PID 1 and enables the server to properly respond to signals. COPY --from=build /tini /tini diff --git a/packages/grpc-js-xds/package.json b/packages/grpc-js-xds/package.json index 1666d2aa..3c9fa944 100644 --- a/packages/grpc-js-xds/package.json +++ b/packages/grpc-js-xds/package.json @@ -12,7 +12,7 @@ "prepare": "npm run generate-types && npm run compile", "pretest": "npm run compile", "posttest": "npm run check", - "generate-types": "proto-loader-gen-types --keepCase --longs String --enums String --defaults --oneofs --includeComments --includeDirs deps/envoy-api/ deps/xds/ deps/googleapis/ deps/protoc-gen-validate/ -O src/generated/ --grpcLib @grpc/grpc-js envoy/service/discovery/v3/ads.proto envoy/service/load_stats/v3/lrs.proto envoy/config/listener/v3/listener.proto envoy/config/route/v3/route.proto envoy/config/cluster/v3/cluster.proto envoy/config/endpoint/v3/endpoint.proto envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto udpa/type/v1/typed_struct.proto xds/type/v3/typed_struct.proto envoy/extensions/filters/http/fault/v3/fault.proto envoy/service/status/v3/csds.proto envoy/extensions/load_balancing_policies/wrr_locality/v3/wrr_locality.proto envoy/extensions/load_balancing_policies/ring_hash/v3/ring_hash.proto envoy/extensions/load_balancing_policies/pick_first/v3/pick_first.proto envoy/extensions/clusters/aggregate/v3/cluster.proto envoy/extensions/transport_sockets/tls/v3/tls.proto envoy/config/rbac/v3/rbac.proto", + "generate-types": "proto-loader-gen-types --keepCase --longs String --enums String --defaults --oneofs --includeComments --includeDirs deps/envoy-api/ deps/xds/ deps/googleapis/ deps/protoc-gen-validate/ -O src/generated/ --grpcLib @grpc/grpc-js envoy/service/discovery/v3/ads.proto envoy/service/load_stats/v3/lrs.proto envoy/config/listener/v3/listener.proto envoy/config/route/v3/route.proto envoy/config/cluster/v3/cluster.proto envoy/config/endpoint/v3/endpoint.proto envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto udpa/type/v1/typed_struct.proto xds/type/v3/typed_struct.proto envoy/extensions/filters/http/fault/v3/fault.proto envoy/service/status/v3/csds.proto envoy/extensions/load_balancing_policies/wrr_locality/v3/wrr_locality.proto envoy/extensions/load_balancing_policies/ring_hash/v3/ring_hash.proto envoy/extensions/load_balancing_policies/pick_first/v3/pick_first.proto envoy/extensions/clusters/aggregate/v3/cluster.proto envoy/extensions/transport_sockets/tls/v3/tls.proto envoy/config/rbac/v3/rbac.proto envoy/extensions/filters/http/rbac/v3/rbac.proto", "generate-interop-types": "proto-loader-gen-types --keep-case --longs String --enums String --defaults --oneofs --json --includeComments --includeDirs proto/ -O interop/generated --grpcLib @grpc/grpc-js grpc/testing/test.proto", "generate-test-types": "proto-loader-gen-types --keep-case --longs String --enums String --defaults --oneofs --json --includeComments --includeDirs proto/ -O test/generated --grpcLib @grpc/grpc-js grpc/testing/echo.proto" }, diff --git a/packages/grpc-js-xds/src/environment.ts b/packages/grpc-js-xds/src/environment.ts index 300f989e..a7770776 100644 --- a/packages/grpc-js-xds/src/environment.ts +++ b/packages/grpc-js-xds/src/environment.ts @@ -27,3 +27,4 @@ export const EXPERIMENTAL_RING_HASH = (process.env.GRPC_XDS_EXPERIMENTAL_ENABLE_ export const EXPERIMENTAL_PICK_FIRST = (process.env.GRPC_EXPERIMENTAL_PICKFIRST_LB_CONFIG ?? 'false') === 'true'; export const EXPERIMENTAL_DUALSTACK_ENDPOINTS = (process.env.GRPC_EXPERIMENTAL_XDS_DUALSTACK_ENDPOINTS ?? 'true') === 'true'; export const AGGREGATE_CLUSTER_BACKWARDS_COMPAT = (process.env.GRPC_XDS_AGGREGATE_CLUSTER_BACKWARD_COMPAT ?? 'false') === 'true'; +export const EXPERIMENTAL_RBAC = (process.env.GRPC_XDS_EXPERIMENTAL_RBAC ?? 'false') === 'true'; diff --git a/packages/grpc-js-xds/src/generated/envoy/extensions/filters/http/rbac/v3/RBAC.ts b/packages/grpc-js-xds/src/generated/envoy/extensions/filters/http/rbac/v3/RBAC.ts new file mode 100644 index 00000000..0afac042 --- /dev/null +++ b/packages/grpc-js-xds/src/generated/envoy/extensions/filters/http/rbac/v3/RBAC.ts @@ -0,0 +1,104 @@ +// Original file: deps/envoy-api/envoy/extensions/filters/http/rbac/v3/rbac.proto + +import type { RBAC as _envoy_config_rbac_v3_RBAC, RBAC__Output as _envoy_config_rbac_v3_RBAC__Output } from '../../../../../../envoy/config/rbac/v3/RBAC'; +import type { Matcher as _xds_type_matcher_v3_Matcher, Matcher__Output as _xds_type_matcher_v3_Matcher__Output } from '../../../../../../xds/type/matcher/v3/Matcher'; + +/** + * RBAC filter config. + * [#next-free-field: 8] + */ +export interface RBAC { + /** + * Specify the RBAC rules to be applied globally. + * If absent, no enforcing RBAC policy will be applied. + * If present and empty, DENY. + * If both rules and matcher are configured, rules will be ignored. + */ + 'rules'?: (_envoy_config_rbac_v3_RBAC | null); + /** + * Shadow rules are not enforced by the filter (i.e., returning a 403) + * but will emit stats and logs and can be used for rule testing. + * If absent, no shadow RBAC policy will be applied. + * If both shadow rules and shadow matcher are configured, shadow rules will be ignored. + */ + 'shadow_rules'?: (_envoy_config_rbac_v3_RBAC | null); + /** + * If specified, shadow rules will emit stats with the given prefix. + * This is useful to distinguish the stat when there are more than 1 RBAC filter configured with + * shadow rules. + */ + 'shadow_rules_stat_prefix'?: (string); + /** + * The match tree to use when resolving RBAC action for incoming requests. Requests do not + * match any matcher will be denied. + * If absent, no enforcing RBAC matcher will be applied. + * If present and empty, deny all requests. + */ + 'matcher'?: (_xds_type_matcher_v3_Matcher | null); + /** + * The match tree to use for emitting stats and logs which can be used for rule testing for + * incoming requests. + * If absent, no shadow matcher will be applied. + */ + 'shadow_matcher'?: (_xds_type_matcher_v3_Matcher | null); + /** + * If specified, rules will emit stats with the given prefix. + * This is useful to distinguish the stat when there are more than 1 RBAC filter configured with + * rules. + */ + 'rules_stat_prefix'?: (string); + /** + * If track_per_rule_stats is true, counters will be published for each rule and shadow rule. + */ + 'track_per_rule_stats'?: (boolean); +} + +/** + * RBAC filter config. + * [#next-free-field: 8] + */ +export interface RBAC__Output { + /** + * Specify the RBAC rules to be applied globally. + * If absent, no enforcing RBAC policy will be applied. + * If present and empty, DENY. + * If both rules and matcher are configured, rules will be ignored. + */ + 'rules': (_envoy_config_rbac_v3_RBAC__Output | null); + /** + * Shadow rules are not enforced by the filter (i.e., returning a 403) + * but will emit stats and logs and can be used for rule testing. + * If absent, no shadow RBAC policy will be applied. + * If both shadow rules and shadow matcher are configured, shadow rules will be ignored. + */ + 'shadow_rules': (_envoy_config_rbac_v3_RBAC__Output | null); + /** + * If specified, shadow rules will emit stats with the given prefix. + * This is useful to distinguish the stat when there are more than 1 RBAC filter configured with + * shadow rules. + */ + 'shadow_rules_stat_prefix': (string); + /** + * The match tree to use when resolving RBAC action for incoming requests. Requests do not + * match any matcher will be denied. + * If absent, no enforcing RBAC matcher will be applied. + * If present and empty, deny all requests. + */ + 'matcher': (_xds_type_matcher_v3_Matcher__Output | null); + /** + * The match tree to use for emitting stats and logs which can be used for rule testing for + * incoming requests. + * If absent, no shadow matcher will be applied. + */ + 'shadow_matcher': (_xds_type_matcher_v3_Matcher__Output | null); + /** + * If specified, rules will emit stats with the given prefix. + * This is useful to distinguish the stat when there are more than 1 RBAC filter configured with + * rules. + */ + 'rules_stat_prefix': (string); + /** + * If track_per_rule_stats is true, counters will be published for each rule and shadow rule. + */ + 'track_per_rule_stats': (boolean); +} diff --git a/packages/grpc-js-xds/src/generated/envoy/extensions/filters/http/rbac/v3/RBACPerRoute.ts b/packages/grpc-js-xds/src/generated/envoy/extensions/filters/http/rbac/v3/RBACPerRoute.ts new file mode 100644 index 00000000..59fc20a0 --- /dev/null +++ b/packages/grpc-js-xds/src/generated/envoy/extensions/filters/http/rbac/v3/RBACPerRoute.ts @@ -0,0 +1,19 @@ +// Original file: deps/envoy-api/envoy/extensions/filters/http/rbac/v3/rbac.proto + +import type { RBAC as _envoy_extensions_filters_http_rbac_v3_RBAC, RBAC__Output as _envoy_extensions_filters_http_rbac_v3_RBAC__Output } from '../../../../../../envoy/extensions/filters/http/rbac/v3/RBAC'; + +export interface RBACPerRoute { + /** + * Override the global configuration of the filter with this new config. + * If absent, the global RBAC policy will be disabled for this route. + */ + 'rbac'?: (_envoy_extensions_filters_http_rbac_v3_RBAC | null); +} + +export interface RBACPerRoute__Output { + /** + * Override the global configuration of the filter with this new config. + * If absent, the global RBAC policy will be disabled for this route. + */ + 'rbac': (_envoy_extensions_filters_http_rbac_v3_RBAC__Output | null); +} diff --git a/packages/grpc-js-xds/src/generated/rbac.ts b/packages/grpc-js-xds/src/generated/rbac.ts index bc2ed797..3207c69e 100644 --- a/packages/grpc-js-xds/src/generated/rbac.ts +++ b/packages/grpc-js-xds/src/generated/rbac.ts @@ -93,6 +93,18 @@ export interface ProtoGrpcType { } } } + extensions: { + filters: { + http: { + rbac: { + v3: { + RBAC: MessageTypeDefinition + RBACPerRoute: MessageTypeDefinition + } + } + } + } + } type: { matcher: { v3: { diff --git a/packages/grpc-js-xds/src/http-filter/rbac-filter.ts b/packages/grpc-js-xds/src/http-filter/rbac-filter.ts new file mode 100644 index 00000000..d1766532 --- /dev/null +++ b/packages/grpc-js-xds/src/http-filter/rbac-filter.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2021 gRPC authors. + * + * Licensed 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. + */ + +// This is a non-public, unstable API, but it's very convenient +import { loadProtosWithOptionsSync } from '@grpc/proto-loader/build/src/util'; +import { experimental, logVerbosity, ServerInterceptingCall, ServerInterceptor, ServerListener, status } from '@grpc/grpc-js'; +import { Any__Output } from '../generated/google/protobuf/Any'; +import { HttpFilterConfig, registerHttpFilter } from '../http-filter'; +import { RbacPolicyGroup, UnifiedInfo as UnifiedRbacInfo } from '../rbac'; +import { RBAC__Output } from '../generated/envoy/extensions/filters/http/rbac/v3/RBAC'; +import { RBACPerRoute__Output } from '../generated/envoy/extensions/filters/http/rbac/v3/RBACPerRoute'; +import { parseConfig as parseRbacConfig } from '../rbac'; +import { EXPERIMENTAL_RBAC } from '../environment'; + +const TRACER_NAME = 'rbac_filter'; + +function trace(text: string): void { + experimental.trace(logVerbosity.DEBUG, TRACER_NAME, text); +} + +const resourceRoot = loadProtosWithOptionsSync([ + 'envoy/extensions/filters/http/rbac/v3/rbac.proto'], { + keepCase: true, + includeDirs: [ + // Paths are relative to src/build/http-filter + __dirname + '/../../../deps/xds/', + __dirname + '/../../../deps/envoy-api/', + __dirname + '/../../../deps/protoc-gen-validate/', + __dirname + '/../../../deps/googleapis/' + ], + } +); + +const RBAC_FILTER_URL = 'type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC'; +const RBAC_FILTER_OVERRIDE_URL ='type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute'; + +const toObjectOptions = { + longs: String, + enums: String, + defaults: true, + oneofs: true +} + +function parseAnyMessage(message: Any__Output): MessageType | null { + const typeName = message.type_url.substring(message.type_url.lastIndexOf('/') + 1); + const messageType = resourceRoot.lookup(typeName); + if (messageType) { + const decodedMessage = (messageType as any).decode(message.value); + return decodedMessage.$type.toObject(decodedMessage, toObjectOptions) as MessageType; + } else { + return null; + } +} + +interface RbacFilterConfig extends HttpFilterConfig { + typeUrl: 'type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC'; + config: RbacPolicyGroup; +} + +function parseTopLevelRbacConfig(encodedConfig: Any__Output): RbacFilterConfig | null { + if (encodedConfig.type_url !== RBAC_FILTER_URL) { + trace('Config parsing failed: unexpected type URL: ' + encodedConfig.type_url); + return null; + } + const parsedMessage = parseAnyMessage(encodedConfig); + if (parsedMessage === null) { + trace('Config parsing failed: failed to parse RBAC message'); + return null; + } + trace('Parsing RBAC message ' + JSON.stringify(parsedMessage, undefined, 2)); + if (!parsedMessage.rules) { + trace('Config parsing failed: no rules found'); + return null; + } + try { + return { + typeUrl: RBAC_FILTER_URL, + config: parseRbacConfig(parsedMessage.rules) + }; + } catch (e) { + trace('Config parsing failed: ' + (e as Error).message); + return null; + } +} + +function parseOverrideRbacConfig(encodedConfig: Any__Output): RbacFilterConfig | null { + if (encodedConfig.type_url !== RBAC_FILTER_OVERRIDE_URL) { + trace('Config parsing failed: unexpected type URL: ' + encodedConfig.type_url); + return null; + } + const parsedMessage = parseAnyMessage(encodedConfig); + if (parsedMessage === null) { + trace('Config parsing failed: failed to parse RBACPerRoute message'); + return null; + } + trace('Parsing RBAC message ' + JSON.stringify(parsedMessage, undefined, 2)); + if (!parsedMessage.rbac?.rules) { + trace('Config parsing failed: no rules found'); + return null; + } + try { + return { + typeUrl: RBAC_FILTER_URL, + config: parseRbacConfig(parsedMessage.rbac.rules) + }; + } catch (e) { + trace('Config parsing failed: ' + (e as Error).message); + return null; + } +} + +function createRbacServerFilter(config: HttpFilterConfig, overrideConfigMap: Map): ServerInterceptor { + return function rbacServerFilter(methodDescriptor, call): ServerInterceptingCall { + const listener: ServerListener = { + onReceiveMetadata: (metadata, next) => { + let activeConfig = config; + const routeName = metadata.get('grpc-route')[0]; + if (routeName) { + const overrideConfig = overrideConfigMap.get(routeName as string); + if (overrideConfig) { + activeConfig = overrideConfig; + } + } + const rbacMetadata = metadata.clone(); + rbacMetadata.set(':method', 'POST'); + rbacMetadata.set(':authority', call.getHost()); + rbacMetadata.set(':path', methodDescriptor.path); + const connectionInfo = call.getConnectionInfo(); + const authContext = call.getAuthContext(); + const info: UnifiedRbacInfo = { + destinationIp: connectionInfo.localAddress!, + destinationPort: connectionInfo.localPort!, + sourceIp: connectionInfo.remoteAddress!, + headers: rbacMetadata, + tls: authContext.transportSecurityType !== undefined, + peerCertificate: authContext.sslPeerCertificate ?? null, + urlPath: methodDescriptor.path + }; + if ((activeConfig as RbacFilterConfig).config.apply(info)) { + next(metadata); + } else { + call.sendStatus({code: status.PERMISSION_DENIED, details: 'Unauthorized RPC rejected'}); + } + } + }; + return new ServerInterceptingCall(call, { + start: next => { + next(listener); + } + }); + } +} + +export function setup() { + if (EXPERIMENTAL_RBAC) { + registerHttpFilter(RBAC_FILTER_URL, { + parseTopLevelFilterConfig: parseTopLevelRbacConfig, + parseOverrideFilterConfig: parseOverrideRbacConfig, + createServerFilter: createRbacServerFilter + }); + } +} diff --git a/packages/grpc-js-xds/src/index.ts b/packages/grpc-js-xds/src/index.ts index b83992fc..8db8b44e 100644 --- a/packages/grpc-js-xds/src/index.ts +++ b/packages/grpc-js-xds/src/index.ts @@ -25,6 +25,7 @@ import * as xds_wrr_locality from './load-balancer-xds-wrr-locality'; import * as ring_hash from './load-balancer-ring-hash'; import * as router_filter from './http-filter/router-filter'; import * as fault_injection_filter from './http-filter/fault-injection-filter'; +import * as rbac_filter from './http-filter/rbac-filter'; import * as csds from './csds'; import * as round_robin_lb from './lb-policy-registry/round-robin'; import * as typed_struct_lb from './lb-policy-registry/typed-struct'; @@ -53,6 +54,7 @@ export function register() { ring_hash.setup(); router_filter.setup(); fault_injection_filter.setup(); + rbac_filter.setup(); csds.setup(); round_robin_lb.setup(); typed_struct_lb.setup(); diff --git a/packages/grpc-js-xds/src/rbac.ts b/packages/grpc-js-xds/src/rbac.ts index 89b95db3..b1d8558f 100644 --- a/packages/grpc-js-xds/src/rbac.ts +++ b/packages/grpc-js-xds/src/rbac.ts @@ -278,8 +278,6 @@ export class MetadataPrincipal implements PrincipalRule { } } -export type RbacAction = 'ALLOW' | 'DENY'; - export interface UnifiedInfo extends PermissionInfo, PrincipalInfo {} export class RbacPolicy { @@ -301,15 +299,20 @@ export class RbacPolicy { } export class RbacPolicyGroup { - constructor(private policies: Map, private action: RbacAction) {} + constructor(private policies: Map, private allow: boolean) {} - apply(info: UnifiedInfo): RbacAction | null { + /** + * + * @param info + * @returns True if the call should be accepted, false if it should be rejected + */ + apply(info: UnifiedInfo): boolean { for (const policy of this.policies.values()) { if (policy.matches(info)) { - return this.action; + return this.allow; } } - return null; + return !this.allow; } toString() { @@ -318,7 +321,7 @@ export class RbacPolicyGroup { policyStrings.push(`${name}: ${policy.toString()}`); } return `RBAC - action=${this.action} + action=${this.allow ? 'ALLOW' : 'DENY'} policies: ${policyStrings.join('\n')}`; } @@ -392,5 +395,5 @@ export function parseConfig(rbac: RBAC__Output): RbacPolicyGroup { for (const [name, policyConfig] of Object.entries(rbac.policies)) { policyMap.set(name, parsePolicy(policyConfig)); } - return new RbacPolicyGroup(policyMap, rbac.action); + return new RbacPolicyGroup(policyMap, rbac.action === 'ALLOW'); } diff --git a/packages/grpc-js-xds/test/framework.ts b/packages/grpc-js-xds/test/framework.ts index af15054a..a3caa190 100644 --- a/packages/grpc-js-xds/test/framework.ts +++ b/packages/grpc-js-xds/test/framework.ts @@ -31,6 +31,7 @@ import { ClusterConfig } from "../src/generated/envoy/extensions/clusters/aggreg import { Any } from "../src/generated/google/protobuf/Any"; import { ControlPlaneServer } from "./xds-server"; import { UpstreamTlsContext } from "../src/generated/envoy/extensions/transport_sockets/tls/v3/UpstreamTlsContext"; +import { HttpFilter } from "../src/generated/envoy/extensions/filters/network/http_connection_manager/v3/HttpFilter"; interface Endpoint { locality: Locality; @@ -400,7 +401,7 @@ const DEFAULT_BASE_SERVER_ROUTE_CONFIG: RouteConfiguration = { export class FakeServerRoute { private listener: Listener; private routeConfiguration: RouteConfiguration; - constructor(port: number, routeName: string, baseListener?: Listener | undefined, baseRouteConfiguration?: RouteConfiguration) { + constructor(port: number, routeName: string, baseListener?: Listener | undefined, baseRouteConfiguration?: RouteConfiguration | undefined, httpFilters?: HttpFilter[]) { this.listener = baseListener ?? {...DEFAULT_BASE_SERVER_LISTENER}; this.listener.name = `[::1]:${port}`; this.listener.address = { @@ -414,11 +415,9 @@ export class FakeServerRoute { rds: { route_config_name: routeName, config_source: {ads: {}} - } + }, + http_filters: httpFilters ?? [] }; - this.listener.api_listener = { - api_listener: httpConnectionManager - } const filterList = [{ typed_config: httpConnectionManager }]; diff --git a/packages/grpc-js-xds/test/test-rbac-filter.ts b/packages/grpc-js-xds/test/test-rbac-filter.ts new file mode 100644 index 00000000..2238e403 --- /dev/null +++ b/packages/grpc-js-xds/test/test-rbac-filter.ts @@ -0,0 +1,188 @@ +/* + * Copyright 2025 gRPC authors. + * + * Licensed 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. + * + */ + +import * as assert from 'assert'; +import { createBackends } from "./backend"; +import { XdsTestClient } from "./client"; +import { FakeEdsCluster, FakeRouteGroup, FakeServerRoute } from "./framework"; +import { ControlPlaneServer } from "./xds-server"; +import { AnyExtension } from '@grpc/proto-loader'; +import { RBAC } from '../src/generated/envoy/extensions/filters/http/rbac/v3/RBAC'; +import { status } from '@grpc/grpc-js'; + +describe.only('RBAC HTTP filter', () => { + let xdsServer: ControlPlaneServer; + let client: XdsTestClient; + beforeEach(done => { + xdsServer = new ControlPlaneServer(); + xdsServer.startServer(error => { + done(error); + }); + }); + afterEach(() => { + client?.close(); + xdsServer?.shutdownServer(); + }); + it('Should accept matching requests with ALLOW action', async () => { + const [backend] = await createBackends(1); + const rbacFilter: AnyExtension & RBAC = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC', + rules: { + action: 'ALLOW', + policies: { + local: { + principals: [{any: true}], + permissions: [{any: true}] + } + } + } + }; + const routerFilter: AnyExtension = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router' + }; + const serverRoute = new FakeServerRoute(backend.getPort(), 'serverRoute', undefined, undefined, [{typed_config: rbacFilter, name: 'rbac'}, {typed_config: routerFilter, name: 'router'}]); + xdsServer.setRdsResource(serverRoute.getRouteConfiguration()); + xdsServer.setLdsResource(serverRoute.getListener()); + xdsServer.addResponseListener((typeUrl, responseState) => { + if (responseState.state === 'NACKED') { + client?.stopCalls(); + assert.fail(`Client NACKED ${typeUrl} resource with message ${responseState.errorMessage}`); + } + }); + const cluster = new FakeEdsCluster('cluster1', 'endpoint1', [{backends: [backend], locality:{region: 'region1'}}]); + const routeGroup = new FakeRouteGroup('listener1', 'route1', [{cluster: cluster}]); + await routeGroup.startAllBackends(xdsServer); + xdsServer.setEdsResource(cluster.getEndpointConfig()); + xdsServer.setCdsResource(cluster.getClusterConfig()); + xdsServer.setRdsResource(routeGroup.getRouteConfiguration()); + xdsServer.setLdsResource(routeGroup.getListener()); + client = XdsTestClient.createFromServer('listener1', xdsServer); + const error = await client.sendOneCallAsync(); + assert.strictEqual(error, null); + }); + it('Should reject matching requests with DENY action', async () => { + const [backend] = await createBackends(1); + const rbacFilter: AnyExtension & RBAC = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC', + rules: { + action: 'DENY', + policies: { + local: { + principals: [{any: true}], + permissions: [{any: true}] + } + } + } + }; + const routerFilter: AnyExtension = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router' + }; + const serverRoute = new FakeServerRoute(backend.getPort(), 'serverRoute', undefined, undefined, [{typed_config: rbacFilter, name: 'rbac'}, {typed_config: routerFilter, name: 'router'}]); + xdsServer.setRdsResource(serverRoute.getRouteConfiguration()); + xdsServer.setLdsResource(serverRoute.getListener()); + xdsServer.addResponseListener((typeUrl, responseState) => { + if (responseState.state === 'NACKED') { + client?.stopCalls(); + assert.fail(`Client NACKED ${typeUrl} resource with message ${responseState.errorMessage}`); + } + }); + const cluster = new FakeEdsCluster('cluster1', 'endpoint1', [{backends: [backend], locality:{region: 'region1'}}]); + const routeGroup = new FakeRouteGroup('listener1', 'route1', [{cluster: cluster}]); + await routeGroup.startAllBackends(xdsServer); + xdsServer.setEdsResource(cluster.getEndpointConfig()); + xdsServer.setCdsResource(cluster.getClusterConfig()); + xdsServer.setRdsResource(routeGroup.getRouteConfiguration()); + xdsServer.setLdsResource(routeGroup.getListener()); + client = XdsTestClient.createFromServer('listener1', xdsServer); + const error = await client.sendOneCallAsync(); + assert.strictEqual(error?.code, status.PERMISSION_DENIED); + }); + it('Should reject non-matching requests with ALLOW action', async () => { + const [backend] = await createBackends(1); + const rbacFilter: AnyExtension & RBAC = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC', + rules: { + action: 'ALLOW', + policies: { + local: { + principals: [{any: true}], + permissions: [{not_rule: {any: true}}] + } + } + } + }; + const routerFilter: AnyExtension = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router' + }; + const serverRoute = new FakeServerRoute(backend.getPort(), 'serverRoute', undefined, undefined, [{typed_config: rbacFilter, name: 'rbac'}, {typed_config: routerFilter, name: 'router'}]); + xdsServer.setRdsResource(serverRoute.getRouteConfiguration()); + xdsServer.setLdsResource(serverRoute.getListener()); + xdsServer.addResponseListener((typeUrl, responseState) => { + if (responseState.state === 'NACKED') { + client?.stopCalls(); + assert.fail(`Client NACKED ${typeUrl} resource with message ${responseState.errorMessage}`); + } + }); + const cluster = new FakeEdsCluster('cluster1', 'endpoint1', [{backends: [backend], locality:{region: 'region1'}}]); + const routeGroup = new FakeRouteGroup('listener1', 'route1', [{cluster: cluster}]); + await routeGroup.startAllBackends(xdsServer); + xdsServer.setEdsResource(cluster.getEndpointConfig()); + xdsServer.setCdsResource(cluster.getClusterConfig()); + xdsServer.setRdsResource(routeGroup.getRouteConfiguration()); + xdsServer.setLdsResource(routeGroup.getListener()); + client = XdsTestClient.createFromServer('listener1', xdsServer); + const error = await client.sendOneCallAsync(); + assert.strictEqual(error?.code, status.PERMISSION_DENIED); + }); + it('Should accept non-matching requests with DENY action', async () => { + const [backend] = await createBackends(1); + const rbacFilter: AnyExtension & RBAC = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC', + rules: { + action: 'DENY', + policies: { + local: { + principals: [{any: true}], + permissions: [{not_rule: {any: true}}] + } + } + } + }; + const routerFilter: AnyExtension = { + '@type': 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router' + }; + const serverRoute = new FakeServerRoute(backend.getPort(), 'serverRoute', undefined, undefined, [{typed_config: rbacFilter, name: 'rbac'}, {typed_config: routerFilter, name: 'router'}]); + xdsServer.setRdsResource(serverRoute.getRouteConfiguration()); + xdsServer.setLdsResource(serverRoute.getListener()); + xdsServer.addResponseListener((typeUrl, responseState) => { + if (responseState.state === 'NACKED') { + client?.stopCalls(); + assert.fail(`Client NACKED ${typeUrl} resource with message ${responseState.errorMessage}`); + } + }); + const cluster = new FakeEdsCluster('cluster1', 'endpoint1', [{backends: [backend], locality:{region: 'region1'}}]); + const routeGroup = new FakeRouteGroup('listener1', 'route1', [{cluster: cluster}]); + await routeGroup.startAllBackends(xdsServer); + xdsServer.setEdsResource(cluster.getEndpointConfig()); + xdsServer.setCdsResource(cluster.getClusterConfig()); + xdsServer.setRdsResource(routeGroup.getRouteConfiguration()); + xdsServer.setLdsResource(routeGroup.getListener()); + client = XdsTestClient.createFromServer('listener1', xdsServer); + const error = await client.sendOneCallAsync(); + assert.strictEqual(error, null); + }); +}); diff --git a/packages/grpc-js-xds/test/xds-server.ts b/packages/grpc-js-xds/test/xds-server.ts index ed0a03dc..3dbeecd1 100644 --- a/packages/grpc-js-xds/test/xds-server.ts +++ b/packages/grpc-js-xds/test/xds-server.ts @@ -54,7 +54,9 @@ const loadedProtos = loadPackageDefinition(loadSync( 'envoy/extensions/load_balancing_policies/ring_hash/v3/ring_hash.proto', 'envoy/extensions/load_balancing_policies/pick_first/v3/pick_first.proto', 'envoy/extensions/transport_sockets/tls/v3/tls.proto', - 'xds/type/v3/typed_struct.proto' + 'xds/type/v3/typed_struct.proto', + 'envoy/extensions/filters/http/router/v3/router.proto', + 'envoy/extensions/filters/http/rbac/v3/rbac.proto' ], { keepCase: true, diff --git a/packages/grpc-js/src/metadata.ts b/packages/grpc-js/src/metadata.ts index eabd2dff..d59b6cc2 100644 --- a/packages/grpc-js/src/metadata.ts +++ b/packages/grpc-js/src/metadata.ts @@ -19,7 +19,7 @@ import * as http2 from 'http2'; import { log } from './logging'; import { LogVerbosity } from './constants'; import { getErrorMessage } from './error'; -const LEGAL_KEY_REGEX = /^[0-9a-z_.-]+$/; +const LEGAL_KEY_REGEX = /^[:0-9a-z_.-]+$/; const LEGAL_NON_BINARY_VALUE_REGEX = /^[ -~]*$/; export type MetadataValue = string | Buffer; @@ -222,6 +222,9 @@ export class Metadata { const result: http2.OutgoingHttpHeaders = {}; for (const [key, values] of this.internalRepr) { + if (key.startsWith(':')) { + continue; + } // We assume that the user's interaction with this object is limited to // through its public API (i.e. keys and values are already validated). result[key] = values.map(bufToString); diff --git a/packages/grpc-js/src/server-interceptors.ts b/packages/grpc-js/src/server-interceptors.ts index d6c6e227..88d05943 100644 --- a/packages/grpc-js/src/server-interceptors.ts +++ b/packages/grpc-js/src/server-interceptors.ts @@ -1005,9 +1005,10 @@ export class BaseServerInterceptingCall } getAuthContext(): AuthContext { if (this.stream.session?.socket instanceof TLSSocket) { + const peerCertificate = this.stream.session.socket.getPeerCertificate(); return { transportSecurityType: 'ssl', - sslPeerCertificate: this.stream.session.socket.getPeerCertificate() + sslPeerCertificate: peerCertificate.raw ? peerCertificate : undefined } } else { return {};