blob: 2630e9eb4ebbff3e52152b6e08f751b18e3776b8 [file] [log] [blame]
// Package grpcsp implements grpc server interceptors to apply role-based
// access control to a grpc service. It is intended to work with headers
// set by [go.skia.org/infra/kube/go/authproxy] on incoming requests.
package grpcsp
import (
"context"
"fmt"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"go.skia.org/infra/go/roles"
"go.skia.org/infra/kube/go/authproxy"
)
// ServerPolicy captures the set of authorization policies for
// a given [grpc.Server] instance, including all individual services
// registered to it.
type ServerPolicy struct {
// maps ServiceName to authorization policy
servicePolicy map[string]*ServicePolicy
}
// ServicePolicy captures the authorization policy for an individual
// grpc service.
type ServicePolicy struct {
desc grpc.ServiceDesc
allowUnauthenticated bool
allowRoles roles.Roles
rolesForMethod map[string]roles.Roles
}
// Server returns a new ServerPolicy instance.
func Server() *ServerPolicy {
ret := &ServerPolicy{
servicePolicy: map[string]*ServicePolicy{},
}
return ret
}
// Service returns a new configurable ServicePolicy.
// The policy is conservative in that anything that isn't explicitly allowed
// by the policy is denied. Calling this more than once with the same
// [grpc.ServiceDesc] results in an error.
func (sp *ServerPolicy) Service(desc grpc.ServiceDesc) (*ServicePolicy, error) {
if _, ok := sp.servicePolicy[desc.ServiceName]; ok {
return nil, fmt.Errorf("service policy already exists for %q", desc.ServiceName)
}
ret := &ServicePolicy{
desc: desc,
allowRoles: nil,
rolesForMethod: map[string]roles.Roles{},
}
for _, m := range desc.Methods {
fullPath := "/" + desc.ServiceName + "/" + m.MethodName
ret.rolesForMethod[fullPath] = nil
}
sp.servicePolicy[desc.ServiceName] = ret
return ret, nil
}
// AuthorizeUnauthenticated configures the service to allow any request, regardless
// of authentication or roles attached to the request.
func (p *ServicePolicy) AuthorizeUnauthenticated() error {
if p.allowRoles != nil {
return fmt.Errorf("allowed roles for %q have already been set", p.desc.ServiceName)
}
p.allowUnauthenticated = true
return nil
}
// AuthorizeRoles configures the policy to allow users with any of the given [role]
// values to make calls to any method. Authorize multiple roles by passing multiple role
// values. Calling this more than once results in an error.
func (p *ServicePolicy) AuthorizeRoles(r roles.Roles) error {
if p.allowRoles != nil || p.allowUnauthenticated {
return fmt.Errorf("allowed roles for %q have already been set", p.desc.ServiceName)
}
p.allowRoles = r
return nil
}
// AuthorizeMethodForRoles configures the policy to allow users with any of the given [role]
// values to make calls to [method]. Authorize multiple roles by passing multiple role
// values. Calling this more than once with the same [method] results in an error. Calling
// this with a method not included in the service description results in an error.
func (p *ServicePolicy) AuthorizeMethodForRoles(method string, r roles.Roles) error {
if p.allowUnauthenticated {
return fmt.Errorf("allowed roles for %q have already been set to allow any", p.desc.ServiceName)
}
fullPath := "/" + p.desc.ServiceName + "/" + method
rfm, ok := p.rolesForMethod[fullPath]
if !ok {
return fmt.Errorf("unknown grpc method: %q for service %q", method, p.desc.ServiceName)
}
if rfm != nil {
return fmt.Errorf("already have roles set for method: %q", method)
}
p.rolesForMethod[fullPath] = r
return nil
}
// rolesFromContext is dependent on specific headers set by skia's auth-proxy implementation.
func rolesFromContext(ctx context.Context) (roles.Roles, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.PermissionDenied, "could not authorize without metadata from incoming context")
}
user := md.Get(authproxy.WebAuthHeaderName)
if len(user) == 0 {
return nil, status.Error(codes.PermissionDenied, "could not authorize without user identity from incoming context")
}
return roles.RolesFromStrings(md.Get(authproxy.WebAuthRoleHeaderName)...), nil
}
// UnaryInterceptor returns a [grpc.UnaryServerInterceptor] that applies role checks defined
// by the policy to incoming requests. Requests that do not satisfy the policy result in
// a [codes.PermissionDenied] response code returned to the caller.
func (sp *ServerPolicy) UnaryInterceptor() grpc.UnaryServerInterceptor {
ret := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
serviceName := strings.Split(info.FullMethod, "/")[1]
p, ok := sp.servicePolicy[serviceName]
if !ok {
return nil, status.Errorf(codes.PermissionDenied, "no policy for service: %q", serviceName)
}
if p.allowUnauthenticated {
return handler(ctx, req)
}
roleSet, err := rolesFromContext(ctx)
if err != nil {
return nil, status.Error(codes.PermissionDenied, err.Error())
}
if p.allowRoles.IsAuthorized(roleSet) {
return handler(ctx, req)
}
allowedRoleSet, ok := p.rolesForMethod[info.FullMethod]
if !ok {
return nil, status.Error(codes.PermissionDenied, "unrecognized method")
}
if !allowedRoleSet.IsAuthorized(roleSet) {
return nil, status.Error(codes.PermissionDenied, "user does not have required role(s)")
}
return handler(ctx, req)
}
return ret
}