blob: a212579c63e2def132f01f7d0e187b74011e5183 [file] [log] [blame]
/*
* 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.
*/
// Package rbac provides service-level and method-level access control for a
// service. See
// https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/rbac/v3/rbac.proto#role-based-access-control-rbac
// for documentation.
package rbac
import (
"context"
"crypto/x509"
"errors"
"fmt"
"net"
"strconv"
v3rbacpb "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/grpclog"
"google.golang.org/grpc/internal/transport"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
var logger = grpclog.Component("rbac")
var getConnection = transport.GetConnection
// ChainEngine represents a chain of RBAC Engines, used to make authorization
// decisions on incoming RPCs.
type ChainEngine struct {
chainedEngines []*engine
}
// NewChainEngine returns a chain of RBAC engines, used to make authorization
// decisions on incoming RPCs. Returns a non-nil error for invalid policies.
func NewChainEngine(policies []*v3rbacpb.RBAC) (*ChainEngine, error) {
engines := make([]*engine, 0, len(policies))
for _, policy := range policies {
engine, err := newEngine(policy)
if err != nil {
return nil, err
}
engines = append(engines, engine)
}
return &ChainEngine{chainedEngines: engines}, nil
}
func (cre *ChainEngine) logRequestDetails(rpcData *rpcData) {
if logger.V(2) {
logger.Infof("checking request: url path=%s", rpcData.fullMethod)
if len(rpcData.certs) > 0 {
cert := rpcData.certs[0]
logger.Infof("uri sans=%q, dns sans=%q, subject=%v", cert.URIs, cert.DNSNames, cert.Subject)
}
}
}
// IsAuthorized determines if an incoming RPC is authorized based on the chain of RBAC
// engines and their associated actions.
//
// Errors returned by this function are compatible with the status package.
func (cre *ChainEngine) IsAuthorized(ctx context.Context) error {
// This conversion step (i.e. pulling things out of ctx) can be done once,
// and then be used for the whole chain of RBAC Engines.
rpcData, err := newRPCData(ctx)
if err != nil {
logger.Errorf("newRPCData: %v", err)
return status.Errorf(codes.Internal, "gRPC RBAC: %v", err)
}
for _, engine := range cre.chainedEngines {
matchingPolicyName, ok := engine.findMatchingPolicy(rpcData)
if logger.V(2) && ok {
logger.Infof("incoming RPC matched to policy %v in engine with action %v", matchingPolicyName, engine.action)
}
switch {
case engine.action == v3rbacpb.RBAC_ALLOW && !ok:
cre.logRequestDetails(rpcData)
return status.Errorf(codes.PermissionDenied, "incoming RPC did not match an allow policy")
case engine.action == v3rbacpb.RBAC_DENY && ok:
cre.logRequestDetails(rpcData)
return status.Errorf(codes.PermissionDenied, "incoming RPC matched a deny policy %q", matchingPolicyName)
}
// Every policy in the engine list must be queried. Thus, iterate to the
// next policy.
}
// If the incoming RPC gets through all of the engines successfully (i.e.
// doesn't not match an allow or match a deny engine), the RPC is authorized
// to proceed.
return nil
}
// engine is used for matching incoming RPCs to policies.
type engine struct {
policies map[string]*policyMatcher
// action must be ALLOW or DENY.
action v3rbacpb.RBAC_Action
}
// newEngine creates an RBAC Engine based on the contents of policy. Returns a
// non-nil error if the policy is invalid.
func newEngine(config *v3rbacpb.RBAC) (*engine, error) {
a := config.GetAction()
if a != v3rbacpb.RBAC_ALLOW && a != v3rbacpb.RBAC_DENY {
return nil, fmt.Errorf("unsupported action %s", config.Action)
}
policies := make(map[string]*policyMatcher, len(config.GetPolicies()))
for name, policy := range config.GetPolicies() {
matcher, err := newPolicyMatcher(policy)
if err != nil {
return nil, err
}
policies[name] = matcher
}
return &engine{
policies: policies,
action: a,
}, nil
}
// findMatchingPolicy determines if an incoming RPC matches a policy. On a
// successful match, it returns the name of the matching policy and a true bool
// to specify that there was a matching policy found. It returns false in
// the case of not finding a matching policy.
func (r *engine) findMatchingPolicy(rpcData *rpcData) (string, bool) {
for policy, matcher := range r.policies {
if matcher.match(rpcData) {
return policy, true
}
}
return "", false
}
// newRPCData takes an incoming context (should be a context representing state
// needed for server RPC Call with metadata, peer info (used for source ip/port
// and TLS information) and connection (used for destination ip/port) piped into
// it) and the method name of the Service being called server side and populates
// an rpcData struct ready to be passed to the RBAC Engine to find a matching
// policy.
func newRPCData(ctx context.Context) (*rpcData, error) {
// The caller should populate all of these fields (i.e. for empty headers,
// pipe an empty md into context).
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errors.New("missing metadata in incoming context")
}
// ":method can be hard-coded to POST if unavailable" - A41
md[":method"] = []string{"POST"}
// "If the transport exposes TE in Metadata, then RBAC must special-case the
// header to treat it as not present." - A41
delete(md, "TE")
pi, ok := peer.FromContext(ctx)
if !ok {
return nil, errors.New("missing peer info in incoming context")
}
// The methodName will be available in the passed in ctx from a unary or streaming
// interceptor, as grpc.Server pipes in a transport stream which contains the methodName
// into contexts available in both unary or streaming interceptors.
mn, ok := grpc.Method(ctx)
if !ok {
return nil, errors.New("missing method in incoming context")
}
// The connection is needed in order to find the destination address and
// port of the incoming RPC Call.
conn := getConnection(ctx)
if conn == nil {
return nil, errors.New("missing connection in incoming context")
}
_, dPort, err := net.SplitHostPort(conn.LocalAddr().String())
if err != nil {
return nil, fmt.Errorf("error parsing local address: %v", err)
}
dp, err := strconv.ParseUint(dPort, 10, 32)
if err != nil {
return nil, fmt.Errorf("error parsing local address: %v", err)
}
var authType string
var peerCertificates []*x509.Certificate
if pi.AuthInfo != nil {
tlsInfo, ok := pi.AuthInfo.(credentials.TLSInfo)
if ok {
authType = pi.AuthInfo.AuthType()
peerCertificates = tlsInfo.State.PeerCertificates
}
}
return &rpcData{
md: md,
peerInfo: pi,
fullMethod: mn,
destinationPort: uint32(dp),
localAddr: conn.LocalAddr(),
authType: authType,
certs: peerCertificates,
}, nil
}
// rpcData wraps data pulled from an incoming RPC that the RBAC engine needs to
// find a matching policy.
type rpcData struct {
// md is the HTTP Headers that are present in the incoming RPC.
md metadata.MD
// peerInfo is information about the downstream peer.
peerInfo *peer.Peer
// fullMethod is the method name being called on the upstream service.
fullMethod string
// destinationPort is the port that the RPC is being sent to on the
// server.
destinationPort uint32
// localAddr is the address that the RPC is being sent to.
localAddr net.Addr
// authType is the type of authentication e.g. "tls".
authType string
// certs are the certificates presented by the peer during a TLS
// handshake.
certs []*x509.Certificate
}